0
2

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プラン(Free/Basic/Pro)のサブスクリプションを実装する完全ガイド

0
Posted at

はじめに

個人開発アプリで収益化を考えたとき、「どの課金モデルを選ぶか」は避けて通れない問題です。

  • 買い切りにするか、サブスクリプションにするか
  • 無料プランはどこまで使えるようにするか
  • 広告と課金のバランスをどう取るか

筆者は「Veloquo」という英語学習アプリ(動画に二重字幕を付けて学習する)を開発する中で、Free / Basic / Pro の3プラン構成に落ち着きました。この記事では、StoreKit 2を使った実装の全体像を、実際のコードとともに解説します。

対象読者は、StoreKit 2でサブスクリプションを初めて実装する方、複数プランの設計に悩んでいる方です。

3プランの設計思想

なぜ2プランではなく3プランか

最初は Free と Pro の2プランで考えていました。しかし、月額580円という価格は「ちょっと試してみたい」ユーザーにはハードルが高い。一方で、無料のままだと広告収入だけでは厳しい。

そこで間に Basic プランを挟むことで、段階的なアップグレードパスを作りました。

各プランの内容

  • Free: 字幕生成1日3本まで、動画読み込み時にインタースティシャル広告あり
  • Basic(月額280円): 広告非表示、字幕生成1日5本まで
  • Pro(月額580円 / 年額4,800円): 広告非表示、字幕生成無制限、将来のAI機能へのアクセス

ポイントは、Basicの「広告非表示」です。広告が気になるだけなら280円で解決できる。これが「まず課金してみよう」という最初の一歩になります。

商品IDの設計

App Store Connectで登録する商品IDは、後から変更できません。プラン変更や価格改定を見越して、末尾にバージョン番号を付けています。

static let basicMonthlyID = "com.example.basic.monthly"
static let proMonthlyID = "com.example.pro.monthly"
static let proYearlyID = "com.example.pro.yearly"

monthly2monthly3のように番号が飛んでいるのは、App Store Connectで商品を作り直した痕跡です。個人開発あるあるですが、一度作った商品IDは削除しても再利用できないため、こうなります。

StoreManagerの実装

課金管理の中核となるStoreManagerの全体構成を見ていきます。

SubscriptionTierの定義

まず、プランを表すenumをComparableに準拠させます。

enum SubscriptionTier: Comparable {
    case free
    case basic
    case pro
}

Comparableにしているのは、>=での比較を可能にするためです。「Basic以上か?」という判定が頻出するため、これだけで実装がかなり楽になります。

Productの取得

func loadProducts() async {
    guard availableProducts.isEmpty else { return }
    isLoading = true
    defer { isLoading = false }
    do {
        let products = try await Product.products(for: Self.allProductIDs)
        await MainActor.run {
            self.availableProducts = products.sorted { $0.price < $1.price }
        }
    } catch {
        print("Failed to load products: \(error)")
    }
}

Product.products(for:)にSetで商品IDを渡すだけで、App Store Connectに登録した商品情報を取得できます。StoreKit 1時代のSKProductsRequestと比べると劇的にシンプルです。

取得した商品は価格の昇順でソートしています。PaywallのUIで安いプランから表示するためです。

購入処理

func purchase(_ product: Product) async throws -> Bool {
    let result = try await product.purchase()
    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        await transaction.finish()
        await refreshStatus()
        return true
    case .userCancelled:
        return false
    case .pending:
        return false
    @unknown default:
        return false
    }
}

StoreKit 2ではproduct.purchase()を呼ぶだけで購入フローが走ります。戻り値は3パターンです。

  • .success: 購入成功。検証してからfinishする
  • .userCancelled: ユーザーがキャンセルした
  • .pending: Ask to Buy(ファミリー共有)などで保留中

注意点として、.successで返ってきたトランザクションは必ずfinish()を呼ぶ必要があります。呼ばないとトランザクションが未完了のまま残り、次回起動時にもTransaction.updatesで通知され続けます。

トランザクション検証

private nonisolated func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .unverified:
        throw StoreError.failedVerification
    case .verified(let value):
        return value
    }
}

StoreKit 2ではトランザクションの署名検証がデバイス上で自動的に行われます。VerificationResult.verifiedならAppleの署名が正しいことが保証されているため、サーバーサイドでのレシート検証が不要になります。

