StoreKit 2によるiOSのアプリ内課金のチュートリアル

実際に動作するサンプルコードが付属したアプリ内課金のステップ・バイ・ステップ形式の解説です。

StoreKit 2 tutorial: implementing in-app purchases in a SwiftUI app
Kishikawa Katsumi

Kishikawa Katsumi

PublishedLast updated

この StoreKit 2 チュートリアルにはサンプルコードとサンプルアプリが提供されており、以下のURLからダウンロードできます。https://github.com/RevenueCat/storekit2-demo-app.

はじめに

アプリ内課金とサブスクリプションはApp Storeで収益を上げるための最適な方法の一つです。Appleが新たにアップデートしたStoreKit 2は、アプリ内課金のためのフレームワークで、開発者はこれを利用してiOS、macOS、watchOS、tvOSのアプリにIAP(アプリ内課金)を追加できます。AppleのドキュメントにはStoreKitの使い方に関して基本的な説明がありますが、複雑な部分の詳細や完全な使用例は提供されていません。

このチュートリアルでは、基本的なコンセプト、App Store Connectの設定、StoreKit 2の導入方法、購入の表示、完了、確認の方法、そしてアプリ外で発生する変更(更新、キャンセル、課金の問題など)の対応方法について解説します。さらに、サーバーを持つかどうかの利点とトレードオフについても説明します。Swiftによるサンプルコードと実行可能なサンプルアプリも提供します。

用語について

アプリ内課金(IAP)とは、アプリ内で使用するために購入するデジタル製品のことです。これらの購入は通常アプリ内で直接行われますが、App Store経由で購入することも可能です。

Appleプラットフォームでは4種類のIAPが提供されています。

  • 消耗型
    ゲームをさらに進めるためのライフや宝石、デートアプリで自分のプロフィールの表示頻度を向上するためのブースト、ソーシャルメディアアプリでクリエイターが活用できるデジタルヒントなど、異なるタイプの消耗型アイテムを提供できます。消耗型のアプリ内課金は一度使うとなくなり、再度購入することが可能です。フリーミアムビジネスモデルを使うアプリやゲームでよく提供されています。

    消耗品は、一度か複数回購入でき、大量に利用される商品です。例えば、ゲーム内の「ライフ」や「ジェム」、出会い系アプリの「ブースト」、開発者やクリエイター向けの「ヒント」などがあります。
  • 非消耗型
    一度購入すれば無期限に使用できる、非消耗型のプレミアム機能を提供することができます。たとえば、写真アプリの追加フィルタ、イラスト作成アプリの追加ブラシ、ゲームのコスメティックアイテムなどがあります。非消耗型のアプリ内課金では、ファミリー共有を提供することができます。

    非消費型商品は、一度しか購入できない永続的な商品です。これには、アプリのプレミアム機能のアンロック、ペイントアプリの追加ブラシ、ゲームのコスメティックアイテムなどが含まれます。
  • 自動更新サブスクリプション
    アプリのコンテンツ、サービス、プレミアム機能への継続的なアクセスを提供できます。自動更新サブスクリプションでは、ユーザーがキャンセルするか何らかの問題が発生するまで定期的に課金が行われます。一般的なユースケースには、メディアやコンテンツのライブラリ(ビデオ、音楽、記事など)、サービスとしてのソフトウェア(クラウドストレージ、仕事効率化、グラフィックス、デザインなど)、教育コンテンツなどへのアクセスがあります。自動更新サブスクリプションでは、ファミリー共有を提供することができます。

    自動更新可能なサブスクリプションは、アプリ内のコンテンツやサービス、プレミアム機能への継続的なアクセスを提供します。顧客は、キャンセルするか課金問題が発生するまで定期的に課金されます。例としては、ソフトウェアサービスへのアクセスや教育アプリのレッスンなどがあります。
  • 非自動更新サブスクリプション
    ゲーム内コンテンツのシーズンパスなど、期間限定のサービスやコンテンツを提供することができます。このタイプのサブスクリプションは自動的に更新されないため、アクセスの継続を希望する場合は、ユーザー自身が都度購入する必要があります。

    更新不要のサブスクリプションは、自動更新なしで、アプリ内のコンテンツやサービス、プレミアム機能への期間限定アクセスを提供します。このタイプのサブスクリプションでは、サービスやコンテンツへのアクセスを継続するためにユーザーが手動で再購読する必要があります。例としては、ゲーム内コンテンツのシーズンパスが挙げられます。

App Store Connectの設定​​

アプリにアプリ内課金を追加する最初の手順は、App Store Connectで商品を作成することです。

アプリ内課金の作成と管理には、「アプリ内課金」と「サブスクリプション」という2つのセクションがあります。「アプリ内課金」セクションでは、消耗型と非消耗型を扱います。一方、「サブスクリプション」セクションでは、自動更新サブスクリプションと非自動更新サブスクリプションを管理します。サブスクリプションは、消耗型や非消耗型と比べて設定が複雑なため別のセクションに分離されました。

App Store Connectの管理画面で「アプリ内課金」と「サブスクリプション」の場所を示しています。

App Store Connectの要件

App Store Connectでアプリがアプリ内課金を販売するためには、以下の管理手続きを完了する必要があります。

自動更新および非自動更新サブスクリプションの作成

  1. 「サブスクリプション」を選択
  2. 「サブスクリプショングループ​​」を作成
  3. 「サブスクリプショングループ」のローカリゼーション​​を追加する​​
  4. 新しいサブスクリプションを作成する(参照名と製品ID)​​
  5. すべてのメタデータを記入する(期間、価格、ローカリゼーション、審査に関する情報)​​

自動更新と非自動更新のサブスクリプションを作成するApp Store Connectの画面

消耗型および非消耗型の作成

  1. 「アプリ内課金」を選択
  2. 「アプリ内課金」を作成する
  3. タイプ(消耗型または非消耗型)を選択し、参照名と製品IDを設定する
  4. すべてのメタデータを記入する(期間、価格、ローカリゼーション、審査に関する情報)

消耗型と非消耗型のアプリ内課金を作成するApp Store Connectの画面

StoreKit Configuration Fileをセットアップする

App Store Connectで商品を設定する作業は大変ですが、アプリ内課金(IAP)付きアプリをリリースするためには不可欠です。しかし、ローカル開発にはApp Storeは必要ありません。Xcode 13からはアプリ内課金の全ワークフローをStoreKit Configuration Fileを使って行うことができます。

StoreKit Configuration Fileの利用には、App Store Connectへのログインを遅らせるだけでなく、以下のような用途もあります:

  • シミュレーターでの購入フローのテスト
  • 単体テストやUIテストでの購入フローのテスト
  • ネットワーク接続がない場合のローカルテスト
  • サンドボックス環境での設定や、エッジケースのデバッグ
  • トラブル、更新、課金問題、プロモーションオファー、紹介オファーを含むエンドツーエンドのトランザクションテスト

実際、このチュートリアルに添付されているサンプルコードとサンプルアプリは、StoreKit設定ファイルを使用しています。これらをダウンロードして実行すると、App Store Connectでの設定は不要です。

StoreKit Configuration Fileの全機能については、以下のWWDCセッションが詳しく説明しています:

また、iOS 14のStoreKitテストの改善に関する記事でこれらのWWDCセッションの内容を簡潔にまとめています。

次に、StoreKit Configuration Fileの作成、設定、有効化の手順の概要を説明します。

