RevenueCatを使ってFlutterにアプリ内課金をさくっと導入する(iOS編)

公開:2020/11/02
更新:2020/11/03
11 min読了の目安(約10700字TECH技術記事

Flutterでアプリ内課金(非消耗型)をサクサクっと実装する方法を書きました。

審査通過済みのほぼコピペでいけるサンプルコードも載せているので、Flutterを使ってアプリ内課金をこれから実装しようとしている方の参考になれば嬉しいです。

RevenueCatとは


RevenueCatは、iOSやAndroid,Webで利用できる課金プラットフォームです。

Freeプランの場合、手数料は月$10000の売り上げに到達するまで無料。

面倒な課金レシート検証用のサーバーを用意する必要が無い。最高。。

では、登録から。

アカウント作成


名前とEmail、パスワードを設定して登録します。

Appの登録


早速アプリ追加画面になるので、アプリの情報を入力して追加します。

iTunesConnect App-Specific Shared Secretは、日本語ではApp 用共有シークレットでApp Store ConnectのApp内課金の管理画面で発行できます。
画面右側のApp 用共有シークレットのリンクを押すと、発行および表示ができます。

Entitlements/Offerings/Packages/Productsについて

Entitlements

Entitlementsは、ユーザーが持っているサブスクやアイテム購入で得られる権利を表します。
アプリ内で用意しているプランの利用権の確認に使用します。
Entitlementに紐づいたいずれかのアイテムが有効であれば、そのEntitlementが有効になります。

Offerings

Offeringsは、現在提供しているPackagesの表示パターン的なもの。
例えば通常価格の販売セットや、期間限定の販売セットなどを作れます。
コードでCurrentのOfferingを表示するようにしておけば、DashBoardで切り替えることでアプリを修正することなく、表示される課金アイテムを動的に切り替えることができます。

Packages

Packagesは、課金プロダクトを管理するグループ的なもので、Offeringsの中で設定します。

Products

Productsは、課金プロダクトそのものです。

Entitlements/Offerings/Packages/Productsの登録

広告削除の課金アイテム(非消耗型)の実装を例にします。

App Store Connectで課金アイテムを作成

App Store Connect -> App -> App内課金 -> 管理から、App内課金を作成します。

非消耗型を選択して、作成し、価格や説明文等を入力します。
審査に関する情報は、 審査提出時までは適当でOK。
必要な情報を入力してステータスが送信準備完了となればOKです。

Entitlementsを登録


ユーザーに与える権利を登録します。今回のケースでは広告の非表示という権利になります。

Productの登録


購入されたらEntitlementが有効になるProductを登録します。

Offeringの登録


アプリに表示する課金モードを登録します。
今回は一つのモードなので、defaultで1つ作成します。

Packageの登録


Offeringに登録する課金アイテムグループを登録します。
作成したら、Productを紐付けます。
今回は広告削除のみ。

これでRevenueCat側の準備は完了です。

SDKの導入

Packageのインストール

pubspec.yamlにpurchases_flutterパッケージを追加します。

dependencies:
  purchases_flutter: ^1.4.1
$ flutter pub get

iOS Deployment Targetを設定

iOSのDeployment Targetを9.0以上に設定します。
ios/Podfileに下記を設定。

platform :ios, '9.0'

XcodeのCapabilityにIn-App Purchaseを追加

プロジェクトのTARGETS -> Signing & CapabilitiesでIn-App Purchaseを追加します。

実装

RevenueCatのflutterサンプルをベースに実装したコードを貼ります。

課金画面のベースWidget

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';

class IAPScreen extends StatefulWidget {
  const IAPScreen({Key key}) : super(key: key);

  
  _IAPScreenState createState() => _IAPScreenState();
}

class _IAPScreenState extends State<IAPScreen> {
  PurchaserInfo _purchaserInfo;
  Offerings _offerings;

  
  void initState() {
    super.initState();
    initPlatformState();
  }

  // 初期化処理
  // 購入情報・Offeringsの取得を行う
  Future<void> initPlatformState() async {
    await Purchases.setDebugLogsEnabled(true);

    // SDK Keyは RevenueCatの各アプリのAPI Keysから取得できます。
    // アプリで使用しているユーザーIDと紐づける場合は、
    // await Purchases.setup("public_sdk_key", appUserId: "my_app_user_id");
    await Purchases.setup('public_sdk_key'); 

    final purchaserInfo = await Purchases.getPurchaserInfo();
    final offerings = await Purchases.getOfferings();

    if (!mounted) return;

    setState(() {
      _purchaserInfo = purchaserInfo;
      _offerings = offerings;
    });
  }

  
  Widget build(BuildContext context) {
    if (_purchaserInfo == null) {
      return Scaffold(
        appBar: AppBar(title: Text('課金画面')),
        body: const Center(
          child: Text('Loading...'),
        ),
      );
    }

    return Container(
      child: Scaffold(
        appBar: AppBar(
          title: Text('課金画面'),
        ),
        body: UpsellScreen(
          offerings: _offerings,
        ),
      ),
    );
  }
}

課金アイテム表示Widget

class UpsellScreen extends StatelessWidget {
  const UpsellScreen({Key key,  this.offerings}) : super(key: key);

  final Offerings offerings;

  
  Widget build(BuildContext context) {
    if (offerings != null) {
      // DashBoardでcurrent設定のOfferingを取得します。
      final offering = offerings.current;
      if (offering != null) {
        
        // Offeringに紐づいたPackageを取得します。
        // 今回はCustomタイプのPackageを作成したので、Package名を指定しています。
        // Monthlyなど、デフォルトで用意されているPackageを使う場合は
        // offering.monthlyで取得できます。
        final noAdsPackage = offering.getPackage('NoAds');
        if (noAdsPackage != null) {
          return Container(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: <Widget>[
                  
                  // 課金アイテムの説明
                  Text('アプリ内の広告が非表示になります'),
                  const SizedBox(height: 10),
                  
                  // 購入ボタン
                  PurchaseButton(
                    package: noAdsPackage,
                    label: '広告を削除',
                  ),
                  const SizedBox(height: 30),

                  // 復元ボタンのガイド
                  Text('すでにご購入いただいている場合は、下記の復元ボタンをタップしてください'),
                  const SizedBox(height: 10),

                  // 復元ボタン
                  RestoreButton(),
                ],
              ),
            ),
          );
        }
      }
    }
    return const Center(
      child: Text('Loading...'),
    );
  }
}