個人開発でサーバーを持たない場合、これは非常にありがたい仕組みです。

現在のプランの判定

func refreshStatus() async {
    var entitlements: [String] = []

    for await result in Transaction.currentEntitlements {
        guard let transaction = try? checkVerified(result) else { continue }
        entitlements.append(transaction.productID)
    }

    let newTier: SubscriptionTier
    if entitlements.contains(Self.proMonthlyID) || entitlements.contains(Self.proYearlyID) {
        newTier = .pro
    } else if entitlements.contains(Self.basicMonthlyID) {
        newTier = .basic
    } else {
        newTier = .free
    }

    await MainActor.run {
        self.currentTier = newTier
    }
}

Transaction.currentEntitlementsは、ユーザーが現在有効な権利を持つすべてのトランザクションを返すAsyncSequenceです。これをイテレートして、どの商品IDが含まれているかでプランを判定します。

Proには月額と年額の2つの商品IDがあるため、どちらかが含まれていればProと判定しています。

トランザクションのリアルタイム監視

private func listenForTransactions() -> Task<Void, Never> {
    Task.detached {
        for await result in Transaction.updates {
            if let transaction = try? self.checkVerified(result) {
                await transaction.finish()
                await self.refreshStatus()
            }
        }
    }
}

Transaction.updatesは、アプリ起動中にトランザクションの状態が変わったときに通知を受け取るAsyncSequenceです。

具体的には以下のケースで発火します。

  • 他のデバイスで購入した場合
  • サブスクリプションが更新された場合
  • Ask to Buyが承認された場合
  • 払い戻しが行われた場合

これをアプリのライフサイクル全体で監視するため、init()Task.detachedとして起動し、deinitでキャンセルしています。

購入の復元

func restorePurchases() async {
    try? await AppStore.sync()
    await refreshStatus()
}

復元はAppStore.sync()を呼ぶだけです。これによりApp Storeと同期が行われ、refreshStatus()で最新の状態を取得できます。

IMG_8708.PNG

SubscriptionStoreView vs カスタムUI

SubscriptionStoreViewを選んだ理由

iOS 17から使えるSubscriptionStoreViewは、Appleが提供するサブスクリプション購入UIです。カスタムUIを自前で作る選択肢もありますが、以下の理由からSubscriptionStoreViewを採用しました。

  • App Reviewでリジェクトされにくい(Appleのガイドラインに準拠したUI)
  • 価格表示、トライアル表示、プラン切替のUIが自動生成される
  • ローカライズも自動(ユーザーのApp Store地域に合わせた通貨表示)
  • 復元ボタンの表示も.storeButtonで制御できる

productIDsでの初期化

SubscriptionStoreView(productIDs: [
    StoreManager.proYearlyID,
    StoreManager.proMonthlyID,
    StoreManager.basicMonthlyID,
]) {
    VStack(spacing: 16) {
        Image("LaunchIcon")
            .resizable()
            .scaledToFit()
            .frame(width: 80, height: 80)
            .clipShape(RoundedRectangle(cornerRadius: 18))

        Text("Veloquo Premium")
            .font(.title2.bold())

        VStack(alignment: .leading, spacing: 10) {
            featureRow(icon: "sparkles", text: "広告を完全に非表示", tier: "Basic〜")
            featureRow(icon: "infinity", text: "字幕生成が無制限", tier: "Pro")
            featureRow(icon: "brain.head.profile", text: "将来のAI機能を利用", tier: "Pro")
        }
        .padding(.horizontal)
    }
    .padding(.vertical)
}
.storeButton(.visible, for: .restorePurchases)
.storeButton(.hidden, for: .cancellation)
.subscriptionStoreControlStyle(.prominentPicker)

SubscriptionStoreViewのイニシャライザにproductIDsの配列を渡すと、その順序でプランが表示されます。ここでは年額Proを最上位に配置し、おすすめプランとして目立たせています。

クロージャ内にはマーケティングコンテンツ(ヘッダー部分)を自由に配置できます。ここでアプリアイコンや特典リストを表示しています。