Create the file

  1. Xcodeを起動して、メニューバーから「File」>「New」>「File…」と選択します。
  2. 検索フィールドに「storekit」と入力します。
  3. 「StoreKit Configuration File」を選択します。
  4. 適当なファイル名を入力して「Sync this file with an app in App Store Connect 」をチェックして保存します。

製品の追加(任意)

Xcode 14ではこのファイルをApp Store Connectのアプリと同期する機能が追加されました。製品を手作業でStoreKit設定ファイルに追加しなくてすみます。

Xcode 13を使用している場合や異なる製品タイプや期間でテストしたい場合は下記の手順で製品を追加できます。

  1. XcodeでStoreKit Configuration Fileを選択し左下の「+」ボタンをクリックします。
  2. アプリ内課金の種類を選択します。
  3. 下記の必要事項を記入します。
  • 参照名
  • 製品ID
  • 価格
  • 1つ以上のローカリゼーション

StoreKit Configuration Fileを有効にする

StoreKit Configuration Fileを作成しただけではまだ使用できません。XcodeのSchemeでStoreKit Configuration Fileを選択する必要があります。

  • Scheme名をクリックして「Edit Scheme…」を選択します。
  • 「Run」>「Options」と選択します。
  • 「StoreKit Configuration」でファイルを選択します。

StoreKit Configuration Fileを使用することはアプリ内課金をテストする最も簡単な方法ですが、デバイス上でサンドボックス環境をテストする必要がある場合もあります。もともとのSchemeを複製して「YourApp (SK Config)」のような名前でStoreKit Configuration File専用のSchemeにすることを推奨します。そうしておくと、StoreKit Configuration Fileを使用するかどうかを簡単に切り替えられます。

StoreKit 2を利用してデバイス上でアプリ内課金を実装する​​

StoreKit Configuration Fileを使うか実際のApp Store Connectのデータを使用するかに関係なく、アプリ内課金における製品はStoreKit APIを使う際の最初のステップです。

このセクションではStoreKit 2を使用するコードをステップ・バイ・ステップで解説します。

  • List products
  • Purchase products
  • Unlock features for active subscriptions and lifetime purchases
  • Handle renewals, cancellations, and billing errors
  • Validate receipts

上記のそれぞれの手順はバックエンドサーバーを使わない方法で実装します。バックエンドサーバーを使わずにアプリ内課金の処理を実行することはStoreKit 1では難しく、安全ではありませんでした。AppleはStoreKit 2でそれを可能にし、安全にするために大きな改良を加えました。バックエンドサーバーで購入処理を行うには、デバイスだけで実行するより多くの作業が必要になりますが、利点もたくさんあります。

では、はじめましょう。

手順1:商品の一覧を表示する

まずアプリ内課金に登録した製品をボタンとして表示します。ボタンをタップすると製品を購入できます。下記の例では「月額課金」「年額課金」「終身サブスクリプション課金」の各製品が表示されます。この手順を実行すると表示される画面は下記になります。

サンプルアプリの製品一覧画面

StoreKit 2では製品データの取得に必要なコードはほんの数行だけです。

1import StoreKit
2
3let productIds = ["pro_monthly", "pro_yearly", "pro_lifetime"]
4let products = try await Product.products(for: productIds)

上記のコードの意味は、まずStoreKitをインポートします。次に画面に表示する製品IDを文字列の配列として定義します。最後にAPIから製品データを取得します。製品IDはStoreKit Configuration Fileまたは App Store Connect で定義された商品と一致する必要があります。結果としてProductオブジェクトの配列が得られます。Productオブジェクトには画面のボタンに表示するために必要な情報がすべて含まれています。またProductオブジェクトはボタンがタップされて購入の処理が発生するときにも使用されます。

この例では製品IDをハードコーディングしていますが、実際のアプリではハードコーディングすべきではありません。製品IDをハードコーディングしてしまうと、製品の追加や変更のたびにアプリのアップデートが必要になります。すべてのユーザーが自動アップデートを有効にしているわけではないので、すでに削除した製品などが購入されるおそれがあります。リモートサーバーから購入可能な製品の一覧を提供することがもっとも良い方法です。

次に、取得した製品をビューに表示します。下記は@Stateを付与したproductsという変数に取得した製品を保持するSwiftUIのビューです。

1import SwiftUI
2import StoreKit
3
4struct ContentView: View {
5    let productIds = ["pro_monthly", "pro_yearly", "pro_lifetime"]
6
7    @State
8    private var products: [Product] = []
9
10    var body: some View {
11        VStack(spacing: 20) {
12            Text("Products")
13            ForEach(self.products) { product in
14                Button {
15                    // Don't do anything yet
16                } label: {
17                    Text("\(product.displayPrice) - \(product.displayName)")
18                }
19            }
20        }.task {
21            do {
22                try await self.loadProducts()
23            } catch {
24                print(error)
25            }
26        }
27    }
28
29    private func loadProducts() async throws {
30        self.products = try await Product.products(for: productIds)
31    }
32}

こちらのコードでは製品データを取得する処理を新しくloadProducts()関数に移動しています。この関数は.task()モディファイアを使用してビューが表示されたときに呼び出されます。取得した製品はForEachループを使って取り出されて各製品のボタンが表示されます。このボタンはまだタップしても何もしません。

ここまでのコードの完全な実装はこちらをご覧ください。

https://github.com/RevenueCat/storekit2-demo-app/tree/main/StepByStepExamples/Step1

手順2:商品の購入

ここまでで、各製品が画面に一覧として表示されるようになりました。次の手順はボタンをタップすると製品を購入できるようにすることです。

製品に対して購入の処理を開始するには、製品オブジェクトのpurchase()関数を呼び出します。

1private func purchase(_ product: Product) async throws {
2    let result = try await product.purchase()
3}

この関数がProduct.PurchaseErrorまたはStoreKitErrorをthrowした場合、購入が成功しなかったことを示します。ただし、エラーが発生しなくても購入が成功したとはまだ限りません。購入処理が成功したかどうかを判断するには関数の戻り値を検証する必要があります。

purchase()の戻り値はenum Product.PurchaseResult型です。PurchaseResultの定義は下記になります。

1public enum PurchaseResult {
2    case success(VerificationResult<Transaction>)
3    case userCancelled
4    case pending
5}
6
7public enum VerificationResult<SignedType> {
8    case unverified(SignedType, VerificationResult<SignedType>.VerificationError)
9    case verified(SignedType)
10}

PurchaseResultは製品の購入処理で起こりうるすべての結果を表しています。PurchaseResultを検証するように更新したコードを下記に示します。

1private func purchase(_ product: Product) async throws {
2    let result = try await product.purchase()
3
4    switch result {
5    case let .success(.verified(transaction)):
6        // Successful purhcase
7        await transaction.finish()
8    case let .success(.unverified(_, error)):
9        // Successful purchase but transaction/receipt can't be verified
10        // Could be a jailbroken phone
11        break
12    case .pending:
13        // Transaction waiting on SCA (Strong Customer Authentication) or
14        // approval from Ask to Buy
15        break
16    case .userCancelled:
17        // ^^^
18        break
19    @unknown default:
20        break
21    }
22}

PurchaseResultの値の種類と意味は下記の表をご覧ください。

