【初心者向け】買い切り(買い切り解除)iOSアプリの作り方:StoreKit2で非消耗型IAPを実装→テスト→審査まで

Swift

最近、アプリ開発を本格的に取り組んでいる中で、何度か躓いたところがあったので、同じような方のためになればと思い、まとめました。

App Store ConnectDeveloperに課金について解説しているページもあるので、そちらも参考になされて下さい。

スポンサーリンク
スポンサーリンク

「買い切り」には2種類ある(先に方針を決める)

買い切りの実現方法は主に2つです。

  1. 有料アプリ(Paid App):ダウンロード時点で課金。
  2. 無料 + アプリ内課金(非消耗型 / Non-Consumable):無料で配布し、アプリ内で1回だけ課金して永久解放。

初心者の個人開発で簡単に実装できるのは ②(非消耗型 IAP)。無料で試せるのでCVが作りやすく、購入後は「復元(Restore)」で再インストール・機種変更に対応できます。Appleも「過去の購入はすぐに反映」「復元手段の提供」を明確に要求しています。(AppleDeveloper)

私の場合は、最初に無料でリリース後、バージョンアップ時に買切りを実装しました。初めての課金申請だったので、大分、時間がかかりました。

事前に、App Store Connectで支払い情報/税などを登録する必要があります。

App Store Connect で「買い切り商品(非消耗型)」を作る

商品を作成(Non-Consumable)

App Store Connect → 対象アプリ → Monetization(収益化)In-App Purchases → 作成
タイプは Consumable ではなく Non-Consumable を選びます。

メタデータ(表示名・説明・価格)を入れる

ユーザーに見える文言は ローカライズ(表示名・説明) 側で設定します。

画像2種類に注意(ここが落とし穴)

  • Image(表示用画像):1024×1024、72dpi、RGB、角丸なし…など要件があります(App Store上の露出に使われる枠)。
  • App Review Screenshot(審査用スクショ):審査だけに使われ、ストアには表示されません。アプリが対応するスクリーンショット仕様に合わせてアップします。

さらに、審査を通しやすくするため Review Notes(審査メモ) に「どの画面で購入できるか」「テスト手順」を書くのが定石のようです。

実装(StoreKit 2 / SwiftUI)最小構成

ここでは「買い切り解除(productID 1つ)」の定番構成を載せます。

Store(課金管理クラス)を作る

import StoreKit
import SwiftUI

@MainActor
final class PurchaseStore: ObservableObject {
    // あなたの Product ID(App Store Connect で設定したもの)
    static let proProductID = "com.example.app.pro"

    @Published var product: Product?
    @Published var isProUnlocked: Bool = false
    @Published var lastErrorMessage: String?

    private var updatesTask: Task<Void, Never>?

    init() {
        // 1) 起動直後から Transaction.updates を監視(取りこぼし防止)
        updatesTask = Task { await observeTransactionUpdates() }

        // 2) 起動時に entitlement を復元(再インストール/機種変更でも反映)
        Task { await refreshEntitlements() }

        // 3) 商品情報を取得
        Task { await loadProduct() }
    }

    deinit { updatesTask?.cancel() }

    func loadProduct() async {
        do {
            let products = try await Product.products(for: [Self.proProductID])
            self.product = products.first
        } catch {
            lastErrorMessage = "商品情報の取得に失敗: \(error.localizedDescription)"
        }
    }

    func buyPro() async {
        guard let product else { return }
        do {
            let result = try await product.purchase()
            switch result {
            case .success(let verification):
                let transaction = try checkVerified(verification)
                await transaction.finish()   // finish は必須
                await refreshEntitlements()
            case .userCancelled:
                break
            case .pending:
                // 承認待ち(Ask to Buy等)
                break
            @unknown default:
                break
            }
        } catch {
            lastErrorMessage = "購入に失敗: \(error.localizedDescription)"
        }
    }

    /// 「復元」ボタンに紐付ける(非消耗型は復元導線が必要)
    func restore() async {
        do {
            try await AppStore.sync()
            await refreshEntitlements()
        } catch {
            lastErrorMessage = "復元に失敗: \(error.localizedDescription)"
        }
    }

    /// 現在の権利(entitlements)から Pro 解放を判断
    func refreshEntitlements() async {
        var unlocked = false
        for await result in Transaction.currentEntitlements {
            if let transaction = try? checkVerified(result),
               transaction.productID == Self.proProductID {
                unlocked = true
            }
        }
        isProUnlocked = unlocked
    }

    /// アプリ外で発生した取引も含め、更新を監視
    private func observeTransactionUpdates() async {
        for await result in Transaction.updates {
            if let transaction = try? checkVerified(result),
               transaction.productID == Self.proProductID {
                await transaction.finish()
                await refreshEntitlements()
            }
        }
    }

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

    enum StoreError: Error { case failedVerification }
}

躓いたポイントとその対策です。

  • 起動直後から Transaction.updates を監視:購入成功を取りこぼさないための対応です。
  • Transaction.currentEntitlements で権利を復元:再インストールや機種変更でも「購入済み」を即反映しやすくなります。
  • 復元(Restore Purchases)導線が必須:非消耗型は復元ボタン等を用意する必要があります。
  • 永続化の考え方:非消耗型は「レシート/取引情報」を永続記録として扱うのが基本で、ローカルのフラグはキャッシュに留めるのが安全です。

SwiftUI の画面(購入・復元ボタン)

struct PaywallView: View {
    @StateObject private var store = PurchaseStore()

    var body: some View {
        VStack(spacing: 16) {
            Text(store.isProUnlocked ? "✅ Pro 解放済み" : "🔒 Pro を解放する")
                .font(.title3)

            if let product = store.product {
                Text("買い切り: \(product.displayPrice)")
                Button("購入する") {
                    Task { await store.buyPro() }
                }
                .buttonStyle(.borderedProminent)

                Button("購入を復元する") {
                    Task { await store.restore() }
                }
                .buttonStyle(.bordered)
            } else {
                ProgressView("商品情報を取得中…")
            }

            if let msg = store.lastErrorMessage {
                Text(msg).foregroundStyle(.red)
            }
        }
        .padding()
    }
}

テスト(開発中に“課金実機”は不要)

ここを最優先で押さえると、後工程が一気に楽です。

4-1. Xcode の StoreKit Testing(ローカルで即テスト)

Xcode では StoreKit Configuration File を作って、App Store Connect を作り込む前でも課金フローを動かせます。

  • File → New → StoreKit Configuration File
TemplateからStoreKitを選択
  • Nameを付ける(App Store Connectと合わせる)→チェックはしない
  • 「+」→「Non-consumable」を選択
この画面はNon-consumableを作成し終えている
  • Scheme の Options でそのファイルを選択して有効化してコンパイル

あとは、アプリのテストで機能確認するだけです。

まとめ

買い切り(非消耗型IAP)は、(1) App Store Connect 商品作成(2) StoreKit2 実装(購入・復元・権利反映)(3) テスト(Xcode/Sandbox/TestFlight)→(4) 審査情報(Review Screenshot/Notes)の順で進めると、上手くいきます。

私の場合は、「 StoreKit Configuration File」作成時に、App Store ConnectとのSyncにチェックを入れて作成すると上手くいきませんでした。ですので、本手順ではチェックを外しております。

手順等過不足ありましたら、ご指摘下さい。随時更新していきます。

タイトルとURLをコピーしました