booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Flutter開発におけるアプリ内課金で注意すべき点

アイキャッチ

自己紹介

はじめまして。株式会社ブックリスタ プロダクト開発部の横山です。

現在はモバイルアプリエンジニアとしてコミックアプリ「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」の開発を行なっています。

コミックROLLYとは

2023年8月1日にリリースされたiOS/Android向けのコミックアプリです。
フルカラーの縦読みコミック「webtoon」をはじめ、電子コミックをスマートフォンやタブレットで読むことができます。

コミックROLLY

https://rolly.jp/

はじめに

ここではFlutter開発において、iOS/Androidのアプリ内課金に対応したことについて話します。
その中でも、特に必要だったAndroid独自の対応について注目して話します。

アプリ内課金(消耗型)のシステム構成

アプリ内課金は、1回購入する度に消費する消耗型の課金アイテムを使用します。
システム構成については、アプリ、ストア(AppStoreおよびGooglePlayストア)、独自サーバーのよくある構成を取ります。
独自サーバーでは、ストア購入後のレシート検証および、課金アイテムの対価を付与する処理を実施します。

AppleのStoreKitのドキュメントにあるシステム構成図は以下のとおりです。

システム構成図

https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase

in_app_purchaseパッケージの導入

FlutterでiOS/Androidのアプリ内課金を共通的に実装するために、in_app_purchaseパッケージを使用しました。

https://pub.dev/packages/in_app_purchase

以下は、上記ページに記載されているサンプルコードの抜粋です。

消耗型課金アイテムを購入する

課金アイテムに対して、購入処理を開始します。

final ProductDetails productDetails = ... // Saved earlier from queryProductDetails().
final PurchaseParam purchaseParam = PurchaseParam(productDetails: productDetails);
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam);

購入処理の更新をlistenする

上記の購入処理に対し、購入成功やキャンセル、エラーなどのステータスを受け取れるため、対応した処理を書くことができます。

Future<void> _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
  for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
    if (purchaseDetails.status == PurchaseStatus.pending) {
      showPendingUI();
    } else {
      if (purchaseDetails.status == PurchaseStatus.error) {
        handleError(purchaseDetails.error!);
      } else if (purchaseDetails.status == PurchaseStatus.purchased ||
        purchaseDetails.status == PurchaseStatus.restored) {
        final bool valid = await _verifyPurchase(purchaseDetails);
        if (valid) {
          unawaited(deliverProduct(purchaseDetails));
        } else {
          _handleInvalidPurchase(purchaseDetails);
          return;
        }
      }
      if (Platform.isAndroid) {
        if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
          final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
          await androidAddition.consumePurchase(purchaseDetails);
        }
      }
      if (purchaseDetails.pendingCompletePurchase) {
        await _inAppPurchase.completePurchase(purchaseDetails);
      }
    }
  }
}

注意点

ストア購入成功後のサーバー処理の失敗を考慮したリトライ

課金アイテムの購入成功を受けて、独自サーバーでの課金アイテムの対価を付与することになりますが、付与が完了しなかった場合に備えてリトライできるような設計が必要です。

例えば、ネットワークエラーや、ユーザーがアプリを終了させてしまった場合などがあります。

ネットワークエラーであれば、その場でダイアログを表示してリトライを促すことができます。
しかし、アプリを終了した場合は、次回起動時にリトライできるような仕組みが必要です。

リトライ時に、保留状態になった課金アイテムは、別途取得できます。 但しiOSとAndroidとでは取得方法が異なることに注意です。

iOS

SKPaymentQueueWrapperを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_storekit パッケージが必要)

https://pub.dev/packages/in_app_purchase_storekit

final paymentQueueWrapper = SKPaymentQueueWrapper();
final transactions = await paymentQueueWrapper.transactions();
for (final transaction in transactions) {
    // 独自サーバーへの購入処理
    await deliverProduct(transaction);
    // 購入トランザクション終了
    await paymentQueueWrapper.finishTransaction(transaction);
}