説明
Success – verified製品の購入処理が成功しました。
Success – unverified製品の購入処理は成功しましたが、StoreKitの検証が失敗しました。この状態は脱獄したデバイス上で実行されたことが原因の可能性があります。しかしStoreKitのドキュメントにこの状態についての記載はなく、詳細は不明です。
Pending強力な顧客認証(SCA:Strong Customer Authentication)」または「承認と購入のリクエスト」のいずれかによって発生します。「強力な顧客認証」は購入処理が完了する前に金融機関が求める追加の確認や承認のプロセスです。このプロセスはアプリやSMSのテキストメッセージを通じて行われます。承認された後に購入処理のトランザクションが更新されます。「承認と購入のリクエスト」は、子どもがアプリ内課金で製品を購入しようとした際に、親や保護者の承認を必要とする機能です。保護者が購入を承認または却下するまで、購入処理は保留状態になります。
User Canceledユーザーが購入処理をキャンセルしました。通常はこの値をエラーとして扱う必要はありません。キャンセルが発生したことを記録できるようにしておくとアプリの改善に役立ちます。
ErrorErrorがthrowされた場合の値はProduct.PurchaseErrorまたはStoreKitErrorです。インターネットに接続できない、App Storeに障害が発生している、クレジットカードの支払いに問題が発生した、などの原因が考えられます。

ボタンをタップすると購入処理が実行されるようにボタンのActionクロージャ内でpurchase(_ product: Product)関数を呼び出します。

1ForEach(self.products) { (product) in
2    Button {
3        Task {
4            do {
5                try await self.purchase(product)
6            } catch {
7                print(error)
8            }
9        }
10    } label: {
11        Text("\(product.displayPrice) - \(product.displayName)")
12    }
13}

ここまでのコードの完全な実装はこちらをご覧ください。

https://github.com/RevenueCat/storekit2-demo-app/tree/main/StepByStepExamples/Step2

手順3:有料の機能を解放する処理の準備

ここまでで、購入可能なすべての製品(今回の例では、2種類の定期サブスクリプションと終身サブスクリプション)を一覧に表示し、ボタンをタップするとその製品を購入できるようになりました。しかし購入処理が実行されるだけでは完成とはいえません。製品の購入に成功するとなんらかの機能が解放されたり、サブスクリプションが有効な間に定期的にコンテンツをダウンロードする必要があるかもしれません。

購入後のコンテンツのダウンロードや機能の解放を実装しようとすると、コードが複雑になってきます。購入が成功した後に機能を解放する処理を追加するだけでいいのであれば簡単ですが、たいていはそれだけでは十分ではありません。アプリから購入する以外にも複数の購入フローがあり、前の手順ではそのうちの2つである「強力な顧客認証」と「承認と購入のリクエスト」について説明しました。アプリ内課金は、直接App Storeアプリから購入することもできるので、アプリの外部でも発生します。購入はいつでも起こりうるので、アプリはあらゆる状況に対応できるように準備しなければなりません。

これらのすべてのケースを扱い始める前に既存の実装をクリーンアップする必要があります。現在すべてのアプリ内課金のロジックはSwiftUIビュー内に存在します。これはエンドツーエンドの購入フローを動作させようとするときには問題ありませんでしたが、アプリの規模が大きくなるにつれてうまくスケールしません。すべてのアプリ内課金のロジックはビューではなく再利用可能なコンポーネントに移動するべきです。これにはさまざまな方法があるのでアプリによってやり方は異なりますが、このステップではアプリ内課金のロジックを新しくPurchaseManagerに移動します。PurchaseManagerは、最初は製品データの読み込みと製品の購入処理を担当します。他の必要な機能は後ほど追加していきます。

1import Foundation
2import StoreKit
3
4@MainActor
5class PurchaseManager: ObservableObject {
6
7    private let productIds = ["pro_monthly", "pro_yearly", "pro_lifetime"]
8
9    @Published
10    private(set) var products: [Product] = []
11    private var productsLoaded = false
12
13    func loadProducts() async throws {
14        guard !self.productsLoaded else { return }
15        self.products = try await Product.products(for: productIds)
16        self.productsLoaded = true
17    }
18
19    func purchase(_ product: Product) async throws {
20        let result = try await product.purchase()
21
22        switch result {
23        case let .success(.verified(transaction)):
24            // Successful purhcase
25            await transaction.finish()
26        case let .success(.unverified(_, error)):
27            // Successful purchase but transaction/receipt can't be verified
28            // Could be a jailbroken phone
29            break
30        case .pending:
31            // Transaction waiting on SCA (Strong Customer Authentication) or
32            // approval from Ask to Buy
33            break
34        case .userCancelled:
35            // ^^^
36            break
37        @unknown default:
38            break
39        }
40    }
41}

loadProducts()とpurchase()関数をPurchaseManagerに移動します。ContentViewではPurchaseManagerを使用します。 PurchaseManagerはAppで作成されて、EnvironmentObjectとしてContentViewに渡されます。この方法では他にSwiftUIのビューが増えても、同じPurchaseManagerオブジェクトに簡単にアクセスできます。

1struct YourApp: App {
2    @StateObject
3    private var purchaseManager = PurchaseManager()
4
5    var body: some Scene {
6        WindowGroup {
7            ContentView()
8                .environmentObject(purchaseManager)
9        }
10    }
11}

PurchaseManagerはObservableObjectなので、プロパティが変更されるとSwiftUIのビューは自動的に再描画されます。

1struct ContentView: View {
2    @EnvironmentObject
3    private var purchaseManager: PurchaseManager
4
5    var body: some View {
6        VStack(spacing: 20) {
7            Text("Products")
8            ForEach(purchaseManager.products) { product in
9                Button {
10                    Task {
11                        do {
12                            try await purchaseManager.purchase(product)
13                        } catch {
14                            print(error)
15                        }
16                    }
17                } label: {
18                    Text("\(product.displayPrice) - \(product.displayName)")
19                        .foregroundColor(.white)
20                        .padding()
21                        .background(.blue)
22                        .clipShape(Capsule())
23                }
24            }
25        }.task {
26            Task {
27                do {
28                    try await purchaseManager.loadProducts()
29                } catch {
30                    print(error)
31                }
32            }
33        }
34    }
35}

アプリの実行結果はステップ2とまったく変わりません。ですがアプリ内課金のユースケースを増やす際に、ステップ2と比べてコードが管理しやすくなりました。

ここまでのコードの完全な実装はこちらをご覧ください。

https://github.com/RevenueCat/storekit2-demo-app/tree/main/StepByStepExamples/Step3

手順4:有料の機能を解放する

これからPurchaseManagerに有料の機能を解放するためのロジックを記述していきます。有料の機能の購入の判定についてはStoreKit 2のTransaction.currentEntitlementsプロパティを主に利用します。

1for await result in Transaction.currentEntitlements {
2    // Do something with transaction
3}
4

currentEntitlementsという名前は、StoreKit 2のそれ以外のAPIの命名からすると少しわかりにくいです。実際にはTransaction.currentEntitlementsは有効なトランザクションの配列を返します。currentEntitlementsのドキュメントでは次のように説明されています。

  • 非消耗型の製品における購入トランザクション
  • 有効な自動更新サブスクリプションにおける最新のトランザクション
  • 非自動更新サブスクリプションにおける最新のトランザクション non-renewing subscription
  • 消耗型の製品におけるまだ完了していないトランザクション

PurchaseManagerは各トランザクションをループして製品IDをpurchasedProductIDsという Set(重複した要素を含むことができない配列)に格納します。この後で有効であることが確認できた製品IDに対応する有料の機能やコンテンツを解放します。

