0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

StoreKit 2でハマったポイント3選 - サブスク実装で踏んだ地雷

0
Posted at

個人開発でiOSアプリにStoreKit 2でサブスクリプションを実装したら、見事に地雷を3つ踏みました。同じ轍を踏まないように共有します。

  • ハマり①: Transaction.updates のリスナー起動タイミングを間違えて、プロモコード経由の購入を取り逃がす
  • ハマり②: transaction.finish() を呼び忘れて、同じトランザクションが起動のたびに流れてくる
  • ハマり③: Sandboxの期間圧縮を本番挙動と勘違いして、自動更新ロジックの設計をやり直し

はじめに

StoreKit 2はiOS 15で登場した新しい課金API群で、async/awaitベースで書けてJWS検証も組み込まれており、StoreKit 1と比べて大幅にモダンになりました。

ただ「モダンで簡単」というのは表面的な話で、サブスクリプション特有の状態管理は依然として複雑です。実装中に複数の落とし穴を踏んだので、特に他の人もハマりそうな3つを記事にしておきます。

開発中のアプリは社内SE向けの学習アプリで、月額・年額のサブスクモデル+無料枠の構成。読者対象としては、StoreKit 2でこれから個人開発のアプリにサブスクを組み込もうとしている方を想定しています。


ハマり① Transaction.updates のリスナー起動タイミング

何が起きるか

Transaction.updates は、アプリ外で発生したトランザクション(プロモコード適用、家族共有、リファンド、Ask to Buyの承認など)を非同期で受け取るためのストリームです。

これを アプリ起動の最初期に起動していないと、外部購入を取り逃がして「課金したのに反映されない」状態が発生します。

最初、私はメイン画面の .task 修飾子内でリスナーを起動していました。

// ❌ これだと画面遷移時にキャンセルされる
struct ContentView: View {
    var body: some View {
        MainView()
            .task {
                for await result in Transaction.updates {
                    // ...
                }
            }
    }
}

.task はビューが画面から消えるとキャンセルされます。設定画面に遷移している間にプロモコード経由の購入完了通知が来ると、それが流れていきます。

解決策

@main で初期化される場所(App構造体やAppDelegate相当)で起動するStoreManagerに、init でリスナーを張ります。

// ✅ アプリのライフサイクルに紐づける
@MainActor
final class StoreManager: ObservableObject {
    private var updateListenerTask: Task<Void, Error>?

    init() {
        // 起動直後にリスナーを起動
        updateListenerTask = listenForTransactions()
    }

    deinit {
        updateListenerTask?.cancel()
    }

    private func listenForTransactions() -> Task<Void, Error> {
        Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try await self.checkVerified(result)
                    await self.updatePurchasedProducts()
                    await transaction.finish()
                } catch {
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }
}

@main
struct MyApp: App {
    @StateObject private var store = StoreManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}

ポイントは Task.detached でバックグラウンドに切り出して、メインの画面遷移と独立させる ことです。

公式ドキュメントの注意書き

Appleのドキュメントにも明記されています。

Begin observing this sequence as soon as your app launches; otherwise, you may miss transactions.

「アプリ起動と同時に監視を開始してください、さもなくばトランザクションを取り逃します」と。読み飛ばすと普通に踏みます。


ハマり② transaction.finish() の呼び忘れ

何が起きるか

トランザクション処理が完了したら、必ず transaction.finish() を呼ぶ必要があります。これを忘れると、そのトランザクションは「未完了」として残り続け、起動するたびに Transaction.updates に流れてきます。

私はエラー処理の分岐で finish を呼び忘れていて、テスト中に同じ購入通知が起動のたびに何度も来る現象に遭遇しました。

// ❌ 検証エラー時にfinishを呼んでいない
for await result in Transaction.updates {
    do {
        let transaction = try await checkVerified(result)
        await updatePurchasedProducts()
        await transaction.finish()
    } catch {
        // エラーログだけ出して終わり…これがダメ
        print("Verification failed")
    }
}