購入ボタンWidget

class PurchaseButton extends StatelessWidget {
  const PurchaseButton({Key key,  this.package,  this.label})
      : super(key: key);

  final Package package;
  final String label;

  
  Widget build(BuildContext context) {
    return FlatButton(
      padding: const EdgeInsets.all(10),
      color: Colors.grey,
      onPressed: () async {
        try {
          // 購入処理
          final purchaserInfo = await Purchases.purchasePackage(package);
          final isNoAds = purchaserInfo.entitlements.all['NoAds'].isActive;
          if (isNoAds) {
            // 購入完了時の処理
            ...

          }
        } on PlatformException catch (e) {
          final errorCode = PurchasesErrorHelper.getErrorCode(e);
          if (errorCode == PurchasesErrorCode.purchaseCancelledError) {
            print('User cancelled');
          } else if (errorCode == PurchasesErrorCode.purchaseNotAllowedError) {
            print('User not allowed to purchase');
          }
        }
      },
      child: Container(
          width: 200,
          child:
              Center(child: Text('$label   (${package.product.priceString})'))),
    );
  }
}

復元ボタン

class RestoreButton extends StatefulWidget {
  const RestoreButton({Key key}) : super(key: key);

  
  _RestoreButtonState createState() => _RestoreButtonState();
}

class _RestoreButtonState extends State<RestoreButton> {
  bool _restoring;

  
  void initState() {
    super.initState();
    setState(() {
      _restoring = false;
    });
  }

  
  Widget build(BuildContext context) {
    return FlatButton(
      padding: const EdgeInsets.all(10),
      color: Colors.grey,
      onPressed: () async {
        try {
          setState(() {
            _restoring = true;
          });
          
          // 過去の購入情報を取得
          final restoredInfo = await Purchases.restoreTransactions();

          if (restoredInfo.entitlements.all['NoAds'] != null &&
              restoredInfo.entitlements.all['NoAds'].isActive) {
            
            // 復元完了時の処理を記載
            ...

            // 復元完了のポップアップ
            var result = await showDialog<int>(
              context: context,
              barrierDismissible: false,
              builder: (BuildContext context) {
                return AlertDialog(
                  title: Text('確認'),
                  content: Text('復元が完了しました。'),
                  actions: <Widget>[
                    FlatButton(
                      child: const Text('OK'),
                      onPressed: () => Navigator.of(context).pop(1),
                    ),
                  ],
                );
              },
            );
          } else {
            // 購入情報が見つからない場合
            var result = await showDialog<int>(
              context: context,
              barrierDismissible: false,
              builder: (BuildContext context) {
                return AlertDialog(
                  title: Text('確認'),
                  content: Text('過去の購入情報が見つかりませんでした。アカウント情報をご確認ください。'),
                  actions: <Widget>[
                    FlatButton(
                      child: const Text('OK'),
                      onPressed: () => Navigator.of(context).pop(1),
                    ),
                  ],
                );
              },
            );
          }
          setState(() {
            _restoring = false;
          });
        } on PlatformException catch (e) {
          setState(() {
            _restoring = false;
          });
          final errorCode = PurchasesErrorHelper.getErrorCode(e);
          print('errorCode: $errorCode');
        }
      },
      child: Container(
        width: 200,
        child: Center(
          child: Text(_restoring
              ? '復元中'
              : '復元',
        ),
      ),
    );
  }
}

以上のコードで下記のような画面ができます。

実装時の注意

課金の動作確認は実機が必要になります。

申請時の注意

実装が終わったら、App Store ConnectのApp内課金のスクショ等を修正するのを忘れないようにしましょう。
また、申請時にApp内課金を選択するのも。

終わりに

RevenueCatのおかげで自分の中ではアプリ内課金の実装ハードルがかなり下がりました。
レシート検証をお任せできるのは大きいです。個人開発には最適。

今回iOSについて書きましたが、Androidもアプリ連携と課金アイテムの連携部分が異なるだけなので特に問題なくいけるのではと思います。

この記事が、皆さんのアプリ内課金の実装の助けになれば幸いです!

参考

RevenueCat Docs
https://docs.revenuecat.com/docs/getting-started

RevenueCat 公式Flutterサンプル
https://github.com/RevenueCat/purchases-flutter/tree/master/example

プラン一覧
https://www.revenuecat.com/pricing

Apple - In-App Purchase
https://developer.apple.com/documentation/storekit/in-app_purchase