12k
All articles

FlutterアプリにApp内課金を追加する方法

in_app_purchaseプラグインでFlutterアプリにアプリ内課金を追加。AppleとGoogleの設定、購入処理、取引の検証、購入の復元、安全なテスト方法を解説します。

OpenReplay Team
OpenReplay Team
FlutterアプリにApp内課金を追加する方法

iOSやAndroidでデジタルコンテンツやサブスクリプションを販売するFlutterアプリは、原則としてAppleのApp内課金システムまたはGoogle Play Billingで決済を受け付ける必要があります。この用途にStripeや独自のチェックアウトは使用できず、どちらのストアも販売ごとに手数料を徴収します。

すでにWebでStripe決済を導入しているアプリであれば、そのチェックアウトをモバイル版でも流用できると考えるかもしれません。しかしデジタルコンテンツの場合、その方針はApp Reviewでのリジェクトにつながりがちです。

Appleの標準手数料は30%です。App Store Small Business Programを利用すると、前年の収益が100万米ドル以下のデベロッパーは、有料アプリとApp内課金の両方で手数料が15%に引き下げられます。Googleには単一の料率はなく、サービス手数料のページによると、手数料を支払っているデベロッパーの99%が15%以下の料率の対象となっています。物理的な商品や、アプリ外で利用されるサービスについては、引き続きStripeが適切な選択肢です。これらのルールは、AppleのApp内課金に関するApp ReviewガイドラインとGoogleのPlay決済ポリシーに定められています。EUではデジタル市場法(DMA)により、ストアは代替決済手段を認めることが義務付けられており、AppleはEU向けの仕組みをDMAに関するデベロッパー向けページで説明しています。また米国のストアフロントでは、Appleのガイドライン上、アプリ外の購入手段へのボタンやリンクを設置することも認められるようになりました。外部決済リンクに関するルールは地域によって異なり、変更も続いているため、購入フローを設計する前に両ストアの最新ガイドラインを確認してください。

本記事では、公式のin_app_purchaseプラグインについて、商品タイプ、ストアの設定、実際に動作するサービスクラス、購入が失われる障害パターン、テストまでを一通り解説します。Flutterを初めて使う方は、まずFlutter guide for new developersをご覧ください。

重要ポイント

  • iOSおよびAndroidアプリ内で販売するデジタルコンテンツは、原則としてAppleとGoogleの課金システムを経由する必要があります。Stripeの対象は物理的な商品とアプリ外で利用されるサービスです。
  • buyConsumableとbuyNonConsumableが返すFuture<bool>は、リクエストが開始されたことを示すだけです。実際の結果はpurchaseStream上のPurchaseDetailsとして届きます。
  • pendingCompletePurchaseがtrueの購入に対してcompletePurchaseを呼び出さないと、Google Playは3日後に返金し、App Storeではトランザクションが未完了のまま残ります。
  • Appleのレビューガイドラインでは、復元可能な購入に対して復元手段を用意することが求められています。
  • コンテンツのロック解除は、バックエンドでApp Store Server APIまたはGoogle Play Developer APIを使ってserverVerificationDataを検証した後にのみ行ってください。

消耗型・非消耗型・サブスクリプション

Flutterのin_app_purchaseプラグインは、消耗型、非消耗型、サブスクリプションの3種類の商品タイプをサポートしています。消耗型にはbuyConsumableを使用し、非消耗型とサブスクリプションにはどちらもbuyNonConsumableを使用します。商品タイプによって、アプリ側で何を記録しておく必要があるかも決まります。

タイプ購入メソッド例アプリが管理すべきもの復元可能か
消耗型buyConsumable100コイン残高(バックエンドに保存)不可
非消耗型buyNonConsumableプレミアム機能の解放エンタイトルメントのフラグ可
サブスクリプションbuyNonConsumable月額Proプラン有効期限と更新状態(サーバー検証から取得)可

ストアが記録を保持してくれないのは消耗型だけです。コインパックが消費されると、残高の記録は自社のバックエンドにしか存在しません。非消耗型のエンタイトルメントは復元によっていつでも再構築できます。サブスクリプションの状態は更新、失効、返金によって時間とともに変化するため、ローカルにキャッシュしたフラグではなく、検証済みのサーバーデータから読み取るようにしてください。