Android

InAppPurchaseAndroidPlatformAdditionを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_android パッケージが必要)

https://pub.dev/packages/in_app_purchase_android

final androidAddition = InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final response = await androidAddition.queryPastPurchases();
for (final purchaseDetails in response.pastPurchases) {
    if (purchaseDetails.pendingCompletePurchase) {
        // 独自サーバーへの購入処理
        await deliverProduct(purchaseDetails);
        // 購入トランザクション終了
        await InAppPurchase.instance.completePurchase(purchaseDetails);
    }
}

尚、購入処理の更新をlistenするコード において、独自サーバーでの購入処理が失敗して未完了だった場合を考えます。
その場合、そのまま_inAppPurchase.completePurchase()を呼ぶと、購入トランザクションが終了してしまい、上記のリトライもできなくなってしまいます。
そのため、独自サーバーでの購入処理が失敗した場合は呼ばないように制御する必要があります。

逆に、独自サーバーでの購入処理が成功した場合には、リトライ時にもきちんと購入トランザクションを終了しましょう。

更にAndroidで注意すべき点

Androidでは購入アイテムの消費を明示的にする

先程の 消耗型課金アイテムを購入するコードbuyConsumable()において、第2引数のautoConsumeがデフォルトtrueになっています。

Future<bool> buyConsumable({
  required PurchaseParam purchaseParam,
  bool autoConsume = true,
})

しかし、Androidでは先程の 購入処理の更新をlistenするコード でもあったとおり、以下のとおり明示的にconsumePurchase()を呼ぶ必要があります。

if (Platform.isAndroid) {
  if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
    final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
    await androidAddition.consumePurchase(purchaseDetails);
  }
}

そのため、buyConsumable()においては、autoConsumeを使用せずに、以下のとおりiOSとAndroidで分けましょう。

final bool _kAutoConsume = Platform.isIOS || true;
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam,
 autoConsume: _kAutoConsume);

これをしないと、購入が成功した場合でも消費が正常に完了せずに、同じ課金アイテムを購入する際に「このアイテムはすでに所有しています」とエラーが出てしまう場合があります。

consumePurchaseでエラーが返る場合に備えてリトライ処理を追加する

前述のconsumePurchase()の対応を入れても、テスト中に端末によっては「このアイテムはすでに所有しています」とエラー表示されてしまう場合がありました。

それは、購入処理中に機内モードでオフラインにするなどの動作をした後、リトライのテストをした場合に限ります。

同じ条件において様々なOSバージョンで検証したところ、 Android10以下で発生し、Android11以上では発生しないという差がありました。

また、Android10以下でも、オンラインに復帰してからある程度時間が経っていた場合、もしくはDebugモードだった場合では発生しないこともありました。

Android10以下でエラーの詳細を確認したところ、リトライ時のconsumePurchase()において、SERVICE_UNAVAILABLE(エラーコード 2)が返っていることが分かりました。

デベロッパーサイトのリファレンスによると、 このエラーは一時的なもので、再試行で解決する類のエラーということが分かりました。

https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode

したがって、解決策として、 consumePurchase()が失敗した場合、非同期でconsumePurchase()のリトライを実施するようにした方がよいでしょう。

リトライ方法については、以下デベロッパーサイトのページを参考になります。

https://developer.android.com/google/play/billing/errors?hl=ja

さいごに

in_app_purchaseパッケージを活用して、iOS/Androidそれぞれのアプリ内課金を共通的に書くことができました。
しかし、それと同時にOS固有の処理が必要な部分もあり、その処理についての理解も必要だということも分かりました。
今回は消耗型の課金アイテムについて触れましたが、自動更新サブスクリプションであれば更に複雑度も増すことでしょう。
自動更新サブスクリプションの対応機会がありましたら、また注意点をシェアできればと考えています。
この記事が、これからFlutterにてアプリ内課金を対応される方の参考になれば幸いです。