1class PurchaseManager: ObservableObject {
2
3    ...
4
5    @Published
6    private(set) var purchasedProductIDs = Set<String>()
7
8    var hasUnlockedPro: Bool {
9       return !self.purchasedProductIDs.isEmpty
10    }
11
12    func updatePurchasedProducts() async {
13        for await result in Transaction.currentEntitlements {
14            guard case .verified(let transaction) = result else {
15                continue
16            }
17
18            if transaction.revocationDate == nil {
19                self.purchasedProductIDs.insert(transaction.productID)
20            } else {
21                self.purchasedProductIDs.remove(transaction.productID)
22            }
23        }
24    }
25}
26

ここで新しく追加したupdatePurchasedProducts関数は、アプリの起動時、購入後、およびトランザクションが更新されたときに呼び出され、purchasedProductIDs(およびhasUnlockedPro)プロパティが正しい値を返すように実装する必要があります。

まず、アプリの起動時にupdatePurchasedProducts()を呼び出すようにアプリを更新します。これによりpurchasedProductIDsが起動時のcurrentEntitlementsの状態で初期化されます。

1struct YourApp: App {
2    @StateObject
3    private var purchaseManager = PurchaseManager()
4
5    var body: some Scene {
6        WindowGroup {
7            ContentView()
8                .environmentObject(purchaseManager)
9                .task {
10                    await purchaseManager.updatePurchasedProducts()
11                }
12        }
13    }
14}
15

次にpurchase()関数で購入処理が正常に完了した後にもupdatePurchasedProducts()を呼び出す必要があります。これにより、新しく購入された製品でpurchasedProductIDsが更新されます。

1func purchase(_ product: Product) async throws {
2    let result = try await product.purchase()
3
4    switch result {
5    case let .success(.verified(transaction)):
6        await transaction.finish()
7        await self.updatePurchasedProducts()
8    case let .success(.unverified(_, error)):
9        break
10    case .pending:
11        break
12    case .userCancelled:
13        break
14    @unknown default:
15        break
16    }
17}
18

最後にすることは、アプリの外部で作成されたトランザクションの監視です。アプリの外部で作成されたトランザクションとは、サブスクリプションの更新や解約、または課金の問題によって取り消されたサブスクリプションなどがあります。また別のデバイスで発生した新しいアプリ内課金の購入の場合もあります。その場合も現在のデバイスでコンテンツを解放する必要があります。外部のトランザクションの監視には、Transaction.updatesというAsync Sequence型のプロパティを使います。

1@MainActor
2class PurchaseManager: ObservableObject {
3
4    ...
5
6    private var updates: Task<Void, Never>? = nil
7
8    init() {
9        updates = observeTransactionUpdates()
10    }
11
12    deinit {
13        updates?.cancel()
14    }
15
16    ...
17
18    private func observeTransactionUpdates() -> Task<Void, Never> {
19        Task(priority: .background) { [unowned self] in
20            for await verificationResult in Transaction.updates {
21                // Using verificationResult directly would be better
22                // but this way works for this tutorial
23                await self.updatePurchasedProducts()
24            }
25        }
26    }
27}

ここまでの処理に必要なコードはすべてPurchaseManagerに記述しました。SwiftUIのビューには有効なサブスクリプションがある場合に製品の一覧を非表示にする数行のコードを追加するだけです。実際にはpurchaseManager.hasUnlockedProプロパティをチェックするif文を追加します。PurchaseManagerは ObservableObjectなので、SwiftUIのビューはプロパティが変更されると自動的に再描画されます。

1var body: some View {
2    VStack(spacing: 20) {
3        if purchaseManager.hasUnlockedPro {
4            Text("Thank you for purchasing pro!")
5        } else {
6            Text("Products")
7            ForEach(purchaseManager.products) { (product) in
8                Button {
9                    Task {
10                        do {
11                            try await purchaseManager.purchase(product)
12                        } catch {
13                            print(error)
14                        }
15                    }
16                } label: {
17                    Text("\(product.displayPrice) - \(product.displayName)")
18                        .foregroundColor(.white)
19                        .padding()
20                        .background(.blue)
21                        .clipShape(Capsule())
22                }
23            }
24        }
25    }
26}
27

ここまでのコードの完全な実装はこちらをご覧ください。

https://github.com/RevenueCat/storekit2-demo-app/tree/main/StepByStepExamples/Step4

手順5:オフライン時のトランザクション

手順4ではTransaction.currentEntitlementsプロパティをユーザーが購入した製品(非消耗型、有効なサブスクリプション、または完了していない消耗型のトランザクション)を判定するために利用しました。このプロパティはインターネット接続がある場合は最新のトランザクションをフェッチします。デバイスがインターネットに接続していない場合(Wi-Fiが故障している、機内モードがオンになっているなど)、Transaction.currentEntitlementsはローカルにキャッシュされたデータを返します。また、トランザクションはオフライン状態からオンライン状態に復帰した際にデバイスにプッシュされるため、デバイスが一時的にオフラインになることがあっても、インターネット接続が回復したときに最新のトランザクションを取得できます。

StoreKit 2のこの仕組みは本当に素晴らしく作られています。デベロッパーはStoreKit 2 対応アプリをオフラインで動作させるためのロジックやキャッシュの設計について考える必要はありません。Transaction.currentEntitlementsによる購入済み製品の取得は、オンラインでもオフラインでも同じように動作します。

手順6:購入の復元

手順5と同様に購入の復元を実装するために何かする必要はありません。StoreKitのTransaction.currentEntitlementsとTransaction.allを利用すると、アプリ内課金のステータスと購入履歴を自動的に最新の状態に保ちます。そのため、ユーザー自身が購入の復元を実行することは技術的には不要です。しかしアプリ内に「購入の復元」ボタンを設置することはわかりやすさの観点から良い方法です。

  • App Store Reviewガイドラインのセクション3.1.1では復元可能(非消耗型など)な製品がある場合に購入を復元する仕組みがアプリ内に必要であると書かれています。「購入の復元」というボタンを表示しなければならないとは書かれてませんし、StoreKitは自動的にすべてのトランザクションを最新の状態に同期しているので、わざわざその機能を実装する必要がない可能性はあります。しかし、「購入の復元」ボタンを表示することはこのガイドラインの曖昧性を簡単な方法で満たすことができます。
  • 実際に購入を復元する機能が不要だとしても、ユーザー自身にアプリのコントロールがあるという感覚があることは望ましい挙動です。もしユーザーが購入処理が正しく動作していないと不安に感じたときに、「購入の復元」というボタンがあればわかりやすいです。

AppStore.sync()があるので「購入の復元」機能を追加することは非常に簡単です。AppStore.sync()のドキュメントにもAppStore.sync()は「購入の復元」ボタンを実装するために使用されることが想定されていると記述があります。さらにStoreKitは自動的に最新の状態を保持するので必要になることはほとんどないのですが、トランザクションやサブスクリプションのステータスに何か問題があるのではないかと不安を感じているユーザーにとってわかりやすいとも書かれています。WWDC22では、このトピックについてより深く掘りさげた、プロアクティブなアプリ内の復元機能を実装するというセッションがあります。

アプリの購入処理が正しく動作していないとユーザーが疑った場合、AppStore.sync()を呼び出し、アプリにApp Storeから購入履歴とサブスクリプションのステータスを取得させます。

1Each(purchaseManager.products) { product in
2    Button {
3        Task {
4            do {
5                try await purchaseManager.purchase(product)
6            } catch {
7                print(error)
8            }
9        }
10    } label: {
11        Text("\(product.displayPrice) - \(product.displayName)")
12    }
13}
14
15Button {
16    Task {
17        do {
18            try await AppStore.sync()
19        } catch {
20            print(error)
21        }
22    }
23} label: {
24    Text("Restore Purchases")
25}
26