作業の大半はストアの設定

FlutterでApp内課金を導入する際、作業の大部分はApp Store ConnectとPlay Consoleでの設定に費やされます。Dart側のコードは短く済みます。両ストアの設定が完了するまでは、コードがどれだけ正しくてもqueryProductDetailsは何も返しません。

Apple(App Store Connect):

  1. 有料App契約に署名し、口座情報と税務情報を入力します。
  2. premium_unlockのようなプロダクトIDで各App内課金アイテムを作成します。サブスクリプションはサブスクリプショングループ内に作成します。
  3. メタデータを入力し、各商品のステータスを「提出準備完了(Ready to Submit)」にします。

Google(Play Console):

  1. テストトラックにビルドをアップロードします。内部テストトラックで問題ありません。
  2. 商品を作成します。Appleと同じIDを使用するか、コード内でマッピングします。
  3. 商品を有効化します。Googleは、商品を公開しないとテストできないと注記しています。

queryProductDetailsの結果でnotFoundIDsにIDが含まれている場合、原因はほぼ必ずDartではなくストアの設定にあります。典型的な原因は、IDの不一致、商品が有効化されていない、有料App契約が未署名、Playのテストトラックにビルドがアップロードされていない、などです。

FlutterのApp内課金コード:ストリーム、購入、完了処理

FlutterのApp内課金フローは4つのステップで構成されます。起動時にpurchaseStreamを購読し、isAvailableを確認し、queryProductDetailsで商品を読み込み、購入メソッドを呼び出します。結果はすべてストリームのリスナーで処理します。

依存関係を追加します。

dependencies:
  in_app_purchase: ^3.3.1

in_app_purchaseパッケージは、Android SDK 24以上、iOS 13以上、macOS 10.15以上をサポートしています。StoreKitとPlay Billingの実装はendorsedなフェデレーテッドパッケージとして自動的に取り込まれるため、プラットフォーム固有のクラスを使う場合を除き、自分で追加する必要はありません。Appleプラットフォームでは、対応デバイス上でStoreKit 2がデフォルトになっています。

buyConsumableとbuyNonConsumableは購入情報を返しません。よくある誤りは次のようなものです。

// Wrong: the Future resolves to a bool, not a purchase
final result = await iap.buyNonConsumable(purchaseParam: param);
if (result) unlockPremium(); // true only means the sheet opened

正しい実装では、boolを「リクエストが開始された」という意味として扱い、付与処理はすべてストリームのリスナーで行います。

final started = await iap.buyNonConsumable(purchaseParam: param);
if (!started) showError('Could not start purchase');
// Unlocking happens in the purchaseStream handler

以下は、runAppの前にmain()内で購読を開始するサービスの全体像です。プラグインのREADMEではアプリのinitState内での購読が推奨されていますが、main()で行えばそれより一段階早くなり、プラグインが更新を送出する前に確実にリスナーが存在する状態にできます。

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:in_app_purchase/in_app_purchase.dart';

class PurchaseService {
  PurchaseService._();
  static final instance = PurchaseService._();

  static const coins100 = 'coins_100';
  static const premium = 'premium_unlock';

  final InAppPurchase _iap = InAppPurchase.instance;
  StreamSubscription<List<PurchaseDetails>>? _sub;

  void start() {
    _sub ??= _iap.purchaseStream.listen(
      _onPurchases,
      onDone: () => _sub?.cancel(),
      onError: (Object e) {/* log */},
    );
  }

  Future<List<ProductDetails>> loadProducts() async {
    if (!await _iap.isAvailable()) return [];
    final res = await _iap.queryProductDetails({coins100, premium});
    if (res.notFoundIDs.isNotEmpty) {/* store setup problem */}
    return res.productDetails;
  }

  Future<bool> buy(ProductDetails p) {
    final param = PurchaseParam(productDetails: p);
    return p.id == coins100
        ? _iap.buyConsumable(purchaseParam: param)
        : _iap.buyNonConsumable(purchaseParam: param);
  }

