スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

「スタディサプリ 中学講座」のSwiftUI x In-App Purchase設計と結合テストのノウハウ

こんにちは!iOSエンジニアの @chuymaster です。今回は私たちのチームが2月にリリースした「スタディサプリ 中学講座」のIn-App Purchase(IAP)の実装について書きたいと思います。SwiftUI x IAPの事例をお探しの方必見です!

背景

スタディサプリ 中学講座」では、ユーザーが月額料金を支払うことで、講座の動画を見る・演習をすることができます。iOSアプリでの決済手段として、In-App Purchase(IAP)機能を提供しています。

スタディサプリ 中学講座のApp内課金導線 スタディサプリ 中学講座」の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クラス図 スタディサプリ 中学講座」の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

必要な画面だけ、 @EnvironmentInAppPurchaseUseCase を参照して、 onAppearViewModel に渡します。

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を導入する方の役に立てば嬉しいです。幸運を祈ります!

参考資料

補足として、実装時に参考にした資料をご紹介します。よければ読んでみてください!

We're hiring!

スタディサプリでは、世界の果てまで最高の学びを共に届ける仲間を募集しています。 少しでも気になった方はカジュアル面談もやっていますのでお気軽にお問い合わせください!