モディファイアによるカスタマイズ

  • .storeButton(.visible, for: .restorePurchases): 「購入の復元」ボタンを表示。App Reviewで必須
  • .storeButton(.hidden, for: .cancellation): 閉じるボタンは自前で実装するため非表示にする
  • .subscriptionStoreControlStyle(.prominentPicker): プラン選択UIのスタイル。.prominentPickerは大きなカード型の表示

閉じるボタンとフッターリンク

VStack(spacing: 0) {
    HStack {
        Spacer()
        Button("閉じる") { dismiss() }
            .padding()
    }

    SubscriptionStoreView(productIDs: [ /* ... */ ]) { /* ... */ }

    HStack(spacing: 16) {
        Link("プライバシーポリシー",
             destination: URL(string: "https://m-naoki-m.github.io/VeloquoWeb/privacy.html")!)
        Text("・")
            .foregroundStyle(.tertiary)
        Link("利用規約",
             destination: URL(string: "https://m-naoki-m.github.io/VeloquoWeb/terms.html")!)
    }
    .font(.caption)
    .foregroundStyle(.secondary)
    .padding(.bottom)
}

PaywallViewの全体構成は、上から「閉じるボタン」「SubscriptionStoreView」「法的リンク」の3段構成です。プライバシーポリシーと利用規約へのリンクはApp Reviewで求められるため、必ず配置してください。

回数制限の実装

なぜKeychainを使うのか

日次の使用回数をUserDefaultsではなくKeychainに保存しています。理由は単純で、UserDefaultsはアプリを削除・再インストールすると消えるためです。

無料ユーザーが「アプリを消して入れ直せば制限リセット」できてしまうと、課金の動機がなくなります。Keychainに保存したデータはアプリを削除しても端末に残るため、この問題を防げます。

UsageLimiterの構成

@Observable
final class UsageLimiter {
    static let shared = UsageLimiter()

    private let service = "com.example.usageLimiter"
    private let countKey = "dailyUsageCount"
    private let dateKey = "dailyUsageDate"
    private static let dateFormatter = ISO8601DateFormatter()

    static let dailyFreeLimit = 3
    static let dailyBasicLimit = 5

    private(set) var todayCount: Int = 0

Keychainに保存するのは2つの値だけです。

  • dailyUsageCount: 今日の使用回数
  • dailyUsageDate: 最後に使用した日付(ISO8601形式)

日次リセットのロジック

private func resetIfNewDay() {
    let today = Calendar.current.startOfDay(for: Date())
    let savedDateString = keychainGet(key: dateKey) ?? ""
    let savedDate = Self.dateFormatter.date(from: savedDateString) ?? .distantPast
    let savedDay = Calendar.current.startOfDay(for: savedDate)

    if today > savedDay {
        todayCount = 0
        keychainSet(key: countKey, value: "0")
        keychainSet(key: dateKey, value: Self.dateFormatter.string(from: today))
    } else {
        todayCount = Int(keychainGet(key: countKey) ?? "0") ?? 0
    }
}

Calendar.current.startOfDay(for:)で日付だけを比較し、日が変わっていればカウントをリセットします。保存された日付が存在しない場合は.distantPast(遠い過去)をデフォルト値にしているため、初回起動時にも正しくリセットされます。

プランに応じた制限判定

func canGenerate(tier: SubscriptionTier) -> Bool {
    if tier >= .pro { return true }
    resetIfNewDay()
    return !hasReachedLimit(for: tier)
}

func dailyLimit(for tier: SubscriptionTier) -> Int {
    switch tier {
    case .basic: return Self.dailyBasicLimit
    default: return Self.dailyFreeLimit
    }
}

先ほどのSubscriptionTierComparableであることが活きています。tier >= .proという1行でProユーザーは無制限と判定でき、それ以外のプランではdailyLimitと比較します。

Keychainへの読み書き

private func keychainSet(key: String, value: String) {
    let data = Data(value.utf8)
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecAttrAccount as String: key,
    ]

    SecItemDelete(query as CFDictionary)

    var addQuery = query
    addQuery[kSecValueData as String] = data
    SecItemAdd(addQuery as CFDictionary, nil)
}

Keychainの更新は「削除してから追加」のパターンを使っています。SecItemUpdateでも可能ですが、アイテムが存在しない場合にエラーになるため、delete + addのほうがシンプルです。

広告連携