  Future<void> restore() => _iap.restorePurchases();

  Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
    for (final p in purchases) {
      var finish = true;
      switch (p.status) {
        case PurchaseStatus.pending:
          // show "waiting for approval"
          break;
        case PurchaseStatus.purchased:
        case PurchaseStatus.restored:
          final valid = await _verifyOnServer(p); // null = unreachable
          if (valid == null) {
            finish = false; // redelivered next launch
          } else if (valid) {
            await _deliver(p);
          }
        case PurchaseStatus.error:
          // show p.error?.message
          break;
        case PurchaseStatus.canceled:
          // no error UI
          break;
      }
      if (finish && p.pendingCompletePurchase) {
        await _iap.completePurchase(p);
      }
    }
  }

  Future<bool?> _verifyOnServer(PurchaseDetails p) async {
    // POST p.verificationData.serverVerificationData,
    // p.productID and platform to your backend
    return true;
  }

  Future<void> _deliver(PurchaseDetails p) async {/* credit coins / set flag */}
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  PurchaseService.instance.start();
  runApp(const MyApp());
}

_verifyOnServerはバックエンド呼び出しのプレースホルダーです。サーバーに到達できない場合はnullを返すため、購入は未完了のまま残り、次回起動時に再度届きます。ただし、これが安全なのはAndroidの3日間の猶予期間内に解決される場合に限られます。

ステータスアプリの処理completePurchaseを呼ぶか
pending待機状態を表示する(例:承認と購入のリクエスト、決済に時間がかかる支払い方法)呼ばない。最終ステータスを待つ
purchasedサーバーで検証してから付与するpendingCompletePurchaseがtrueなら呼ぶ
restored検証してからエンタイトルメントを再付与するpendingCompletePurchaseがtrueなら呼ぶ
errorerror.messageを表示するpendingCompletePurchaseがtrueなら呼ぶ
canceled何も表示せずペイウォールに戻るpendingCompletePurchaseがtrueなら呼ぶ

App内課金でよくある失敗

購入の消失やアプリのリジェクトにつながる代表的な誤りは4つあります。復元ボタンがない、ストリームの購読開始が遅すぎる、completePurchaseを呼んでいない、クライアントを信頼している、の4点です。

復元ボタンがない。 Appleのガイドライン3.1.1では、復元可能なApp内課金を復元する手段を提供するよう求めています。復元された購入はPurchaseStatus.restoredとしてpurchaseStreamに届くため、ボタンはrestorePurchases()を呼ぶだけで済み、アクセス権の付与は既存のハンドラーが行います。ペイウォールと設定画面の両方に配置しましょう。

TextButton(
  onPressed: PurchaseService.instance.restore,
  child: const Text('Restore Purchases'),
)

プッシュ通知やマーケティング用リンクからペイウォールを開く場合は、Deep Linking in Flutterを参考にルーティングし、復元ボタンに常にワンタップでアクセスできるようにしてください。

リスナーの登録が遅すぎる。 アプリが閉じている間に完了した購入(承認された「承認と購入のリクエスト」や処理に時間のかかる決済など)は、次回アプリ起動時にストリームに届きます。ユーザーが到達しない画面でリスナーを登録していると、その更新は処理されません。ユーザーは支払った対価を受け取れず、さらにAndroidでは完了処理されなかった購入は3日後に返金されます。

完了処理をしていない。 購入のpendingCompletePurchaseがtrueなのにcompletePurchaseを一度も呼ばないと、Google Playは3日後に返金し、App Storeではトランザクションが未完了のまま残ります。

クライアントを信頼している。 アプリが受け取ったステータスだけを根拠にコンテンツをロック解除してはいけません。verificationData.serverVerificationDataをバックエンドに送信し、AppleプラットフォームではApp Store Server API、AndroidではGoogle Play Developer APIを使ってサーバー側で検証してください。StoreKit 2では、serverVerificationDataは署名付きのJWSトランザクションです。サーバー側では署名を検証するか、App Store Server APIでトランザクションを照会するか、あるいはその両方を行う必要があります。検証リクエスト自体は通常のバックエンド呼び出しです。バックエンドでGraphQLを使っている場合は、Consuming GraphQL APIs with Flutterで接続方法を解説しています。

ペイウォールのデザインや、RevenueCatのようなマネージド型サブスクリプションSDKの採用は、このフローの上に乗る別個の検討事項です。