AppStore.sync()を呼び出すボタンは製品一覧の下に配置されます。ユーザーが製品を購入したにもかかわらず製品一覧が表示されたままになっている珍しいケースでは、AppStore.sync()がトランザクションを更新し、製品一覧が消えて、ユーザーが購入したアプリ内コンテンツを利用できるようになります。

ここまでのコードの完全な実装はこちらをご覧ください。

https://github.com/RevenueCat/storekit2-demo-app/tree/main/StepByStepExamples/Step6

手順7:購入情報をエクステンションと共有する

iOSアプリにはメインアプリ以外にウィジェットやApple Watch用のアプリなどが含まれることがあります。メインアプリ以外のエクステンションは、メインアプリとは別のコンテキストで実行されることがほとんどですが、アプリ内課金によってはエクステンションの有料機能を解放するような場合も考えられます。幸いなことにStoreKit 2 では、ほとんどのエクステンションでこのようなことが簡単にできるようになっています。

Transaction.currentEntitlementsは前の手順と同じようにエクステンションでも使用できます。これは、ウィジェットやインテントなどのエクステンションで期待する通りに動作します。しかし、コンパニオンwatchOSアプリでは、Transaction.currentEntitlementsは実行できますが正しく動作しません。コンパニオンwatchOSアプリは別の環境とデバイスで動作しているのでiOSアプリと同じ購入履歴で更新されません。

エクステンション

前述のとおり、Transaction.currentEntitlementsはデバイスがオンラインであれば最新のトランザクションを取得しますし、オフラインの場合はローカルにキャッシュされたトランザクションを取得します。実行時間や使える機能が制限されていたりするエクステンションでは、Transaction.currentEntitlementsの実行に時間がかかりすぎて、何度も呼び出すことができない場合があります。

この問題の解決策の一つは、エクステンションに対して特定の製品が購入されたかどうかという情報をフラグなどで共有することです。例えばApp Groupを使って、メインアプリとエクステンションの間で共有されるUserDefaultsに情報を格納することで実現できます。

ここで新しく導入するEntitlementsManagerクラスは、製品を購入した際に発生するアンロックされた機能の状態を保存します。PurchaseManagerは既に存在するupdatePurchasedProducts()関数からTransaction.currentEntitlementsを呼び出した後にEntitlementsManagerを更新します。

1import SwiftUI
2
3class EntitlementManager: ObservableObject {
4    static let userDefaults = UserDefaults(suiteName: "group.your.app")!
5
6    @AppStorage("hasPro", store: userDefaults)
7    var hasPro: Bool = false
8}
9

EntitlementManagerはもObservableObjectですので、SwiftUIのビューは変更を監視して何かが変更されるたびにビューが再描画されます。SwiftUIはプロパティの値をUserDefaults(suiteName: “group.your.app”)に自動的に値を保存する@AppStorageと呼ばれる非常に便利なProperty Wrappersを提供しています。更新されたときにSwiftUIビューを再描画する@Published変数としても動作します。

次の手順はupdatePurchasedProducts()で更新するEntitlementManagerのインスタンスをPurchaseManagerに渡すことです。

1class PurchaseManager: ObservableObject {
2
3    ...
4
5    private let entitlementManager: EntitlementManager
6
7    init(entitlementManager: EntitlementManager) {
8        self.entitlementManager = entitlementManager
9    }
10
11    ...
12
13    func updatePurchasedProducts() async {
14        for await result in Transaction.currentEntitlements {
15            guard case .verified(let transaction) = result else {
16                continue
17            }
18
19            if transaction.revocationDate == nil {
20                self.purchasedProductIDs.insert(transaction.productID)
21            } else {
22                self.purchasedProductIDs.remove(transaction.productID)
23            }
24        }
25
26        self.entitlementManager.hasPro = !self.purchasedProductIDs.isEmpty
27    }
28}
29

もう一つ必要な変更はPurchaseManagerの初期化のコードです。以前はPurchaseManagerは @StateObject var purchaseManager = PurchaseManager()でStateObjectと書いていました。今回からは2つのStateObject変数を持つことになり、片方はもう片方に依存しているので初期化コードはinit()関数の中に書くことになります。