Free時のみインタースティシャル広告を表示

課金状態に応じて広告表示を分岐する部分はContentViewにあります。

.sheet(isPresented: $showingPicker, onDismiss: {
    guard let url = pendingVideoURL else { return }
    pendingVideoURL = nil
    usageLimiter.recordUsage()
    if !storeManager.isBasicOrAbove {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            InterstitialAdManager.shared.show {
                viewModel.translationService.invalidateSession()
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    viewModel.loadVideo(url: url)
                }
            }
        }
    } else {
        viewModel.loadVideo(url: url)
    }
})

ここでのポイントは3つあります。

isBasicOrAboveによる分岐

var isBasicOrAbove: Bool { currentTier >= .basic }

StoreManagerに用意したこのプロパティで、Basic以上のユーザーには広告を表示しません。Free / Basic / Pro の3プラン構成では、「広告非表示」をBasicの特典にしているため、この判定が重要です。

広告表示のタイミング

sheetのonDismissで広告を表示していますが、DispatchQueue.main.asyncAfter(deadline: .now() + 0.3)で0.3秒の遅延を入れています。これはsheetのdismissアニメーションが完了する前にインタースティシャルを表示すると、UIの衝突でクラッシュすることがあるためです。

制限到達時のPaywall表示

private func showPickerOrPaywall() {
    if usageLimiter.canGenerate(tier: storeManager.currentTier) {
        showingPicker = true
    } else {
        showingPaywall = true
    }
}

「動画を選択」ボタンをタップしたとき、使用回数が上限に達していればPaywallを表示します。動画ピッカーを開いてから「使えません」と言うより、事前にPaywallで案内するほうがユーザー体験として自然です。

残り回数の表示

if !storeManager.isPro {
    Text("今日の残り: \(usageLimiter.remaining(for: storeManager.currentTier))/\(usageLimiter.dailyLimit(for: storeManager.currentTier))本")
        .font(.caption)
        .foregroundStyle(.secondary)
}

Proユーザー以外には残り回数を表示します。「あと1回」という表示が課金への自然な動線になります。

InterstitialAdManagerの実装

func show(completion: @escaping () -> Void) {
    guard let interstitial else {
        print("[Ad] Interstitial ad not ready, skipping")
        completion()
        return
    }

    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
          let rootVC = windowScene.windows.first?.rootViewController
    else {
        completion()
        return
    }

    dismissDelegate = AdDismissDelegate { [weak self] in
        self?.dismissDelegate = nil
        completion()
    }
    interstitial.fullScreenContentDelegate = dismissDelegate
    interstitial.present(from: rootVC)
    self.interstitial = nil
}

広告がロードされていない場合はスキップしてcompletionを呼びます。ネットワーク状況によっては広告がロードできないことがあるため、広告表示を必須にしないことが重要です。広告が出なくても機能は使えるようにしておきます。

広告が閉じられたタイミングで次の広告をプリロードする処理はAdDismissDelegateで行っています。

func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
    InterstitialAdManager.shared.loadAd()
    completion()
}

まとめ

StoreKit 2を使った3プラン構成のサブスクリプション実装を解説しました。

設計の要点

  • SubscriptionTierをComparableにして、プラン比較をシンプルに
  • SubscriptionStoreViewを使って、購入UIの実装コストを最小化
  • Keychainで回数制限を管理し、再インストールによるリセットを防止
  • 広告と課金の分岐はisBasicOrAboveで統一的に判定

StoreKit 2のメリット

StoreKit 1と比べて、async/awaitベースのAPIになったことで実装がかなり楽になりました。特にサーバーサイドのレシート検証が不要になった点は、個人開発者にとって大きいです。

実装時に注意すること

  • 商品IDは後から変更できないため、命名規則を決めておく
  • Transaction.finish()を忘れないこと
  • Transaction.updatesの監視はアプリ起動中ずっと必要
  • sheetとインタースティシャル広告の表示タイミングには遅延を入れる
  • App Reviewでは「購入の復元」ボタンとプライバシーポリシー/利用規約リンクが必須

3プラン構成は実装の手間は増えますが、ユーザーに段階的なアップグレードパスを提供でき、結果として課金率の向上が期待できます。個人開発アプリの収益化を検討している方の参考になれば幸いです。

0
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?