2026年5月、iOS の壁紙アプリ4本の年次更新を進めていたとき、Xcode 26 のビルドログに流れる StoreKit の deprecation 警告が、いよいよ無視できない量になっていることに気づきました。課金まわりのコードは2015年頃に書いた SKPaymentQueue ベースのまま、10年近くほぼ無修正で動き続けていたのです。
2014年に個人開発を始めてから、アプリ群を累計5,000万ダウンロードまで少しずつ育ててきましたが、その間ずっと「動いている課金コードには触らない」が私の暗黙のルールでした。課金は事故が許されない領域で、書き換えのリスクとリターンが見合わないと感じていたからです。それでも今回、Firebase Apple SDK の CocoaPods → SPM 移行で詰まった3つのポイント — 4アプリ実例からで書いた SPM 移行と同じタイミングで、StoreKit 2 への移し替えを決めました。ビルドシステムを刷新する機会は数年に一度しか来ないので、課金層も一緒に動かすのが結果的に一番安全だと判断したためです。
この記録が、同じように古い課金コードを抱えている方の判断材料になれば幸いです。
なぜ10年動いたコードを置き換えるのか
判断の決め手は3つありました。
1つ目は「復元」体験の劣化です。旧 API の restoreCompletedTransactions() は Apple ID のパスワード入力を求める場面があり、機種変更したユーザーから「広告除去を買ったのに戻らない」という問い合わせが毎月のように届いていました。StoreKit 2 の Transaction.currentEntitlements は現在有効な権利を直接列挙できるため、そもそも「復元ボタン」という概念がほぼ不要になります。
2つ目はレシート検証です。旧方式ではレシートをサーバーへ送って openssl で検証するか、危険を承知でクライアント検証するかの二択でしたが、StoreKit 2 はトランザクション自体が JWS 署名付きで、検証済みの状態で API から渡されます。個人開発で検証サーバーを保守し続けるコストを考えると、これだけでも移行する価値がありました。
3つ目は async/await との整合です。アプリ本体のコードがすでに Swift Concurrency 前提に書き換わっていく中で、デリゲートベースの SKPaymentTransactionObserver だけが古い世界に取り残され、橋渡しのグルーコードが増え続けていました。
Before / After — 購入処理はここまで短くなる
まず、10年動いていた旧コードの骨格です。
// Before: SKPaymentQueue ベース(2015年頃に書いたもの)
// デリゲート登録・購入・復元・検証がすべて別の場所に散らばる
class IAPManager: NSObject, SKPaymentTransactionObserver {
func purchase(productID: String) {
let payment = SKMutablePayment()
payment.productIdentifier = productID
SKPaymentQueue.default().add(payment) // 結果はデリゲートで受ける
}
func paymentQueue(_ queue: SKPaymentQueue,
updatedTransactions transactions: [SKPaymentTransaction]) {
for tx in transactions {
switch tx.transactionState {
case .purchased, .restored:
// ここからさらにレシート検証へ続く…
SKPaymentQueue.default().finishTransaction(tx)
default: break
}
}
}
}StoreKit 2 では、購入と権利確認が次の形に集約されます。
// After: StoreKit 2(iOS 15+ / 壁紙アプリ4本に導入した実コードの骨格)
import StoreKit
@MainActor
final class EntitlementStore: ObservableObject {
@Published private(set) var isAdFree = false
// 起動直後に必ず呼ぶ: 現在有効な権利を列挙して状態を再構築する
func refresh() async {
var adFree = false
for await result in Transaction.currentEntitlements {
// verified 以外(改ざん疑い)はここで弾かれる
guard case .verified(let tx) = result else { continue }
// 返金・ファミリー共有停止済みは revocationDate が入る
if tx.productID == "com.example.adfree", tx.revocationDate == nil {
adFree = true
}
}
isAdFree = adFree
print("entitlements refreshed: isAdFree=\(isAdFree)")
// 期待される出力(購入済み端末): entitlements refreshed: isAdFree=true
}
// 購入: 結果が戻り値で返る。デリゲートは不要
func purchaseAdFree() async throws {
guard let product = try await Product.products(for: ["com.example.adfree"]).first else { return }
let result = try await product.purchase()
if case .success(let verification) = result,
case .verified(let tx) = verification {
await tx.finish() // finish を忘れると未完了として残り続ける
await refresh()
}
}
}デリゲート・レシート検証・復元処理が消え、課金層の実装は4アプリ平均でおよそ3分の1の行数になりました。書き換え自体よりも「どこまで消してよいか」の見極めが、この移行の本体だったと感じています。
Antigravity エージェントにどこまで任せたか
4アプリ分の機械的な書き換えは Antigravity のエージェントに委ねました。進め方はAntigravity × Xcode 26 × iOS 26 — WWDC 2026 に備える iOS 開発者のための実践ガイドで書いた構成と同じで、Planning モードで移行計画を立てさせてから、1アプリ目だけを人間が細部までレビューし、残り3本へ横展開する流れです。
ただし、1箇所だけエージェントに触らせなかった場所があります。広告除去状態の最終判定ロジックです。私のアプリでは買い切りの広告除去と、リワード広告視聴による一時的な広告非表示が併存していて、表示可否は isAdFree || isRewardAdFree の合成判定を必ず経由する設計にしています。エージェントは migration の文脈でこの合成判定を「冗長」と見なして単純化しようとしたことがあり、ここを自動で書き換えられると収益と UX の両方が壊れます。機械的な API 置換は任せる、お金の流れを決める判定は人間が握る、という線引きは最後まで守りました。
実機とサンドボックスでつまずいた3点
横展開の途中、テストで3つの想定外がありました。
Transaction.updatesの listen 漏れ: 購入フロー外で届くトランザクション(Ask to Buy の承認後など)はTransaction.updatesで受けてfinish()する必要があります。これを起動直後から listen していないと、未完了トランザクションが再起動のたびに蘇り、購入ダイアログが繰り返し出る状態になりました。- オフライン初回起動で権利が空に見える:
currentEntitlementsはローカルキャッシュから返るのが基本ですが、再インストール直後かつオフラインという条件では空の列挙になり得ます。「空=未購入」と即断して UI を切り替えず、前回判定値を保持した上で次回オンライン時に上書きする実装に変えました。 - 返金の
revocationDateチェック漏れ: エージェントが生成した初版コードは列挙された権利をすべて有効として扱っており、返金済みトランザクションを弾いていませんでした。サンドボックスの返金テストで気づけましたが、レビューなしで通していたらと思うと冷や汗が出ます。
このあたりの「公式ドキュメントを読んだだけでは順番に気づけない罠」は、Antigravity で新 iPhone の解像度対応を乗り越えた話 — iPhone Air / 17 Pro 対応で29箇所ハマった実例のときと同じで、結局は実機での観察がいちばんの教師でした。
次の一歩 — まず「読むだけ」の診断コードから
もし手元に SKPaymentQueue 時代の課金コードが残っているなら、いきなり書き換えるのではなく、Transaction.currentEntitlements を列挙してログに出すだけの診断コードを既存アプリに足してみてください。旧実装を一切壊さずに、StoreKit 2 から見えている権利の世界と、自分のアプリが信じている課金状態のずれを観察できます。私の場合、このずれの一覧がそのまま移行計画になりました。
10年動いたコードを置き換えるのは、率直に言ってまだ少し怖さが残ります。それでも、検証済みトランザクションが言語の型として渡ってくる世界に移ってみると、あの頃レシート検証に費やした時間は何だったのかと思うほど見通しが良くなりました。同じ移行を控えている方の参考になれば幸いです。