1struct YourApp: App {
2    @StateObject
3    private var entitlementManager: EntitlementManager
4
5    @StateObject
6    private var purchaseManager: PurchaseManager
7
8    init() {
9        let entitlementManager = EntitlementManager()
10        let purchaseManager = PurchaseManager(entitlementManager: entitlementManager)
11
12        self._entitlementManager = StateObject(wrappedValue: entitlementManager)
13        self._purchaseManager = StateObject(wrappedValue: purchaseManager)
14    }
15
16    var body: some Scene {
17        WindowGroup {
18            ContentView()
19                .environmentObject(entitlementManager)
20                .environmentObject(purchaseManager)
21                .task {
22                    await purchaseManager.updatePurchasedProducts()
23                }
24        }
25    }
26}
27
1struct ContentView: View {
2    @EnvironmentObject
3    private var entitlementManager: EntitlementManager
4
5    @EnvironmentObject
6    private var purchaseManager: PurchaseManager
7
8    var body: some View {
9        VStack(spacing: 20) {
10            if entitlementManager.hasPro {
11                Text("Thank you for purchasing pro!")
12            } else {
13                Text("Products")
14                ForEach(purchaseManager.products) { product in
15

ContentViewもEntitlementManagerクラスを利用するように書き直します。これは前の手順と挙動は変わりませんが、有料機能の解放の処理は完全にPurchaseManagerの外に移動しました。PurchaseManagerの仕事は1つで購入とトランザクションを処理することです。

エクステンションで有料機能の解放ステータスをチェックするには、EntitlementManagerをエクステンションのターゲットに追加するだけでエクステンションはentitlementManager.hasPro を呼び出すことができます。エクステンションで使用する正確なコードについては、以下のサンプルコードを参考にしてください。

1let entitlementManager = EntitlementManager()
2if entitlementManager.hasPro {
3    // Do something
4} else {
5    // Don't do something
6}
7

Watchアプリ

前述したようにコンパニオンwatchOSアプリはiOSアプリからTransaction.currentEntitlementsで同じデータを取得できないので、エクステンションとは異なる動作をします。WatchアプリはApp Groupを使ったUserDefaultsの共有もデバイスが異なるために使用できないので、エクステンションのときのようにEntitlementManagerをwatchOSアプリで使用することはできません。そこでwatchOSアプリではWatchConnectivityを使用してメインのiOSアプリと通信し、EntitlementManagerから購入情報を取得します。このような実装が必要になることはあまりないかもしれませんが、watchOSアプリでアプリ内課金を実装する場合に、iOSアプリやエクステンションと異なることを知っておくことは役に立ちます。

ここまでのコードの完全な実装はこちらをご覧ください。

https://github.com/RevenueCat/storekit2-demo-app/tree/main/StepByStepExamples/Step7

手順8:サブスクリプションの更新、キャンセル、請求に関する問題などの処理

Transaction.currentEntitlementsは有効な自動更新サブスクリプションと、非自動更新サブスクリプションの最新のトランザクションを返します。

つまり、アプリを起動するたびにTransaction.currentEntitlementsは更新、キャンセル、請求の問題を反映した最新のトランザクションを持ちます。サブスクリプションが更新された場合、ユーザーはその有料きのを使い続ける権利を保持し、キャンセルした場合は失います。未解決の課金問題がある場合もユーザーはサブスクリプションが失効します。

アプリ上でサブスクリプションの最新のステータスを常に反映することは、最良のユーザーエクスペリエンスにならないことがあります。更新やキャンセルはユーザーに通知する必要のないイベントですが、請求に関する問題はユーザーに通知したいものです。請求の問題は通常は想定外のイベントであり、ユーザーはサブスクリプションが失効しないように対処したいと思うでしょう。

デバイス上のサブスクリプションの処理だけでは、課金の問題や猶予期間についてユーザーにうまく通知することができません。これはサブスクリプションをサーバーサイドで処理することが有用なケースです。サーバーサイドのサブスクリプション処理は、課金の問題と猶予期間をすばやく検出し、アプリからユーザーに通知できます。

手順9:レシートの検証

レシートの検証はこれまでStoreKitを利用する際に本当に大変な作業でした。StoreKit 1では、レシートを解析して検証することが、購入と機能の解除を判断する唯一の方法でした。レシートを検証する方法は2種類が存在し、必要に応じてどちらを選択することが良いかというドキュメントをAppleは提供しています。

  • ローカルのデバイス上で行うレシートの検証は一度確認すれば十分な種類のアプリ内課金に適しています。
  • App Storeに問い合わせる方式のサーバーサイドで行うレシートの検証は、サブスクリプションなど持続的に状態を管理する必要がある種類のアプリ内課金に適しています。

ここまでこのチュートリアルではStoreKitの完全なローカル実装に焦点を当て、サーバーサイドのコンポーネントを一切使用していません。そのため、レシートの真正性を確認する唯一の方法は、デバイス上でのローカルバリデーションとなります。レシートを解析・検証する方法に関する既存のブログ記事もたくさんありますが、StoreKit 2 で大きく改善されたためレシートに関して難しいことはありません。

ここまでレシートについてあまり触れてこなかったのは、StoreKit 2ではすべての解析と検証をTransaction.currentEntitlementsと Transaction.allの中に隠蔽されているため、デベロッパーは何も気にする必要がないからです。 

手順10:App Storeアプリからのアプリ内課金の購入に対応する

アプリ内課金は必ずしもアプリ内だけから実行されるとは限りません。ユーザーがApp Storeアプリから直接アプリ内課金を購入できるフローもあります。デベロッパーはアプリの商品ページに表示されるアプリ内課金のプロモーションを設定できます。ユーザーがApp Storeアプリからアプリ内課金の製品をタップすると、アプリが起動して購入を確定するように促します。しかしiOSはユーザーが商品を購入するアクションを自動的に継続しません。デベロッパーはこのような動作が発生したことを検知するリスナーを追加する必要があります、StoreKit 2ではこのリスナーを追加する手段はありません。

App Storeアプリからトランザクションを継続する機能はStoreKit 1 APIでのみ可能です。

  • SKPaymentTransactionObserverプロトコルに準拠します(NSObjectを継承する必要があります)。
  • paymentQueue(_:shouldAddStorePayment:for:)関数を実装します。
  • SKPaymentQueueにSKPaymentTransactionObserverを追加します。
1@MainActor
2class PurchaseManager: NSObject, ObservableObject {
3
4    ...
5
6    init(entitlementManager: EntitlementManager) {
7        self.entitlementManager = entitlementManager
8        super.init()
9        SKPaymentQueue.default().add(self)
10    }
11
12    ...
13}
14
15extension PurchaseManager: SKPaymentTransactionObserver {
16    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
17
18    }
19
20    func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool {
21        return true
22    }
23}

この例のようなpaymentQueue(_:shouldAddStorePayment:for:)が単純にtrueを返すという実装は、ユーザーがアプリに移動するとすぐにトランザクションを継続します。これはこのフローを動作させるもっとも簡単な方法ですが、トランザクションを即座に継続することが最良の動作でない場合もあります。このような場合は一旦falseを返し、最適なときにSKPaymentをSKPaymentQueueに追加することで、トランザクションを後の(より良い)タイミングに延期できます。

ここまでのコードの完全な実装はこちらをご覧ください。

https://github.com/RevenueCat/storekit2-demo-app/tree/main/StepByStepExamples/Step10

デバイス上でアプリ内課金を実装するチュートリアルのまとめ

これで、純粋にデバイス上だけでアプリ内課金を実装するためのチュートリアルは完了です。製品の一覧を表示、選択した製品を購入、アプリ内の有料コンテンツや機能の解放、購入済みの製品の復元、エクステンションでの有料機能の解放、App Storeアプリからアプリ内課金のプロモーションにも対応できます。主にStoreKit 2、一部はStoreKit 1のAPIを使用して実装しました。

このようなStoreKitの実装はアプリの種類によっては非常に効果的です。例えば、アプリをAppleプラットフォームだけに提供されていて、ユニバーサル購入を使用している場合などです。ユーザーはアプリ内課金を購入し、アプリで期待される有料コンテンツや機能を解放できます。

しかし、純粋なデバイス上のStoreKit実装は、アプリの種類によっては理想的な挙動ではないかもしれません。サブスクリプションのステータスをWebサービスや他のネイティブ・プラットフォームのアプリとプラットフォームを越えて共有したいこともあります。デバイス上の実装だけでは、ユーザーの行動を簡単にフィードバックできません。請求の問題や解約時の返金キャンペーンについて、アプリの外(メールなど)でユーザーに直接通知することができません。StoreKit 2は簡単に利用できますが、StoreKit 2が隠蔽してくれている部分には多くのデータとチャンスが残されています。

このチュートリアルの次のセクションでは、StoreKit 2 をカスタムサーバーバックエンドで実装するために必要なことを少し説明します。それが最適なソリューションかどうか読んみて判断してください。

サーバーサイドにおけるStoreKit 2の実装

ここまでのチュートリアルでは、アプリの外でコードを書くことなく完全なアプリ内課金を実装しましたが、それが唯一の方法ではありません。StoreKit 2のネイティブAPIの他に、Appleは取引履歴や購読状況を取得するためのApp Store Server APIを提供しています。

このチュートリアルでは、App Storeサーバーと通信するためのWebアプリケーションの設計方法と、iOSアプリとWebアプリケーションの通信方法について説明します。

バックエンドサーバーの実装はこのチュートリアルでは扱いませんが、これから説明するシステム設計にしたがってサンプルのバックエンドサーバーは、空の関数でエンドポイントのインターフェースだけ実装しています。サンプルコードはVapor(SwiftのWebアプリケーションフレームワーク)を使って書かれています。バックエンドサーバーのサンプルコードはこちらです。

App Storeサーバー

アプリ内課金や定期購入についてApp Storeと通信するためのサービスです。App Store Server APIを呼び出す方法と、App Store Server Notificationsを利用する方法の2種類があります。

App Store Server API

App Store Server APIはアプリ内課金に関する情報を取得するためのREST APIです。このAPIは、トランザクション履歴の取得、自動更新可能なすべてのサブスクリプションのステータスの確認、消耗型のIAPステータスの更新、購入履歴の検索、返金履歴の取得、サブスクリプションの更新日の延長に使用できます。これらのエンドポイントのほとんどはトランザクションIDがパラメータとして必要です。トランザクションIDは、ネイティブのStoreKit 2 APIから購入した後にTransactionオブジェクトから取得できます。

App Store Server APIへのリクエストはJSON Web Token(JWT)で認証されます。JWTの生成にはシークレットキーが必要で、App Store Connectで作成できます。

App Store Server Notifications

App Store Server Notificationsは、直接App Storeと通信することにより、アプリ内課金のイベントをリアルタイムに監視する方法を提供します。App Store Server Notificationsを利用するには、App Store Connectからリクエストを送信するWebサーバーのHTTPS URLを設定する必要があります。プロダクション環境とサンドボックス環境のそれぞれに異なるURLを設定できます。

バージョン2のApp Store Server Notificationsから送信される通知は15種類あります。

  • CONSUMPTION_REQUEST
  • DID_CHANGE_RENEWAL_PREF
  • DID_CHANGE_RENEWAL_STATUS
  • DID_FAIL_TO_RENEW
  • DID_RENEW
  • EXPIRED
  • GRACE_PERIOD_EXPIRED
  • OFFER_REDEEMED
  • PRICE_INCREASE
  • REFUND
  • REFUND_DECLINED
  • RENEWAL_EXTENDED
  • REVOKE
  • SUBSCRIBED
  • TEST

App Store Server NotificationsからのリクエストはHTTPのPOSTメソッドとして送信され、ペイロードはJSON Web Signature(JWS)形式でApp Storeによって署名されます。JWSはペイロードがApp Storeから送信されたものであることのセキュリティと信頼性を高めるために使用されます。

通知のペイロードには下記の内容が含まれます。

App StoreとApp Store Server Notificationsを監視しているバックエンドサーバー間の接続をテストするには、App Store Server APIからテストイベントを送信します。これは、エンドツーエンドの接続テストを行うもっとも簡単な方法です。この方法が存在する以前は、サンドボックスまたはプロダクション環境のアプリから実際に製品を購入してテストを行う必要がありました。

システム設計

App Store Server APIとApp Store Server Notificationsについて理解したので、システム設計を行います。

製品の購入

製品の購入だけはStoreKit 2 のネイティブ API を使ってProducts.purchase(product)関数を使う必要がありますが、その後はアプリから独立してサーバー上で処理ができます。どの製品が有効であるかを判断するためにデバイス上ではStoreKit 2のTransaction.currentEntitlementsを利用していましたがその代わりにトランザクションの情報をバックエンドサーバーに送信します。バックエンドサーバーではApp Store Server APIからトランザクション履歴を取得し、App Store Server Notificationsから更新されたトランザクションをトランザクションIDを用いて特定します。バックエンドサーバーでサブスクリプションが扱えるだけでなく、デバイス上のアプリとは独立して購入を検証できるという利点があります。

購入が成功した後にトランザクションを送信すると同時に、アプリはTransaction.updatesを監視している間に発生したトランザクションの更新も送信する必要があります。これは購入時にトランザクションIDを持たなかった「強力な顧客認証」や「承認と購入のリクエスト」による保留中の購入に対応するために必要です。

以下は、iOSアプリの変更点の一例です。

1func purchase(_ product: Product) async throws {
2    let result = try await product.purchase()
3
4    switch result {
5    case let .success(.verified(transaction)):
6        await transaction.finish()
7        await postTransaction(transaction)
8    case let .success(.unverified(_, error)):
9        break
10    case .pending:
11        break
12    case .userCancelled:
13        break
14    @unknown default:
15        break
16    }
17}
18
19private func observeTransactionUpdates() -> Task<Void, Never> {
20    Task(priority: .background) {
21        for await result in Transaction.updates {
22            guard case .verified(let transaction) = result else {
23                continue
24            }
25
26            guard let product = products.first(where: { product in
27                product.id == transaction.productID
28            }) else { continue }
29            await postTransaction(transaction, product: product)
30        }
31    }
32}
33
34func postTransaction(_ transaction: Transaction, product: Product) async {
35    let originalTransactionID = transaction.originalID
36    let body: [String: Any] = [
37        "original_transaction_id": originalTransactionID,
38        "price": product.price,
39        "display_price": product.displayPrice
40    ]
41    await httpPost(body: body)
42}
43

サンプルコードのバックエンドサーバーには、トランザクションを送信する先のエンドポイントが定義されています。このエンドポイントでは以下の処理を行います。

  • ユーザーがアプリでログインしていることの確認。
  • App Storeサーバーにリクエストを送信し、トランザクション情報の取得と検証をする。
  • トランザクション情報をデータベースに格納する。
  • 有料機能の解放条件を判定する。 

下記のコードは、バックエンドサーバーをVaporで実装する場合の例です。

1app.post("apple/transactions") { req async -> Response in
2    do {
3        let newTransaction = try req.content.decode(PostTransaction.self)
4        let transaction = processTransaction(
5            originalTransactionID: newTransaction.originalTransactionId)
6        return try await transaction.encodeResponse(for: req)
7    } catch {
8        return .init(status: .badRequest)
9    }
10}
11

トランザクションの更新(更新、キャンセルなど)もこの関数を通じて監視されることに注意してください。つまり、バックエンドサーバーはアプリと、App Store Server Notificationsからリアルタイムで重複したトランザクションを取得する可能性があります。バックエンドサーバーはこの動作を問題なく処理できる必要があります。

価格の取扱い

トランザクションと一緒に価格を保存することは、顧客の生涯価値(LTV)を理解し、ユーザー獲得支出の意思決定を行うために重要です。最良の方法は、トランザクション履歴にユーザーの支出の合計を保持することです。

App Storeサーバーのレスポンスにはトランザクションに製品の価格や通貨の種類が含まれていません。つまり、iOSアプリから製品の価格を送信する必要があります。StoreKit 2のProductオブジェクトには、price(数値)プロパティとdisplayPrice(文字列値)プロパティがあります。StoreKit 2(iOS 16時点)では、商品の購入に使用された通貨を取得する方法はありません。displayPriceを送信することで、バックエンドは文字列の記号に基づいて通貨を解析できます。文字列から通貨を解析するだけでは十分でない場合は、StoreKit 1 を使用してSKProductオブジェクトから通貨を取得することもできます。面倒で手間がかかりますが信頼性は高くなります。

サブスクリプションの更新、キャンセル、請求に関する問題などの処理

ここまでの変更により、アプリは現在すべてのサブスクリプションとトランザクションを最新の状態に保つためにバックエンドサーバーに依存しています。バックエンドサーバーは、更新、キャンセル、請求の問題、返金をできるだけ早く検知し、ユーザーが購入していない有料機能や失効しているサブスクリプションのコンテンツを得ていないことを保証する必要があります。この処理には2つの方法があります。

  • App Store Server Notificationsのリアルタイム通知を監視する。
  • App Store Server APIをポーリング(定期的にリクエスト)する。

両方とも実装する必要はありませんが、両方を実装すると、より強固で信頼できる処理になります。

App Store Server Notificationを監視する

App Store Server Notificationsを有効にすることは、サブスクリプションのステータスと返金されたトランザクションを最新の状態に保つためのベストプラクティスであり、もっとも効率的な方法です。Appleはトランザクションが更新されるとすぐに、デベロッパーがApp Store Connectで設定したURLに、更新されたトランザクションに関する情報をHTTPのPOSTメソッドを使って送信します。これにより、App Store Server Notificationsを監視しているすべてのシステムが、Appleが保管しているトランザクション情報と一致するようになります。

以下のサンプルコードは、App Storeサーバーからの通知を処理する方法を示しています。

  1. リクエストはhttps://<mydomain>.com/apple/notificationsに送信されます。
  2. リクエストのペイロードは、responseBodyV2を表すSignedPayloadのStructにデコードされます。このStructには署名されたペイロード(JWS)を含みます。
  3. 署名されたペイロードは、Apple のルート証明書を使用してresponseBodyV2DecodedPayload を表すネストしたNotificationPayloadSwiftのStructにデコードされます。
  4. NotificationPayloadはWebアプリケーションのメインプロセスからトランザクションを処理するワーカーキューに送られます。そこでトランザクションとエンタイトルメントデータベースの更新が実行され、請求の問題、有効期限、キャンペーンを知らせるために、利用者にEメールやプッシュ通知を送信できます。
1struct SignedPayload: Decodable {
2    let signedPayload: String
3}
4
5struct NotificationPayload: JWTPayload {
6    let notificationType: NotificationType
7    let notificatonSubtype: NotificationSubtype?
8    let notificationUUID: String
9    let data: NotificationData
10    let version: String
11    let signedDate: Int
12
13    func verify(using signer: JWTSigner) throws {
14    }
15}
16
17app.post("apple/notifications") { req async -> HTTPStatus in
18    do {
19        let notification = try req.content.decode(SignedPayload.self)
20
21        let payload = try req.application.jwt.signers.verifyJWSWithX5C(
22            notification.signedPayload,
23            as: NotificationPayload.self,
24            rootCert: appleRootCert)
25
26        // Add job to process and update transaction
27        // Updates user's entitlements (unlocked products or features)
28        try await req.queue.dispatch(AppleNotificationJob.self, payload)
29
30        return .ok
31    } catch {
32        return .badRequest
33    }
34}
35

SignedPayloadの完全なモデルの定義はこちらです。

https://github.com/RevenueCat/storekit2-demo-app/blob/main/demoserver/Sources/App/models.swift

App Store Server Notificationsを利用する際にもっとも注意が必要なことは、通知を見逃すことです。App Storeサーバーが応答を受信しなかった場合、またはWebリクエストから不正なHTTPのレスポンスを検出した場合、通知の送信を最大5回まで再試行します。再試行は、前回の試行から1時間後、12時間後、24時間後、48時間後、72時間後に行われます。このような事態が発生する可能性が高いのは、サーバーやサービスが停止した場合です。AppleはApp Store Server APIを通じて通知履歴を取得するという別の解決策を用意しています。再試行が失敗した場合は、App Store Server APIを使用して通知履歴を取得し、欠けた通知を復元できます。

通知を有効にして、通信する方法については、このチュートリアルの範囲外です。公式のより広範な文書がこちらにあります。

https://developer.apple.com/documentation/appstoreservernotifications

App Store Server APIをポーリングする

リアルタイム通知を監視している場合でも、トランザクションの更新のために適宜App Store Server APIをポーリングすることは、システムを堅牢にします。トランザクションの処理はリアルタイム通知とよく似ていますが、このアプローチで厄介なのは、App Store ServerへのAPI呼び出しをどのくらいの頻度で実行するかを決めることです。

もっともわかりやすい方法は、自動更新サブスクリプションの期限切れや更新の直後にスケジューリングすることです。また、サブスクリプションの解約の確認は更新が起こる少し前に行うことで、有料サービスがまもなく終了することをユーザーに警告したり、なんらかのキャンペーンを用いてユーザーに再契約を促すことができます。ユーザーはいつでもサブスクリプションを解約できるので、これに関しては完璧なタイミングというものはありません。つまりApp Store Server APIのポーリングは、最初は頻繁に行われず、期限切れや更新時期が近づくにつれて頻繁に行われるようになります。

ポーリングのスケジューリングは、請求に関する問題が発生した場合のために更新の期間が過ぎた後の猶予期間に実行する必要もあります。このようにすることで、ユーザーは請求情報を修正することができれば、サービスが使えなくなることがありません。

非自動更新サブスクリプションや非消耗型の製品についても、ポーリングは有効です。この種類の製品には更新や解約といったイベントはありませんが、ユーザーはいつでも返金をリクエストできるので有料機能やコンテンツへのアクセスを取り消す必要は起こります。

ポーリングは技術的には簡単にみえますが、すぐに複雑なスケジューリングのパズルになってしまいます。そのためApp Store Server Notificationを主に利用することがベストプラクティスです。

製品の一覧

製品の一覧をバックエンドサーバーのAPIによって返すようにします。製品の情報はアプリの製品一覧を変更する際に簡単に設定できるように、データベースに保存することが理想です。製品IDをAPIから提供するとどの製品が効果的に購入されるかなどのA/Bテストを行うことも可能になります。製品IDをハードコーディングする場合はアプリをアップデートすることでしか製品を変更できないので、それに比べると非常に良い方法です。

利点とトレードオフ

StoreKit 2のネイティブ APIは、アプリ内課金やサブスクリプションの複雑な仕組みをうまく隠蔽してくれています。ネイティブ APIのみを使用する選択もまったく間違いではありません。しかしサブスクリプションを管理するためにバックエンドサーバーを使用する利点も多くあります。

  • 購読ステータスの変更や返金に関する通知をリアルタイムで受け取流。
  • トランザクションとレシートの検証。
  • 顧客管理システムと連携して、サブスクリプション期間の延長や割引を提供する。
  • 収益と解約を統計的に分析。
  • サブスクリプションのステータスをバックエンドサーバーの機能と連携する。
  • Webや他のプラットフォームと購入情報を共有する。

StoreKit 2 で IAP を処理するためのサーバーサイドの実装を開発するのは、純粋なオンデバイスの実装に比べて飛躍的に複雑になります。そのため、このチュートリアルのではアプリ内課金の完全なバックエンドサーバーを構築するための手順はステップ・バイ・ステップの解説を提供できませんでした。 

おわりに

私たちRevenueCatの最大の目標は、デベロッパーがより多くの収益を上げられるお手伝いをすることです。そのためにネイティブとクロスプラットフォーム両方のSDKを備えたモバイルサブスクリプションプラットフォーム全体を提供し、どのようなアプリケーションでもアプリ内課金をできるだけ簡単に統合できるようにしています。デベロッパーはプラットフォームごとに異なるサブスクリプションAPIや、更新、キャンセル、請求の問題と同期してサブスクリプションのステータスを維持する複雑さを心配する必要はありません。

デベロッパーのみなさん全員にRevenueCatを使っていただきたいと考えていますが、自分自身で実装したほうがいい場合もたくさんあります。それがこのStoreKit 2のチュートリアルを作成した理由です。私たちはすべてのデベロッパーに成功してほしいと願っています。

In-App Subscriptions Made Easy

See why thousands of the world's tops apps use RevenueCat to power in-app purchases, analyze subscription data, and grow revenue on iOS, Android, and the web.

Related posts

What is SKErrorDomain Error 0 and what can I do about it?
Engineering

What is SKErrorDomain Error 0 and what can I do about it?

What to do when seeing SKErrorDomain Error code 0 from StoreKit on iOS.

Charlie Chapman

Charlie Chapman

April 24, 2024

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
Engineering

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake

Challenges, solutions, and insights from optimizing our data ingestion pipeline.

Jesús Sánchez

Jesús Sánchez

April 15, 2024

How RevenueCat handles errors in Google Play’s Billing Library
How RevenueCat handles errors in Google Play’s Billing Library  
Engineering

How RevenueCat handles errors in Google Play’s Billing Library  

Lessons on Billing Library error handling from RevenueCat's engineering team

Cesar de la Vega

Cesar de la Vega

April 5, 2024

Want to see how RevenueCat can help?

RevenueCat enables us to have one single source of truth for subscriptions and revenue data.

Olivier Lemarié, PhotoroomOlivier Lemarié, Photoroom
Read Case Study