実際のお金を使わずに購入をテストするには?

AppleとGoogleはそれぞれ、App内課金用のテスト環境を提供しています。AppleにはSandboxアカウントとXcodeのStoreKit構成ファイルがあり、GoogleにはPlayのテストトラックで利用するライセンステスターがあります。

  • AppleのSandboxテスター: App Store ConnectでSandboxアカウントを作成し、デバイス上でサインインします。Sandboxでの購入は、App Store Connectに登録した実際の商品を使いつつ、誰にも課金されません。
  • StoreKit構成ファイル: XcodeのStoreKit構成ファイルを使えば、ローカルで商品を定義し、App Store Connectの設定が完了する前からiOSシミュレーターで購入をテストできます。
  • Playのライセンステスター: Play Consoleで「設定」>「ライセンス テスト」を開き、Googleアカウントを追加します。ライセンステスターによるテスト購入は、そのアカウントに課金されません。Googleの課金テストガイドでは、テストトラックのテスターであってもライセンステスターでなければ実際に料金が発生すると警告しています。

申請前に、両プラットフォームで新規インストールからの動作と復元をテストしてください。

リリースに向けて

FlutterのApp内課金フローを正しく実装するためのポイントは、両ストアを設定すること、他の処理より先にpurchaseStreamを購読すること、すべての購入をサーバーで検証すること、そしてpendingCompletePurchaseが設定されたすべての購入を完了処理することです。次のステップとして、各コンソールで商品を1つ作成し、SandboxアカウントとライセンステスターでテストしてSandbox、その商品がnotFoundIDsではなくproductDetailsに表示されることを確認しましょう。

よくある質問

in_app_purchaseでAndroidのサブスクリプションをアップグレード/ダウングレードするには?

ChangeSubscriptionParamを含むGooglePlayPurchaseParamを指定してbuyNonConsumableを呼び出します。ChangeSubscriptionParamにはユーザーの既存のGooglePlayPurchaseDetailsを渡し、必要に応じてReplacementMode.withTimeProrationなどのReplacementModeを指定します。これらのクラスはin_app_purchase_androidに含まれているため、使用する場合は同パッケージをインポートしてください。iOSではこの手順は不要です。同じサブスクリプショングループ内のサブスクリプションは互いに置き換わる仕組みになっているため、切り替えはApp Storeが処理します。

in_app_purchaseプラグインはFlutter Webで動作しますか?

動作しません。このプラグインは背後にストアが必要で、サポートしているのはApp Store(iOSおよびmacOS)とGoogle Play(Android)のみです。Flutter Webのビルドには対応するストアが存在しないため、Webでの販売にはStripeなどのWeb決済サービスが必要です。Stripeに関するプラットフォームの制限は、iOSおよびAndroidアプリ内で販売されるデジタルコンテンツに適用されるものであり、Web上での購入には適用されません。

in_app_purchaseとRevenueCatのpurchases_flutterの違いは何ですか?

in_app_purchaseが担うのはストアとの接続のみです。サーバー側での検証、エンタイトルメントの保存、サブスクリプション状態の管理は自社のバックエンドに任されます。RevenueCatのpurchases_flutterは、StoreKitとGoogle Play BillingをRevenueCatのホスト型バックエンドと組み合わせてラップしており、サーバー側でのレシート検証、サブスクリプション状態の追跡、Webhookが追加されます。どちらのパッケージもAppleとGoogleの課金システムを経由するため、いずれを選んでもストアが徴収する手数料は同じです。

サブスクリプションの価格を値上げすると、既存の購読者はどうなりますか?

ストア、地域、値上げ幅によって異なります。値上げによっては購読者の同意が必要で、同意が得られない場合はサブスクリプションが更新されなくなります。また、両ストアとも一定の値上げについては通知のみで実施することを認めています。iOSでは、StoreKitがデフォルトでアプリ内に価格変更への同意シートを表示します。表示をより適切なタイミングまで遅らせる方法はin_app_purchaseのREADMEで説明されています。Androidでは、Google Playがメールと通知で購読者に変更を知らせるため、Dartのコードで通知をトリガーする必要はありません。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.