解決策

検証に成功しようが失敗しようが、トランザクション自体は finish する のが基本方針です(検証失敗時の扱いはアプリの方針次第ですが、無限ループを防ぐためにも finish は必須)。

// ✅ 検証失敗でも finish する
for await result in Transaction.updates {
    switch result {
    case .verified(let transaction):
        await updatePurchasedProducts()
        await transaction.finish()
    case .unverified(let transaction, let error):
        print("Unverified transaction: \(error)")
        // 信頼できないトランザクションだが、ストリームから外すために finish
        await transaction.finish()
    }
}

Transaction.unfinished で未完了トランザクションを確認できるので、開発中に確認グセをつけておくと安全です。

// 起動時に未完了トランザクションを確認
for await result in Transaction.unfinished {
    // 必要な処理 → finish
}

ハマり③ Sandboxの期間圧縮による感覚の狂い

何が起きるか

Sandbox環境では、サブスクリプションの期間が大幅に圧縮されます。

本番の期間 Sandboxでの実時間
1週間 3分
1ヶ月 5分
2ヶ月 10分
3ヶ月 15分
6ヶ月 30分
1年 1時間

そして自動更新は 最大6回で停止 します。1年プランなら6時間で更新が止まります。

これ自体は「テストを高速化するための仕様」で便利な機能なのですが、本番のサブスクライフサイクルと乖離した状態でロジックを組むと、思わぬ場所で破綻します。

私が踏んだ具体例

「初回購入から30日以内なら特定のオンボーディング画面を表示する」というロジックを書いていました。

// ❌ Sandboxで動作確認したつもりが…
if let purchaseDate = transaction.originalPurchaseDate,
   Date().timeIntervalSince(purchaseDate) < 30 * 24 * 60 * 60 {
    showOnboardingForNewSubscriber()
}

Sandboxでは1ヶ月が5分に圧縮されるので、テスト中はあっという間に「30日経過済み」状態になります。「あれ、新規購入者向けの画面が出ない」と気づくまでに時間を溶かしました。

解決策

  • 「期間そのもの」をテストする処理は、StoreKit Configurationファイル(.storekit)を使ったローカルテストで時間を任意に進める
  • Sandboxは「購入フロー」「更新通知の受信」「サブスク状態の遷移」のテストに使う
  • 本番想定の経過時間ロジックは、ユニットテストで現在時刻を注入できる設計にする
// ✅ 時間依存ロジックは依存注入できる形に
protocol DateProvider {
    var now: Date { get }
}

struct SystemDateProvider: DateProvider {
    var now: Date { Date() }
}

func isWithinTrialPeriod(
    purchaseDate: Date,
    days: Int,
    dateProvider: DateProvider = SystemDateProvider()
) -> Bool {
    let interval = dateProvider.now.timeIntervalSince(purchaseDate)
    return interval < TimeInterval(days * 24 * 60 * 60)
}

これでテスト時に MockDateProvider を渡して、任意の時刻でロジックを検証できます。


まとめ

StoreKit 2は「APIがモダンになった」というだけで、サブスクリプション課金の本質的な複雑さは何も解決していません。

特に意識すべきは以下の3点です。

  1. Transaction.updates はアプリ起動と同時に監視開始する(ライフサイクルに紐づける)
  2. transaction.finish() を必ず呼ぶ(検証失敗時も含めて)
  3. Sandboxの期間圧縮を本番挙動と混同しない(時間依存ロジックは別途検証)

公式ドキュメントは丁寧に書かれているのですが、「ここを読み飛ばすと地雷」のポイントが地味に分散している ので、実装前にざっと一読することをお勧めします。

次回は、AdMobのUMP(User Messaging Platform)同意フロー実装で踏んだ地雷について書く予定です。GDPR対応とAppleのATT(App Tracking Transparency)が絡んで意外と面倒でした。

何か質問やツッコミがあれば、コメントでお気軽にどうぞ。


参考リンク

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?