- 準備
- Storerkit2 課金システム実装例(サブスク + 買い切り)
- 外部購入を感知するリスナー。作成例
- .verified 〜
- Product.products(for:)
- Transaction.currentEntitlements
- Transaction.updates
- UserDefaults(suiteName: “group.your.app”)
- Transaction.originalID
- Product.PurchaseError
- Configuration.state
- .storeProductsTask(for:)
- Task.detached
- 引数に Transaction として型エラーが出る場合
- StoreKit Configuration テスト購入状態の解除
- アプリのアンインストール状態にする方法(macOS)
- StoreKit Configuration 表示言語設定
- StoreKit Configuration 失敗設定
- Sandbox テスト以降時
- Sandboxで「イベントが来ない」時の原因
準備
・App Store Connectでアプリ登録
・アプリ内課金 or サブスクリプションの登録
・Xcode で IAP Capability ON
左のプロジェクト名 → Targets → Signing & Capabilities → + Capability → 検索窓に「In-App Purchase」と入力し、ダブルクリックして追加
・Xcode で StoreKit Configuration ファイルを作成、ストアと同期

Scheme → Edit Scheme → Run → Options → StoreKit Configuration

Storerkit2 課金システム実装例(サブスク + 買い切り)
特に商品表示に拘りがなければサブスクを扱う場合は SubscriptionStoreView を使うこと。
ユーザー許諾書(EULA)とプライバシーポリシーが明示できてないとリジェクトされます。
あ、変数名の修正忘れてたlol
サブスクを売るならアプリ内に必須条件
- サブスク名
- 期間
- 価格
- Privacy Policyリンク
- Terms of Use(EULA)リンク
そしてストアメタデータにも:
- Privacy Policy URL(App Store Connectの欄)
- EULAリンク(説明文 or EULA欄)
が必要。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
import SwiftUI import StoreKit @MainActor @Observable final class UnlockManager { var is_premium: Bool = false let lifetimeID = "App Storeで登録した🆔" private var updateListenerTask: Task<Void, Never>? init() { updateListenerTask = listenForTransactions() Task { await updateEntitlement() } } @MainActor deinit { updateListenerTask?.cancel() } private func listenForTransactions() -> Task<Void, Never> { Task.detached { for await result in Transaction.updates { guard case .verified(let transaction) = result else { continue } await self.updateEntitlement() await transaction.finish() } } } @MainActor func updateEntitlement() async { var premium = false for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } // ✅ 払い戻しされていたら無効 if transaction.revocationDate != nil { continue } // ✅ サブスク or Lifetime if transaction.productType == .autoRenewable || transaction.productID == lifetimeID { premium = true break } } is_premium = premium } } struct UnlockView: View { @Environment(UnlockManager.self) private var UM @State private var lifetimeProduct: Product? @State private var error_message: String? @State private var show_error = false var body: some View { NavigationStack { VStack(spacing: 24) { Text("全ての機能を解放する") .font(.largeTitle) .bold() Text("3日間無料で全機能をお試し") .foregroundStyle(.secondary) Divider() GroupBox("サブスクリプションプラン") { SubscriptionStoreView( groupID: "サブスク【グループ】登録したページにある" ) .subscriptionStoreButtonLabel(.multiline) .subscriptionStorePickerItemBackground(.thinMaterial) // ✅ Privacy Policy 必須 .subscriptionStorePolicyDestination(url: URL(string: "https://〜〜〜〜")!, for: .privacyPolicy) // ✅ Terms of Use 必須 アップル標準URL .subscriptionStorePolicyDestination(url: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!,for: .termsOfService) } GroupBox("永久ライセンスを購入") { if let product = lifetimeProduct { Button { Task { await buyLifetime(product) } } label: { Text("買い切り – \(product.displayPrice)") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Text("一度購入すれば永久に利用できます") .font(.footnote) .foregroundStyle(.secondary) } else { ProgressView("Loading Lifetime option...") .task { await loadLifetimeProduct() } } } Divider() Button("購入を復元") { Task { try? await AppStore.sync() await UM.updateEntitlement() } } .background(.green.opacity(0.5)) .cornerRadius(6) Text("サブスクリプションは、キャンセルしない限り自動的に更新されます。") .font(.footnote) .foregroundStyle(.secondary) } .padding() .frame(width: 480) .alert("エラー", isPresented: $show_error) { Button("OK") { show_error = false } } message: { if let errorMessage = error_message { Text(errorMessage) } } } } private func loadLifetimeProduct() async { do { let products = try await Product.products( for: [UM.lifetimeID] ) lifetimeProduct = products.first } catch { print("Failed to load lifetime product:", error) } } private func buyLifetime(_ product: Product) async { do { let result = try await product.purchase() switch result { case .success(let verification): if case .verified(let transaction) = verification { await transaction.finish() await UM.updateEntitlement() } case .userCancelled: break case .pending: // 承認待ち(ファミリー共有の承認待ちなど) showErrorMessage( title: String(localized: "購入保留中"), message: String(localized: "購入は保留されています。承認後に利用可能になります。") ) @unknown default: // 予期しないケース showErrorMessage( title: String(localized: "購入に失敗しました"), message: String(localized: "もう一度お試しください。") ) } } catch StoreKitError.networkError(let error) { // ネットワークエラー showErrorMessage( title: String(localized: "接続エラー"), message: String(localized: "インターネット接続を確認してください。") ) print("Network error:", error) } catch StoreKitError.notAvailableInStorefront { // 地域で利用不可 showErrorMessage( title: String(localized: "利用できません"), message: String(localized: "この商品はお住まいの地域では利用できません。") ) } catch StoreKitError.notEntitled { // エンタイトルメントなし showErrorMessage( title: String(localized: "購入エラー"), message: String(localized: "購入を完了できませんでした。") ) } catch { // その他のエラー showErrorMessage( title: String(localized: "購入に失敗しました"), message: String(localized: "エラーが発生しました。もう一度お試しください。") ) print("Purchase failed:", error) } } private func showErrorMessage(title: String, message: String) { error_message = "\(title)\n\(message)" show_error = true } } |
外部購入を感知するリスナー。作成例
StoreKit 2におけるトランザクションリスナーは、アプリ外部での購入(App Storeでの直接購入、他端末での同期、Ask to Buyなど)をリアルタイムで検知するために不可欠です
。
2026年時点の最新のベストプラクティスに基づき、アプリ起動時にリスナーを初期化する実装方法を解説します。
1. リスナーの基本構造
Transaction.updates を使用して非同期ストリームを監視します。これを Task 内で実行し、アプリのライフサイクル全体で維持することが推奨されます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
import StoreKit class StoreManager: ObservableObject { // リスナーのタスクを保持(不要になったらキャンセル可能にするため) private var updatesTask: Task<Void, Never>? = nil init() { // アプリ起動時にリスナーを開始 updatesTask = listenForTransactions() } deinit { // インスタンス破棄時にタスクをキャンセル updatesTask?.cancel() } private func listenForTransactions() -> Task<Void, Never> { Task.detached { // Transaction.updates は新しいトランザクションを逐次流す for await result in Transaction.updates { do { // 検証(JWSの署名確認など)を行う let transaction = try self.checkVerified(result) // コンテンツの解放処理 await self.updateCustomerProductStatus() // 処理完了をApp Storeに通知 await transaction.finish() } catch { print("Transaction verification failed: \(error)") } } } } // 検証結果の判定用ユーティリティ private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .unverified: // 署名が正しくない場合はエラーを投げる throw StoreError.failedVerification case .verified(let safe): // 検証済みのデータを返す return safe } } @MainActor func updateCustomerProductStatus() async { // 購入済みアイテムのステータス更新ロジックをここに記述 } } enum StoreError: Error { case failedVerification } |
2. 実装の重要ポイント
- 起動直後の開始: 未完了のトランザクション(クラッシュやネットワーク遮断で中断したもの)を回収するため、アプリ起動後すぐにタスクを開始してください。
- 検証の実施: StoreKit 2は自動的に検証を行いますが、
VerificationResultを確認し、.verifiedの場合のみ処理を継続することで、安全性を担保します。 - transaction.finish() の呼び出し: コンテンツを提供した後は必ず
finish()を呼び出してください。これを忘れると、次回のアプリ起動時に同じトランザクションがリスナーに再送され続けます。 - 重複処理の回避: リスナー(
Transaction.updates)は、アプリ内での直接購入(product.purchase())の結果も受け取ることがありますが、基本的にはアプリ外のイベント用です。直接購入の戻り値とリスナーの両方でfinish()を重複して呼んでも問題ありませんが、ロジックの整合性に注意してください。
リスナーがなぜバックグラウンドで動けるか
どこのコードでループしてますか?
結論(超重要)
はい、リスナーは「バックグラウンドで常に待機」しています。
ただし
❌ CPUを回し続けるループではありません
✅ イベント待ちの非同期ストリームです。
「どこでループしているのか?」の答え
あなたのコードのこの部分です👇
|
1 2 3 |
for await result in Transaction.updates { ... } |
これが ループ本体です。
仕組みを正確に説明すると
① Transaction.updates の正体
|
1 |
Transaction.updates |
これは:
AsyncSequence- イベント駆動
- Appleが管理するストリーム
です。
👉
何も起きなければ、ここでスレッドは「待機」
② for await は while ではない
見た目はループですが、中身は👇
待つ → イベントが来たら1回処理 → また待つ
ポーリング ❌
無限ループでCPU占有 ❌
バックグラウンドで勝手に回る ❌
実行ライフサイクル(超重要)
いつ動き出す?
|
1 |
updateListenerTask = listenForTransactions() |
を呼んだ瞬間です。
👉 通常は:
App.init()EntitlementManager.init()
いつ止まる?
|
1 2 3 |
deinit { updateListenerTask?.cancel() } |
EntitlementManager が解放された時
アプリ終了時
バックグラウンドにいる間は?
macOSの場合
- アプリが 起動中 → 有効
- ウィンドウを閉じてもOK
- Dock / メニューバー常駐中もOK
アプリ終了後
- ❌ 動かない
- 再起動時に再度 listener 開始
👉
Appleが未処理トランザクションを保持
→ 次回起動時に流れてくる
なぜリスナーが必要か、currentEntitlements と Transaction.updates(listener)
🔄 結論を一言で
| 項目 | 役割 |
|---|---|
currentEntitlements | 「今この瞬間に有効な権利の一覧」スナップショット |
Transaction.updates | 「これから起きる取引イベント」リアルタイム通知 |
👉 両方必要です。片方だけでは不完全。
① currentEntitlements とは何か?
|
1 2 3 |
for await result in Transaction.currentEntitlements { ... } |
正体
- Appleが保持している
「未失効・有効なトランザクション一覧」 - 期限切れ・キャンセル済みは 含まれない
- 過去に購入した履歴ではない
使うタイミング(超重要)
✅ アプリ起動時
✅ App再起動後
✅ 復元ボタンを押した直後
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func updateEntitlement() async { isPremium = false for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { if transaction.productType == .autoRenewable || transaction.productID == ProductID.lifetime { isPremium = true return } } } } |
② Transaction.updates(listener)とは?
|
1 2 3 |
for await result in Transaction.updates { ... } |
正体
- これから発生する取引イベント
- 非同期ストリーム
- イベント駆動
流れてくるもの
- 新規購入
- 無料トライアル開始
- 自動更新
- 復元
- 他デバイス購入の同期
- 保留 → 承認
👉 全部リアルタイム
使う理由
purchase()の戻り値だけでは 取り逃がす- FaceID完了後にイベントが来る
- App Store側で完結するケースがある
③ 両者の関係(図解)
|
1 2 3 4 5 6 7 8 9 |
📦 App起動 ↓ currentEntitlements ↓ 「今はプレミアム?」を決定 ↓ Transaction.updates(待機) ↓ 「何か起きたら即反映」 |
④ なぜ片方だけだとダメ?
❌ listener だけの場合
- App起動直後は何も流れてこない
- 既存購入を知らない
❌ currentEntitlements だけの場合
- 購入直後にUIが更新されない
- バックグラウンド更新を拾えない
⑤ Apple公式推奨パターン(完成形)
|
1 2 3 4 5 6 7 |
init() { updateListenerTask = listenForTransactions() Task { await updateEntitlement() } } |
👉
「過去」+「未来」を両方カバー
⑥ 実務での使い分け(覚え方)
🧠 覚え方:
currentEntitlements→ 状態確認Transaction.updates→ 状態変化
⑦ よくある落とし穴
「どっちで isPremium を更新する?」
👉 両方
- 起動時 →
currentEntitlements - 変化時 → listener
「finish() はどこで?」
👉 listener 側
(purchase() 側では最低限)
.verified 〜
| .productID | 商品識別: どのアプリ内課金商品(例: “com.example.pro_feature”, “100_coins”)が購入されたかを特定します。 |
| .productType | どのような課金タイプかを安全に判定・取得するためのプロパティ.nonConsumable: 非消耗型(機能解除など、一度購入すると永続).autoRenewable: 自動更新サブスクリプション.nonRenewable: 非自動更新サブスクリプション.consumable: 消耗型(ポイント、ゲームのライフなど |
| .revocationDate | 効果: revocationDateがnilでない場合、その購入は過去に行われましたが、現在は無効化されています。重要性: currentEntitlements(現在の有効な購入リスト)から外れているかどうかの判断に使われます。用途: コンテンツ(プレミアム機能や消耗品)へのアクセスを即座に停止(撤回)するために使用します |
Product.products(for:)
- 商品情報の取得と表示:
- Swiftの非同期処理(async/await)の活用:
- 購入フローの簡素化:
- プロダクトエンタイトルメントの確認:
| Swift Concurrency | 非同期処理(async/await)が中心で、より直感的にコードを書けます。 |
| Product | App Store Connectで設定したプロダクト(サブスクリプション)の情報を取得します。 |
| Transaction | ユーザーの購入やサブスクリプションの状態(有効、期限切れ、アップグレード済みなど)を管理します。 |
| autoRenewable | Transactionオブジェクトのプロパティで、自動更新サブスクリプションのトランザクションかどうかを判定します。 |
- 商品情報の取得:
Productクラスを使って、App Store Connectで設定した自動更新サブスクリプションの情報を取得します。 - 購入処理: ユーザーが購入ボタンをタップしたら、
Product.purchase()を呼び出します。 - トランザクションの監視:
Transaction.updatesをasyncシーquenceとして監視し、購入や更新、キャンセルなどのイベントをリアルタイムで受け取ります。 - 状態の検証と適用: 受け取った
Transactionオブジェクトを検証し、transaction.isActiveやtransaction.expirationDate、transaction.isUpgradedなどのプロパティでサブスクリプションの状態を確認し、アプリ内のコンテンツへのアクセス権を制御します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// 商品ID let productID = "your_auto_renewable_sub_id" // 商品情報を取得 let product = try await Product.products(for: [productID]) // 購入 let result = try await product.first?.purchase() // トランザクションの監視(例) for await verificationResult in Transaction.updates { switch verificationResult { case .unverified(let transaction, let error): // 未検証のトランザクション - 検証ロジックが必要 print("Unverified: \(error)") case .verified(let transaction): // 検証済みのトランザクション if transaction.productID == productID { if transaction.autoRenewable && !transaction.isUpgraded { // 有効な自動更新サブスクリプション print("Subscription is active!") // ユーザーにコンテンツをアンロック } else { // その他の状態(期限切れ、アップグレード済みなど) print("Subscription state changed.") } } } } |
Transaction.currentEntitlements
SwiftのStoreKit 2における
Transaction.currentEntitlements の効果は、アプリ起動時や特定のタイミングで、ユーザーが現在有効にしている(または未完了の)すべての購入トランザクション(非消耗品、有効な自動更新サブスクリプション、非更新サブスクリプション、未完了の消耗品)の最新情報を取得し、アプリの状態を同期・復元できることです。これにより、購入したコンテンツへのアクセス権を即座に復元したり、サブスクリプションの有効性を確認したり、未完了の購入を処理したりする際に非常に強力なツールとなります。
主な効果と用途
- 購入状態の同期と復元:
- サブスクリプション管理:
- 消耗品トランザクションの処理:
- アプリ起動時の状態確認:
Transaction.updates との違い
Transaction.currentEntitlements:その時点での有効な/未完了のトランザクションのスナップショットを返します。Transaction.updates:アプリ起動後(または監視開始後)に発生した新しいトランザクションすべて(アプリ外での購入含む)をリアルタイムでストリーミング(非同期シーケンス)で通知します。
これらを組み合わせることで、アプリはユーザーの購入状態を常に最新に保ち、スムーズな機能提供を実現できます。
Transaction.updates
SwiftのStoreKit 2における
Transaction.updatesは、アプリの外部で発生した購入トランザクション(サブスクリプションの更新・失効、別デバイスでの購入など)を非同期で検知し、アプリ内でその状態を同期・反映させるための非常に重要なAsync Sequenceです。これにより、ユーザーが他のデバイスで購入したコンテンツや、サブスクリプションの自動更新/失効による状態変化をリアルタイムで把握し、アプリ内の購入済みコンテンツを適切に解放(アンロック)できるようになります。
Transaction.updatesの主な効果と役割
- 購入状態の同期:
- リアルタイムなコンテンツアンロック:
- サブスクリプション管理の自動化:
- レシート検証との連携:
実装のポイント
- アプリ起動時や、購入処理の直後に
Transaction.updatesを監視するTaskを開始し、ループ内で新しいトランザクションを待ち受けます。 Task内でfor await transaction in Transaction.updatesを使って非同期にトランザクションを受け取り、verificationResultを処理します。- 受け取ったトランザクション情報を
ObservableObject(PurchaseManagerなど)に保持させ、SwiftUIのビューでそれを監視してUIを自動更新させることが一般的です。
Transaction.updatesはStoreKit 2の「トランザクションの継続的な監視」を実現するための核心部分であり、ユーザー体験の向上とアプリの収益化を安定させる上で不可欠な機能です。
UserDefaults(suiteName: “group.your.app”)
StoreKit 2 とあわせて UserDefaults(suiteName: "group.your.app") を使用する主な効果は、アプリ本体と App Extension(Widget、Share Extension など)の間で課金ステータスや購入情報を共有できることです。
StoreKit 2 単体でも Transaction.currentEntitlements を通じて最新の購入情報を取得できますが、 UserDefaults を併用することで以下のメリットがあります。
主な効果とメリット
- Extension とのデータ共有:
- パフォーマンスの向上(キャッシュ利用):
- オフライン時の動作保証:
実装のポイント
- App Groups の設定: Xcode の
Signing & Capabilitiesで、アプリ本体と Extension の両方に同じ App Group 名(例:group.your.app)を登録する必要があります。 - セキュリティ上の注意:
UserDefaultsは平文の plist ファイルとして保存されるため、機密性の高いトランザクション ID や署名データそのものではなく、「プレミアム機能が有効か」といった単純なフラグ管理としての利用が推奨されます。
機密性の高いデータを共有する場合は、Shared Keychain の利用も検討してください
実装例
1. 共有用の管理クラスを作成する
まず、指定した App Group の領域にアクセスするためのユーティリティを作成します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import Foundation struct SharedStorage { // XcodeのCapabilitiesで設定したApp Group IDを指定 static let suiteName = "group.your.app" static let keys = (isPremium: "is_premium_user", lastCheck: "last_purchase_check_date") static var userDefaults: UserDefaults? { return UserDefaults(suiteName: suiteName) } static func setPremiumStatus(_ active: Bool) { userDefaults?.set(active, forKey: keys.isPremium) userDefaults?.set(Date(), forKey: keys.lastCheck) } static func getPremiumStatus() -> Bool { return userDefaults?.bool(forKey: keys.isPremium) ?? false } } |
2. StoreKit 2 の購入・更新処理で保存する
StoreKit 2 の Transaction.updates や購入完了時のタイミングで、この共有ストレージを更新します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import StoreKit class StoreManager: ObservableObject { func updatePurchaseStatus() async { var isPremium = false // 現在有効なエンタイトルメントを確認 for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { // 非消耗型アイテムや有効なサブスクリプションがあるか判定 if transaction.revocationDate == nil { isPremium = true } } } // 共有UserDefaultsに保存 SharedStorage.setPremiumStatus(isPremium) } // 購入処理の例 func purchase(product: Product) async throws { let result = try await product.purchase() if case .success(let verification) = result { if case .verified(_) = verification { await updatePurchaseStatus() } } } } |
3. Widget (App Extension) 側で参照する
Widget 側では、同じ App Group ID を指定してデータを読み取ります。@AppStorageを使うと、データの変化に合わせて View を自動更新できるため便利です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import SwiftUI import WidgetKit struct MyWidgetEntryView: View { // 共有UserDefaultsを指定して読み取る @AppStorage("is_premium_user", store: UserDefaults(suiteName: "group.your.app")) var isPremium: Bool = false var body: some View { VStack { if isPremium { Text("プレミアム会員限定コンテンツ") } else { Text("通常版コンテンツ") } } } } |
実装時の注意点
- 同期タイミング: メインアプリで値を更新した後、Widget 側で即座に反映させたい場合は
WidgetCenter.shared.reloadAllTimelines()を呼び出して Widget の更新を促してください。 - データの信頼性:
UserDefaultsはユーザーが手動でバックアップから復元したり、特定のツールで書き換えたりする可能性があるため、重要な機能制限は必ずアプリ起動時にTransaction.currentEntitlementsで再検証するようにしてください。
Transaction.originalID
StoreKit 2における
Transaction.originalID (オリジナルID) は、アプリ内課金(特にサブスクリプション)のライフサイクル全体を通じて、初回購入時につけられた一意の識別子を保持するプロパティです。
自動更新サブスクリプションや、購入の復元(Restore)が行われた際に、新しいトランザクションID (id) が発行されても、この originalID は変わりません。
originalID の主な効果と用途
- サブスクリプションの継続的な識別 (最重要)
- 購入の復元(Restore)の特定
- サーバー側でのID連携
id(Transaction Identifier) との区別
使用例 (Swift)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import StoreKit // Transactionを取得 for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } // 初回購入時のIDを取得 let originalTransactionId = transaction.originalID print("Original ID: \(originalTransactionId)") // 最新のID let currentTransactionId = transaction.id print("Current ID: \(currentTransactionId)") // 更新などでIDが異なっても、originalIDで同一製品の連続性を保証できる } |
まとめ
originalID は、「ユーザーがいつこのサブスクリプションを開始したか」を識別するための信頼できるキーとして機能します。
Product.PurchaseError
StoreKit 2における
Product.PurchaseError(および関連するStoreKitError)は、アプリ内課金処理において購入が正常に完了しなかった場合に発生します。このエラーは、ユーザー体験(UX)と決済プロセスの安定性に影響を与えます。
主なエラーの種類と効果・対応は以下の通りです。
1. 主要な PurchaseError の種類と影響
userCancelled(ユーザーキャンセル):productUnavailable(商品利用不可):purchaseNotAllowed(購入不可):networkError(ネットワークエラー):ineligibleForOffer(オファー対象外):invalidOffer...(オファー不備):
2. その他の関連エラーと効果
SKError.Code.paymentInvalid(決済無効): カードの有効期限切れや残高不足など。SKError.Code.paymentNotAllowed(決済不可): ユーザーが支払い手段を承認していない。StoreKitError.notEntitled(権利なし): 必要な特権(Entitlement)がアプリにない。
3. エラー時の一般的なUXへの影響
- 購入完了の遅延: エラーが発生すると、ユーザーは商品(課金アイテム)をすぐに受け取れず、プレミアム機能が利用できない。
- フラストレーション: 適切なフィードバック(何が原因か、どうすれば解決するか)がない場合、ユーザーの離脱につながる。
- サブスクリプションの未更新: サブスクリプションの更新失敗時(Billing Issue)に適切な案内をしないと、解約率が上がる。
4. 推奨される対応策(実装)
- エラーハンドリングの共通化:
do-catch文を使用してPurchaseErrorを捕捉し、エラーに応じたメッセージを表示する。 - トランザクションの終了:
try await transaction.finish()を使用して、たとえエラーであってもトランザクションを最終確定させ、未完了案件をなくす。 - App Store Connectのチェック: 商品IDが正しいか、商品が「販売準備完了」になっているか確認する。
Product.PurchaseError を適切にハンドリングすることで、ユーザーに明確な状況を伝え、購入の再試行を促すなど、購入体験を向上させることができます。
Configuration.state
StoreKit 2における
Configuration.state(一般にXcodeの.storekitファイルに関連する設定)の主な効果は、App Store Connectの実際のアカウントやサーバーと接続せずに、Xcode上でアプリ内課金(購入、更新、課金エラーなど)のシミュレーションを安全かつ高速に行うことです。
具体的には、以下のような効果とメリットがあります。
1. 課金シミュレーションの自由な状態操作
Xcodeの「Manage StoreKit Transactions」ウィンドウを使用して、以下の状態を擬似的に作り出せます。
- サブスクリプションの更新速度アップ: 1ヶ月のサブスクリプションを数分(または数秒)で更新させ、更新処理をテスト可能。
- 購入のキャンセル/返金: ユーザーによる解約や返金をシミュレーション可能。
- Billing Retry(課金失敗): 有効期限切れ後の課金失敗や、その後の復帰(Grace Period)のテスト。
- 購入履歴のクリア: トランザクションを一度リセットし、新規購入者としての挙動をテスト。
2. オフラインでのテスト
実際のサーバー通信がないため、インターネット環境がなくてもテストが可能です。これにより、不安定な通信環境でのエラーハンドリング(プロダクト情報が取れない等)も確認できます。
3. 購入制限(Family Sharing)のシミュレーション
ファミリー共有の「承認が必要な購入(Ask to Buy)」をシミュレーションし、未成年ユーザーが購入をリクエストし、大人が承認する流れをテストできます。
4. 実際のデータとの連携(Sync)
Xcode 14以降、.storekitファイルをApp Store Connect上のプロダクト設定と同期(Sync)させることができます。これにより、実際のApp Storeの設定(価格や名前)を使いながら、決済自体はローカルの安全な環境でテストできます。
まとめ
Configuration.state(StoreKitテスト設定)は、本番環境のデータを汚さずにサブスクリプションの有効期限切れ、更新、返金、課金失敗など、複雑なシナリオを高速に検証できる、StoreKit 2開発における必須ツールです。
.storeProductsTask(for:)
SwiftUIの .storeProductsTask(for:) は、StoreKit 2を使用してApp Storeからプロダクト情報(Product)を非同期に取得し、画面に反映させるためのViewモディファイアです。
主な効果と特徴は以下の通りです。
1. アプリ内課金アイテムの自動読み込み
このモディファイアをビュー(View)に付与すると、そのビューが表示される際に、指定したプロダクトID(IDs)の製品情報をApp Store Connectから自動的に取得(フェッチ)します。これにより、自分で非同期処理(async/await)を記述しなくても、プロダクト情報を簡単に取得できます。
2. ローカライズされた価格と情報の取得
取得した Product オブジェクトには、ローカライズされた価格(displayPrice)や商品名、説明が含まれています。これにより、ユーザーの環境に合わせた価格表示が自動で対応できます。
3. プロダクト変更時の再読み込み(Reactive)
ids パラメータが変更されると、タスクが自動的に再開され、最新の製品情報を再取得します。
4. 状態の監視
action クロージャ内で、製品が正常に取得できたか、あるいはエラーが発生したかなどの状態(Product.CollectionTaskState)を受け取ることができ、それに応じたUIの変更が可能です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import SwiftUI import StoreKit struct ShopView: View { @State private var products: [Product] = [] var body: some View { List(products) { product in Text(product.displayName) Text(product.displayPrice) } // 指定した製品IDの一覧を読み込む .storeProductsTask(for: ["product_id_1", "product_id_2"]) { taskState in // 状態が確定した時にproductsを更新する if let fetchedProducts = try? await taskState.products { self.products = fetchedProducts } } } } |
まとめ
.storeProductsTask(for:) は、「ストア情報を表示するためのデータを取得する手間を最小限にし、UIとデータ取得を連動させる」効果があります。
Task.detached
StoreKit 2における
Task.detached の主な効果は、App Storeの課金処理(ネットワーク通信や購入検証)をメインスレッド(MainActor)から完全に分離し、アプリのUIフリーズを防ぐ(バックグラウンドで処理する)ことです。
一般的な Task { ... } は呼び出し元のコンテキスト(例: SwiftUIのView = MainActor)を引き継ぎますが、Task.detached { ... } はそれを引き継がないため、明示的に別スレッドで実行したい場合に有効です。
StoreKit 2 で Task.detached を使う具体的な効果
1. UIのフリーズ防止(高レスポンス)
購入処理やトランザクションの検証は時間がかかる場合があります。Task.detachedで実行することで、通信中も画面の描画やボタンの反応が止まらず、スムーズなUXを維持できます。
2. 親タスクのキャンセルから分離
ボタンタップなどで開始された Task が、そのViewが閉じられた際にキャンセルされても、Task.detached で実行された処理はキャンセルされず、最後まで完了させることができます。これにより、購入処理が不完全なまま終了するのを防げます。
3. 継続的なリスナー処理(Transaction.updates)
アプリ起動時に Transaction.updates を監視する際、Task.detached を使うことで、メインスレッドを占有せずバックグラウンドでトランザクションの更新を監視し続けられます。
注意点とベストプラクティス
- メインスレッドへの復帰:
Task.detachedの中で購入処理を行い、その結果を使ってUIを更新する(例:text = "購入完了")場合は、必ずawait MainActor.run { ... }を使用してメインスレッドに戻る必要があります。 - 不必要な利用は避ける:
Task.detachedは呼び出し元の優先順位や環境を継承しないため、単純に「メインスレッドから逃がしたい」目的であれば、通常のTask { ... }内でasync関数を呼び出すだけでもバックグラウンドで実行されます。 - 変数の共有: クラス内のメンバ変数を扱う場合、データ競合(Data Race)に注意し、
actorやMainActor.runを適切に使用してください。
実装イメージ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// ボタンタップ時など Button("購入") { Task.detached(priority: .userInitiated) { // メインスレッドから外れたバックグラウンドで実行 do { let result = try await product.purchase() // UIを更新するときはMainActorに戻る await MainActor.run { // 購入結果に基づくUI処理 } } catch { print("購入エラー: \(error)") } } } |
※ StoreKit 2の Product.purchase() 自体が async であるため、Task.detachedを使わなくても内部的にはスレッドが適切に管理されることが多いですが、処理の独立性を保証したい場合に detached が利用されます。
引数に Transaction として型エラーが出る場合
なぜ起きるのか(背景)
Swift には:
Foundation.Transaction(Swift Concurrency / Observation 系)StoreKit.Transaction(課金の Transaction)
という 同名型が存在します。
Transaction
とだけ書くと、
Swift がどちらを使えばいいか分からないためエラーになります。
修正方法①(最も確実・おすすめ)
型をフルパスで指定する
|
1 2 3 4 5 6 7 8 9 |
@MainActor private func handle(_ transaction: StoreKit.Transaction) async { if transaction.productID == ProductID.lifetime || transaction.productType == .autoRenewable { isPremium = true } } |
👉 これが一番安全・一番使われている
修正方法②(import を整理する)
もしファイルの先頭に👇があれば:
|
1 2 |
import Foundation import StoreKit |
この状態だと 曖昧になります。
修正方法③(typealias で短くする)
少し玄人向けですが、可読性は上がります。
|
1 |
typealias SKTransaction = StoreKit.Transaction |
StoreKit Configuration テスト購入状態の解除
StoreKit Configurationファイル(.storekit)を使用したStoreKit 2のテスト環境で、購入状態(購入履歴)を解除・リセットして再テストする方法は以下の通りです。
1. Xcodeのデバッグメニューから削除する (推奨)
最も簡単で速い方法です。
- Xcodeでアプリを実行中(デバッグ中)に、メニューバーの Debug > StoreKit > Manage Transactions を選択します。
- 表示されたトランザクション一覧(購入履歴)の中から、削除したいアイテムを選択します。
- ゴミ箱アイコン(Delete)をクリックして、削除します。
- アプリを再起動、または「リストア(復元)」ボタンを押すと、購入前状態に戻ります。
2. StoreKit Configurationファイルを直接クリアする
特定の商品のテスト中、最初からやり直したい場合に有効です。
- Xcodeで
.storekitファイルを開きます。 - メニューの Editor をクリックし、購入状態を制御します。
3. アプリの再インストール
実機やシミュレータ上で、アプリを削除して再インストールすることでも購入履歴はリセットされます。
注意点
- 非消耗型(Non-Consumable)やサブスクリプションは、購入履歴が残るため、再購入のテストにはこの削除手順が必須です。
- これらを行っても、App Store Connect上の本番・サンドボックス環境には影響しません。
これでも解決しない場合は、Debug > StoreKit > Clear Transaction History を使用すると、環境全体がまっさらな状態になります
アプリのアンインストール状態にする方法(macOS)
① アプリ本体を削除
方法A(Finder)
- Xcode で一度ビルドして起動
- Dock のアプリを 右クリック
- Finder で表示
.appをゴミ箱へ
または
|
1 |
~/Library/Developer/Xcode/DerivedData/YourApp-xxxx/Build/Products/Debug/YourApp.app |
を削除
② Sandbox Container を削除(最重要)
StoreKitの復元テストで ここが本体です。
場所
|
1 |
~/Library/Containers/ |
の中にある
|
1 |
com.yourcompany.yourapp |
これを 丸ごと削除
③ Application Support も確認(念のため)
もし App Sandbox OFF の場合や
独自フォルダを使っているなら:
|
1 |
~/Library/Application Support/YourApp/ |
まとめ
macOS の「アンインストール」とは
アプリ本体 + Container 削除
StoreKit Configuration 表示言語設定
Xcode 上部タブ
Product → Scheme → Edit Scheme → Run → Options → App Language
StoreKit Configuration 失敗設定
Xcode → サイドバーファイル選択で自分で作った .storekit を選択 → Configuration Settings → Purchase Options
各種エラー設定できる、が、なんかしっくりくるテストはできなかった。その下の設定はグレーアウトで触れなかったし。。。
Sandbox テスト以降時
アプリを Archive して App Store Connect にアップロードしておく
App Store Connect → ユーザとアクセス → 上部タブの Sandbox → テストアカウントを追加で作成
名前、メールアドレス等は適当でOK
ここで【購入履歴を消去】、月次更新の間隔を設定できる。期限切れは【購入履歴を消去】で。(恐らくこれは .updetes と .currentEntitlementsで拾えない、try? await AppStore.sync() で強制同期させれば未購入状態にできる)

Xcode 上部タブ
Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → None
上記が終わったらシミュレーターでサブスク等購入をクリックする。
※この時にテストアカウントを入力する、本アカウントを使用しない事!
最低限やるテスト
🔹 課金
- 無料トライアルが開始される
- 週 / 月 / 年 / 買い切り 全て購入できる
- 購入後すぐ
isPremium == trueになる
🔹 復元
- アプリ削除 → 再インストール
- 「購入を復元」で即 Premium になる
🔹 期限・解約
- Sandboxで自動更新OFF
- 期限切れ後に
isPremium == falseに戻る
| 再現したい状態 | 方法 |
| サブスク期間切れ状態 | ネット接続を切って5分(テストアカウント設定で変更可能)経ってからデバッグ |
Sandboxで「イベントが来ない」時の原因
0️⃣ まず最初に確認(9割ここ)
✅ listener は「起動時」に開始しているか?
|
1 2 3 |
init() { updateListenerTask = listenForTransactions() } |
❌ Paywall を開いた時に開始
❌ ボタンタップ時に開始
👉 App起動直後が正解
1️⃣ Sandbox アカウントの罠(最頻出)
❌ App Store にサインインしていない
macOS/iOS:
設定 → App Store
Sandbox Apple ID でサインイン
⚠️ 通常のApple IDではSandboxになりません
❌ Sandbox Apple ID が期限切れ
TestFlight / App Store Connect
Users and Access → Sandbox Testers
👉 作り直すのが一番早い
2️⃣ Xcode 実行設定ミス
❌ Release で実行している
Sandbox は Debug 実行必須
|
1 |
Scheme → Run → Debug |
❌ 別Bundle IDで起動している
StoreKit Configuration と
App の Bundle ID
👉 完全一致必須
3️⃣ StoreKit Configuration の見落とし
❌ .storekit ファイルを作ってない
|
1 |
File → New → File → StoreKit Configuration |
❌ Scheme に設定していない
|
1 2 |
Scheme → Run → Options → StoreKit Configuration を選択 |
❌ ProductID が一致していない
|
1 2 3 |
Product.products(for: [ "com.yourapp.premium.monthly" ]) |
と
|
1 |
.storekit |
👉 1文字違いでもアウト
4️⃣ purchase() は成功しているのに来ない
原因① finish() 済み
前回の transaction を finish 済み
新規イベントが無い
👉 再購入できないのは正常
原因② すでにプレミアム状態
|
1 |
Transaction.currentEntitlements |
で拾われている場合、
新しい updates は流れません
👉
Sandboxでは アカウントを変えるのが正解
5️⃣ listener が cancel されている
❌ Task が GC されている
|
1 2 3 |
Task { for await ... } |
これだけだと 参照が保持されず終了
✅ 正解
|
1 2 |
private var updateListenerTask: Task<Void, Never>? updateListenerTask = listenForTransactions() |
6️⃣ verified で弾いている
❌ これで continue していない?
|
1 2 3 |
guard case .verified(let transaction) = result else { continue } |
Sandboxで:
- ネットワーク不安定
- 設定ミス
👉 unverified が来ることもある
デバッグ時はログ必須👇
|
1 2 3 4 5 6 |
switch result { case .verified(let t): print("verified:", t.productID) case .unverified(_, let error): print("unverified:", error) } |
7️⃣ finish() の位置ミス
❌ purchase() 側で finish()
→ listener 側に来る前に処理済み扱い
→ updates が流れない
✅ listener 側で finish()
|
1 2 |
await handle(transaction) await transaction.finish() |
8️⃣ macOS 特有の落とし穴
❌ サンドボックスOFF
macOS App:
Signing & Capabilities
App Sandbox → ON
In-App Purchase → ON
❌ ネットワーク権限
|
1 |
Outgoing Connections (Client) |
ON必須
9️⃣ 最終チェック(これだけ見ればOK)
✔ デバッグ用ログ
|
1 2 3 4 5 |
print("Listener started") for await result in Transaction.updates { print("Transaction update received") } |
これが 1回も出ないなら:
- listener 起動していない
- Task が死んでいる
🧠 まとめ(現場ルール)
| 症状 | 原因 |
|---|---|
| 何も来ない | listener未起動 |
| purchase成功後も来ない | finish位置ミス |
| 初回だけ来ない | Sandbox ID |
| 再購入できない | 正常動作 |
コメント