こんにちは!iOSエンジニアの @chuymaster です。今回は私たちのチームが2月にリリースした「スタディサプリ 中学講座」のIn-App Purchase(IAP)の実装について書きたいと思います。SwiftUI x IAPの事例をお探しの方必見です!
背景
「スタディサプリ 中学講座」では、ユーザーが月額料金を支払うことで、講座の動画を見る・演習をすることができます。iOSアプリでの決済手段として、In-App Purchase(IAP)機能を提供しています。
「スタディサプリ 中学講座」のApp内課金導線
既存アプリの実装が参考にできるとはいえ、IAPの仕様はかなり複雑で理解しにくい部分が多いです。また、UIKitで開発された既存アプリと違って、「スタディサプリ 中学講座」はフルSwiftUIで実装されたので、いかにSwiftUIらしく設計・実装できるかかなり悩みました。
この記事では、そんな悩みを乗り越えた先にできた設計を解説し、テストで起こった様々な問題を解決したノウハウを共有したいと思います。
IAPの概要
IAPはApp 内課金とも呼ばれて、Apple IDに紐づく決済手段でデジタル商品を購入できる機能です。決済が成功すると、「レシート」データが端末に保存され、このレシートデータに基づいて、自社サービスの有料会員権利を付与したり、ゲームならアイテムを与えたりすることができます。
前提
以下の前提で説明します。
- レシート検証はサーバーサイドで行い、その実装については触れない(APIができている前提)
- 課金モデルは、自動更新サブスクリプション(Auto-Renewable Subscription)のみ
- iOS14をサポートしているため、新しいAPIであるStoreKit 2が使えず、従来のStoreKitで実装
- Xcode 13.4で開発
SwiftUI x IAPの設計解説
それでは、私たちのSwiftUI x IAPの設計を紹介します。一部実際の実装とは異なる部分があるのでご了承ください。
InAppPurchaseUseCase
「スタディサプリ 中学講座」のInAppPurchaseクラス図
SKPaymentTransactionObserver
の監視を含め、IAP機能はすべてInAppPurchaseUseCase
クラスに集約させています。各データソースとのやりとりもこの中で隠蔽しています。今回は設計の紹介なので、内部実装は割愛しますが、図で表したように、様々なデータソースとやりとりをして、UI描画のために必要な state
を更新しています。下記のシンプルなプロトコルを通してとViewとコミュニケーションしています。
// プロトコル protocol InAppPurchaseUseCaseProtocol { // IAPの状態 var state: AnyPublisher<InAppPurchaseUseCase.State, Never> { get } // 商品情報の取得 func requestProduct() // 商品の購入 func purchase() // 商品の復元 func restore() } // 実装 final class InAppPurchaseUseCase: NSObject, InAppPurchaseUseCaseProtocol, SKPaymentTransactionObserver { ... }
View/ViewModel
はプロトコルの実行したいメソッドを呼び、state
の変化を監視して適切なUIを表示するだけでよいです。状態管理が一本化されることで、とても簡潔に実装できます。
State
は下記のように定義しました。
enum State { // 商品情報取得状態 case fetchingProduct case fetchedProduct(SKProduct) case fetchingProductFailed(Error) // 購入状態 case purchasing case purchased case purchasingFailed(Error) case purchasingDeferred // 復元状態 case restoring case restored case restoringFailed(Error) }
View
は各 State
に対してこのように処理しています。
fetching
,purchasing
,restoring
- ローディングを表示purchased
,restored
- 開いた画面を閉じて、Root画面を再描画(有料会員状態を反映させるため)fetchingProductFailed
,purchasingFailed
,restoringFailed
- エラーダイアログ表示purchasingDeferred
- 何もしない
View/ViewModelでの利用
@Environment
バックグラウンドのトランザクション監視の役割があるため、InAppPurchaseUseCase
はシングルトンである必要があります。ここはSwiftUIの特徴を活かして、 .environment
に登録しました。どこでもアクセスできる static
なオブジェクトより、使う範囲を限定できるメリットがあります。実装は下記のイメージです。
// Mainアプリ @main struct MainApp: App { private let inAppPurchaseUseCase: InAppPurchaseUseCase init() { // 各種初期化 ... inAppPurchaseUseCase = InAppPurchaseUseCase() } var body: some Scene { WindowGroup { RootScreen() // Environmentに登録 .environment(\.inAppPurchaseUseCase, inAppPurchaseUseCase) } } } // EnvironmentKeyの定義 private struct InAppPurchaseUseCaseEnvironmentKey: EnvironmentKey { static let defaultValue: InAppPurchaseUseCaseProtocol? = nil } extension EnvironmentValues { var inAppPurchaseUseCase: InAppPurchaseUseCaseProtocol? { get { self[InAppPurchaseUseCaseEnvironmentKey.self] } set { self[InAppPurchaseUseCaseEnvironmentKey.self] = newValue } } }
View
必要な画面だけ、 @Environment
で InAppPurchaseUseCase
を参照して、 onAppear
で ViewModel
に渡します。
struct InAppPurchaseScreen: View { @Environment(\.inAppPurchaseUseCase) private var inAppPurchaseUseCase @StateObject private var viewModel = InAppPurchaseScreenViewModel() var body: some View { // Viewの定義 ... .onAppear { [weak viewModel] in guard let inAppPurchaseUseCase = inAppPurchaseUseCase else { fatalError("Could not find inAppPurchaseUseCase.") } viewModel?.inject(inAppPurchaseUseCase: inAppPurchaseUseCase) // 商品情報をAppStoreからリクエスト viewModel?.requestProduct() } } }
Viewで直接 state
を監視せず、 ViewModel
に渡す理由はユニットテストが書けるようにするためです。
ViewModel
ViewModel
実装は下記のイメージです。 InAppPurchaseUseCaseProtocol
をインジェクトすることで、ユニットテストの際はモックをインジェクトして、IAP機能を切り離してユニットテストを書くことができます。
final class InAppPurchaseScreenViewModel: ObservableObject { // Viewが監視するローディング表示フラグ @Published private(set) var isLoading = false private var inAppPurchaseUseCase: InAppPurchaseUseCaseProtocol! func inject(inAppPurchaseUseCase: InAppPurchaseUseCaseProtocol) { self.inAppPurchaseUseCase = inAppPurchaseUseCase // ローディングフラグのバインディング例 inAppPurchaseUseCase.state .map { state in switch state { case .fetchingProduct, .purchasing, .restoring: return true case .fetchedProduct, .fetchingProductFailed, .purchased, .purchasingFailed, .purchasingDeferred, .restored, .restoringFailed: return false } } .receive(on: RunLoop.main) .assign(to: &$isLoading) // その他必要な表示要素をバインディング ... } // View表示時に呼ぶ、商品情報取得メソッド func requestProduct() { inAppPurchaseUseCase.requestProduct() } // Viewのボタン押下で呼ぶ、購入メソッド func purchase() { inAppPurchaseUseCase.purchase() } }
このように、複雑なIAPの実装をシンプルなプロトコルを通して使うことで、責務がはっきり分かれてメンテがしやすいコードが実現できました。
余談ですが、この設計ができるまでは何度か試行錯誤をしました。開発途中のアプリでの実装ではなく、新しくプロトタイプアプリで試したことで、ビルド速度も速く、既存部分の影響を受けずに開発を進めることができたのでオススメです。
続いては実装後、品質を保証するための結合テストのノウハウを紹介します。
結合テストのノウハウ
私たちの実装は、予め設計をきちんとしたことでかなりスムーズに進みました。ですが、結合テストの際に「StoreKitの仕様を知らなかった」ことが原因で不具合を起こしたり、調査に時間がかかったりしました。それらを回避するための注意点を説明します。
トランザクションの状態変化条件を洗い出そう
公式資料だけで理解しにくいポイントは、トランザクションの状態変化の条件です。 SKPaymentTransactionObserver
の実装に関しては、詳しく解説されています。手順に従ってオブザーバークラスをグローバルで定義していくとすんなり実装できるでしょう。
ただ、トランザクションを処理するのは paymentQueue(_:updatedTransactions:) メソッドで、バックグラウンドで呼ばれることが分かっていても、実際どういうイベントで呼ばれるかを洗い出さないと、ユーザーとしてIAPを使ったときのテストができないので、かなり時間をかけて調査しました。それが下記の図です。
トランザクションの状態変化条件(paymentQueue(_:updatedTransactions:)が呼ばれるタイミング)
ユーザーがアプリ内で課金して成功したときが一番分かりやすくて、皆さんは問題なく実装・テストできるでしょう。しかし、バックグラウンドでの自動更新やサブスクリプション管理画面での購入は、意識してテストケースを書かないと見逃してしまう可能性が高いので、気をつけましょう。
Sandbox環境のトランザクションの自動更新の頻度と時間を把握しよう
IAPのテスト実施はSandbox環境を使います。現在、Sandbox環境での月次サブスクリプションの更新は12回行われています。つまり、12回の更新が終わるまで待たないと、同じApple IDで同じ商品を再度購入できません。(更新頻度はApp Store Connectで短くできます。)
App 内課金のテスト より
各テスターで、月次サブスクリプションの更新頻度をテスト用に、短くしたり長くしたりすることが可能です。サブスクリプションは、12 回更新されると自動的にキャンセルされます。
恥ずかしながら、実装時、私は最新ドキュメントを読んでおらず、WWDC2018のBest Practices and What’s New with In-App Purchasesの情報(スライドページ37)を盲信して、5回行われるものだと勘違いしていました。そのせいで、30分経ってもなぜ再度購入できないのかが分からなくて、的外れの調査をして時間がかかってしまいました。
自動定期更新時の複数個のトランザクションを考慮しよう
スタディサプリ 中学講座は、月額課金モデルのみ利用しているので、基本的に毎月トランザクションを一つ処理する想定です。しかし、Sandbox環境では、デフォルト設定で1ヶ月=5分なので、課金してしばらく放置すると、すぐに数ヶ月分のトランザクションがたまります。一つ一つにはレシート情報が付与されているので、サーバーに送って検証してもらう必要があります。
例えば、SandboxのApple IDで一度課金して、アプリをキルし、一時間後に起動すると、このようなトランザクションでコールバックされます。
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { // Breakpointを貼って `po transactions` ... }
(lldb) po transactions ▿ 10 elements // 10ヶ月分のトランザクションが一度でまとめて来る - 0 : <SKPaymentTransaction: 0x2838e60d0> - 1 : <SKPaymentTransaction: 0x2838e60e0> - 2 : <SKPaymentTransaction: 0x2838e6d50> - 3 : <SKPaymentTransaction: 0x2838fe7d0> - 4 : <SKPaymentTransaction: 0x2838fdb40> - 5 : <SKPaymentTransaction: 0x2838fc310> - 6 : <SKPaymentTransaction: 0x2838fe350> - 7 : <SKPaymentTransaction: 0x2838fe510> - 8 : <SKPaymentTransaction: 0x2838fdb30> - 9 : <SKPaymentTransaction: 0x2838e6d40>
私たちの実装では、トランザクションを一つずつサーバーに送ってレシート検証しています。開発時はアプリ内で購入してすぐコールバックがされるか、もしくはアプリがフォアグラウンドのままなので、自動更新でもトランザクションが一ヶ月分ずつ来ています。
仕様上、個々のトランザクションに対して、 finishTransaction(_:)
を呼ばなければ、終了とならず、どこかのタイミングでまたコールバックされます。しかし、レシート検証処理が、複数のトランザクションが同時に来たときに、最初の一つのトランザクションにしか適用されず、finishTransaction(_:)
が呼ばれなかったのです。
トランザクションがすべて終わらないとどうなるかというと、Sandboxの自動更新が12回終わっても、新しく購入ができません。 paymentQueue.add(SKPayment(_:))
をした瞬間、決済画面は表示されず、未処理のトランザクションが paymentQueue(_:updatedTransactions:)
にてコールバックされてしまいます。当然、溜まっていたレシートはみんな期限切れなので、一つずつ処理が終わるまで、エラーになってしまいます。
事象を把握して再現できてからは、コードのバグにすぐ気づいて修正できました。初めて実装する場合はこのコールバックのパターンはなかなか把握しにくいと思うので、注意していただければと思います。
まとめ
以上、「スタディサプリ 中学講座」のSwiftUI x In-App Purchase設計と結合テストのノウハウを紹介しました。
IAPはお金にまつわる大切な機能で、慎重に実装してテストしなければならなくてハードルが高いですが、仕様を整理できれば、実装自体は複雑ではないと思います。これからSwiftUIで新規アプリを開発して、IAPを導入する方の役に立てば嬉しいです。幸運を祈ります!
参考資料
補足として、実装時に参考にした資料をご紹介します。よければ読んでみてください!
- Workflow for configuring in-app purchases
- 初めてのiOSアプリ内課金実装
- StoreKit API 1の各クラスの説明やアプリでの実装方法は、この記事がわかりやすいです。
- Testing In-App Purchases with Sandbox
- Introducing StoreKit Testing in Xcode - WWDC20 - Videos - Apple Developer
- Xcode12からはStoreKit Testingが導入されて、完全ローカルでユニットテストが書けるのでおすすめです。
We're hiring!
スタディサプリでは、世界の果てまで最高の学びを共に届ける仲間を募集しています。 少しでも気になった方はカジュアル面談もやっていますのでお気軽にお問い合わせください!