Rork で iOS アプリを生成すると、起動から画面遷移までは綺麗に動くのに、肝心の課金部分は「ここは自分で書いてください」というコメントだけが置かれていることがあります。実際に App Store に出すなら必ず通る部分なので、ここで手が止まってお蔵入りする方を時々見かけます。
廣川政樹です。2014 年から個人開発で iOS / Android アプリを続けていて、累計 5,000 万 DL の壁紙アプリ群(綺麗な壁紙・浮世絵壁紙・クールな壁紙・イラスト壁紙・Dolice 壁紙)と Lab 4 サイト・Blog 2 サイトを一人で運営しています。5 アプリすべてを 2025 年に StoreKit 1 から StoreKit 2 に移行した直後だったので、Rork で生成したコードに StoreKit 2 を後付けで組み込むのは比較的スムーズに進みました。
このノートはその実装メモです。「Rork で動くものは作れたけれど、課金で詰まっている」「StoreKit 2 の実装例を探しているが iOS 公式サンプルが Rork のコード構成と合わない」という方の参考になればと思います。
なぜ Rork コードに StoreKit 2 を後付けすることになるか
Rork は要件から SwiftUI アプリの骨格を生成してくれますが、IAP(In-App Purchase)の実装は基本的に「ここでアンロックする」というプレースホルダだけ置かれます。理由は単純で、IAP は App Store Connect 側の設定(ProductID 作成、Tax フォーム提出、銀行口座登録、契約承認)が完了していないと動作確認できず、Rork 側でテストできないからです。
Rork が生成するアプリは、おおよそ以下のような構造になります。
MyApp/
├── MyAppApp.swift ← @main エントリポイント
├── ContentView.swift ← トップ画面
├── Views/
│ ├── WallpaperView.swift ← 機能画面
│ └── PremiumGateView.swift ← 「ここで課金」と書かれた仮実装
├── Models/
│ ├── WallpaperItem.swift
│ └── PurchaseManager.swift ← 中身は TODO だけ
└── Resources/
└── Assets.xcassets
PurchaseManager.swift が空っぽなので、ここに StoreKit 2 のロジックを書き込むのが課金実装の本体です。SwiftUI ベースなので StoreKit 2 と相性が良く、StoreKit 1 時代のように SKProductsRequestDelegate を SwiftUI のライフサイクルに無理やり押し込む必要がありません。
私自身、壁紙アプリ群を StoreKit 1 → 2 に移行した直後だったので、Rork コードへの後付けは「移行作業を 1 から繰り返す」感覚で進められました。逆に言えば、StoreKit 2 を初めて触る方には少し学習コストがあります。本稿は「初めて StoreKit 2 を触る人」向けにフルセットで書いています。
App Store Connect 側の準備 — 課金実装よりこっちが鬼門
実装の前に App Store Connect 側で済ませる作業があります。私の経験上、ここで詰まる方が多いです。
第一に、App Store Connect で課金契約と各種フォームを完了 させます。これが未完了だとサンドボックスでも本番でも IAP が動きません。Apple の「Tax, Banking, and Trade Representative Contact Information」のセクションを全て埋める必要があります。
第二に、ProductID の命名規則を決めて作成 します。私は以下のような命名規則で統一しています。
{bundle_id}.{type}.{label}
例:
- com.dolice.wallpaper.consumable.pack_50 (消耗品)
- com.dolice.wallpaper.nonconsumable.premium (買い切り)
- com.dolice.wallpaper.subscription.monthly (月額)
- com.dolice.wallpaper.subscription.yearly (年額)
命名規則を決めずに開発すると後で困ります。1 つ目のアプリで premium_full という ID を作って、2 つ目のアプリで unlock_all という ID を作ると、共通化されたコードで分岐が必要になります。最初に統一しておくのが個人運用での実用的な判断です。
第三に、ProductID は本番と検証で同じものを使う こと。Apple のサンドボックスでテストするときと本番購入で同じ ProductID が使われるので、開発初期に作った ID を後から変更しにくい構造になっています。最初に慎重に決めてください。
StoreKit 2 の基本構成 — async/await で完結する
StoreKit 2 は Swift Concurrency(async/await)前提で設計されていて、Delegate パターンの呪縛から解放されます。Rork が生成する SwiftUI コードとの統合は、@MainActor を付けた ObservableObject で囲うのが筋が良いです。
// PurchaseManager.swift
import StoreKit
import SwiftUI
@MainActor
final class PurchaseManager : ObservableObject {
@Published private ( set ) var products: [Product] = []
@Published private ( set ) var purchasedProductIDs: Set < String > = []
private var updates: Task< Void , Never > ? = nil
static let productIDs = [
"com.dolice.wallpaper.nonconsumable.premium" ,
"com.dolice.wallpaper.consumable.pack_50" ,
]
init () {
// バックグラウンドで Transaction 更新を監視
updates = observeTransactionUpdates ()
}
deinit {
updates ? . cancel ()
}
// App Store から商品情報を取得
func loadProducts () async throws {
products = try await Product. products ( for : Self .productIDs)
}
// 購入実行
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 updatePurchasedProducts ()
return true
case .userCancelled :
return false
case .pending :
return false
@unknown default:
return false
}
}
// 検証 — StoreKit 2 が JWS で署名検証してくれる
func checkVerified < T >( _ result: VerificationResult<T>) throws -> T {
switch result {
case . verified ( let safe) :
return safe
case .unverified :
throw PurchaseError.verificationFailed
}
}
// 現在の購入済み商品を更新
func updatePurchasedProducts () async {
var purchased: Set < String > = []
for await result in Transaction.currentEntitlements {
guard case . verified ( let transaction) = result else { continue }
if transaction.revocationDate == nil {
purchased. insert (transaction.productID)
}
}
purchasedProductIDs = purchased
}
// Transaction 更新を継続監視
private func observeTransactionUpdates () -> Task< Void , Never > {
Task { [ weak self ] in
for await result in Transaction.updates {
guard let self else { return }
guard case . verified ( let transaction) = result else { continue }
await transaction. finish ()
await self . updatePurchasedProducts ()
}
}
}
enum PurchaseError : Error {
case verificationFailed
}
}
ポイントは 3 つです。(1) @MainActor で囲うと SwiftUI の @Published が常にメインスレッドで更新される こと(UI 反映の競合を避けます)。(2) Transaction.updates の継続監視を init で起動 すること(プロモーションオファーや家族共有での購入を取りこぼさないため)。(3) transaction.finish() を必ず呼ぶ こと(呼ばないと再起動時に再表示されます)。
SwiftUI 側 — Rork が生成した PremiumGateView に組み込む
Rork が生成した PremiumGateView.swift の中身は、たぶんこんな感じです。
// Rork 生成のプレースホルダ
struct PremiumGateView : View {
var body: some View {
VStack {
Text ( "Premium 機能をアンロック" )
Button ( "購入" ) {
// TODO: implement purchase
}
}
}
}
これを PurchaseManager と結合します。
// PremiumGateView.swift (実装版)
struct PremiumGateView : View {
@EnvironmentObject var purchaseManager: PurchaseManager
@State private var isProcessing = false
@State private var errorMessage: String ?
var body: some View {
VStack ( spacing : 24 ) {
Image ( systemName : "crown.fill" )
. font (. system ( size : 60 ))
. foregroundStyle (.yellow)
Text ( "Premium 機能をアンロック" )
. font (.title2. bold ())
VStack ( alignment : .leading, spacing : 8 ) {
Label ( "全壁紙の解放" , systemImage : "checkmark.circle.fill" )
Label ( "広告非表示" , systemImage : "checkmark.circle.fill" )
Label ( "週次新作の優先配信" , systemImage : "checkmark.circle.fill" )
}
. foregroundStyle (.secondary)
if let product = purchaseManager.products. first ( where : {
$0 .id == "com.dolice.wallpaper.nonconsumable.premium"
}) {
Button {
Task { await buy (product) }
} label : {
Text ( " \( product. displayPrice ) で購入" )
. frame ( maxWidth : . infinity )
. padding ()
}
. buttonStyle (.borderedProminent)
. disabled (isProcessing)
} else {
ProgressView ( "商品情報を取得中..." )
}
Button ( "購入を復元" ) {
Task { await restore () }
}
. font (.footnote)
if let errorMessage {
Text (errorMessage). foregroundStyle (.red). font (.footnote)
}
}
. padding ()
. task {
try? await purchaseManager. loadProducts ()
await purchaseManager. updatePurchasedProducts ()
}
}
func buy ( _ product: Product) async {
isProcessing = true
defer { isProcessing = false }
do {
_ = try await purchaseManager. purchase (product)
} catch {
errorMessage = "購入に失敗しました: \( error. localizedDescription ) "
}
}
func restore () async {
do {
try await AppStore. sync ()
await purchaseManager. updatePurchasedProducts ()
} catch {
errorMessage = "復元に失敗しました: \( error. localizedDescription ) "
}
}
}
.task modifier で loadProducts() を呼ぶのが SwiftUI 流の正解で、onAppear ではなく task を使うと自動的に View の lifetime に合わせて cancel されます。
@EnvironmentObject で PurchaseManager を受け取っているので、アプリのエントリポイント側で注入が必要です。
// MyAppApp.swift
@main
struct MyAppApp : App {
@StateObject private var purchaseManager = PurchaseManager ()
var body: some Scene {
WindowGroup {
ContentView ()
. environmentObject (purchaseManager)
}
}
}
「Premium 機能」のアンロック判定
PurchaseManager.purchasedProductIDs を見てアプリ全体で課金状態を判定します。Rork が生成する壁紙アプリの場合、壁紙詳細画面で「これは Premium 限定」を判定する箇所がいくつかあるはずです。
// WallpaperDetailView.swift
struct WallpaperDetailView : View {
let wallpaper: WallpaperItem
@EnvironmentObject var purchaseManager: PurchaseManager
@State private var showPaywall = false
var isPremiumUnlocked: Bool {
purchaseManager.purchasedProductIDs. contains (
"com.dolice.wallpaper.nonconsumable.premium"
)
}
var body: some View {
VStack {
if wallpaper.isPremium && ! isPremiumUnlocked {
// ぼかして表示 + Paywall ボタン
Image (wallpaper.imageName)
. resizable ()
. blur ( radius : 20 )
Button ( "Premium で解放" ) {
showPaywall = true
}
. buttonStyle (.borderedProminent)
} else {
Image (wallpaper.imageName)
. resizable ()
Button ( "壁紙を保存" ) {
saveWallpaper ()
}
}
}
. sheet ( isPresented : $showPaywall) {
PremiumGateView ()
}
}
}
「ぼかして見せる」のは保存導線として効きます 。一切見せないと「何を買うのか分からない」状態になりますが、ぼかして見せれば「これが解放されるのか」と直感的に分かります。私のアプリ群でも、ブラー表示を導入したら IAP 転換率が +18% 改善した経験があります。
RevenueCat を使うかどうかの判断軸
StoreKit 2 は素で使うことも可能ですが、RevenueCat のような IAP 管理 SaaS を入れる選択肢もあります。個人開発者の実用範囲で比較すると、判断軸は以下です。
観点 素の StoreKit 2 RevenueCat
初期実装の工数 半日〜1 日 半日(SDK 導入)
ProductID 管理 App Store Connect だけ App Store Connect + RevenueCat ダッシュボード
購入レシート検証 デバイス側で完結 RevenueCat サーバーが検証
月額コスト 0 円 月収 $2,500 まで無料、超えたら 1% 手数料
解約理由分析 なし RevenueCat ダッシュボードで可視化
サーバ連携 自分でレシート検証サーバを書く Webhook で完結
クロスプラットフォーム iOS/Android で別実装 iOS/Android で統一
私の壁紙アプリ群は iOS のみで月収 $1,000 程度の規模感 なので、RevenueCat なしで素の StoreKit 2 で運用しています。月額 0 円で十分回ります。
一方で、Lab サイトの Stripe メンバーシップは 解約理由分析と価格 A/B が必要 なので、Stripe Dashboard を使い倒しています。アプリ側で同等の分析が必要になったら RevenueCat に移行する可能性は十分あります。
「とりあえず素の StoreKit 2 で始めて、必要になったら RevenueCat に移行」が個人開発者にとって現実的な進め方だと思います。RevenueCat への移行は ProductID を保ったまま SDK を差し替えるだけなので、後付けで入れても破綻しません。
ハマりどころ — 本番で事故らないための 5 点
実装後に気づいた事故りポイントを共有します。
第一に、サンドボックスでの動作確認は別 Apple ID で行う こと。本番 Apple ID でサンドボックスにログインすると、本番購入の Transaction 履歴が乱れる可能性があります。「Sandbox Apple ID」を App Store Connect の Users and Access から作って、デバイスの設定 > App Store > Sandbox Account にログインしておきます。
第二に、Transaction.updates を起動時に必ず起動 すること。アプリ起動時にプロモーションオファーや家族共有経由の購入が反映されるためです。これを忘れると「購入したのに復元できない」という問い合わせが届きます。
第三に、.task ではなく .onAppear で loadProducts() を呼ぶと、View が再描画されるたびに API が叩かれる こと。.task だと自動的に cancel されるので、SwiftUI の流儀に従うほうが安全です。
第四に、価格表示は product.displayPrice を使う こと。自分で "¥250" のような文字列を書くと、ユーザーの国によって表示通貨と実際の請求通貨がずれて、レビューで星 1 が来ます。displayPrice は通貨記号も含めて適切にローカライズされます。
第五に、Transaction の revocationDate をチェックする こと。家族共有での購入は親 Apple ID で取り消されることがあり、currentEntitlements には残るが revocationDate が設定されています。これを見落とすと「取り消された購入で Premium が解放されたまま」になります。
運用してみた数字
5 アプリで StoreKit 1 → 2 に移行した後の数値を共有します(StoreKit 1 時代との比較)。
IAP 失敗率: 約 2.5% → 0.4%(-84%)
レシート検証エラーの問い合わせ: 月 8 件 → 月 1 件
復元購入の成功率: 約 92% → 99%
IAP 関連のコード行数: 約 600 行 → 約 280 行
行数が半分以下に減ったのが地味に効いて、メンテナンスが楽になりました。SKProductsRequestDelegate や SKPaymentTransactionObserver のようなデリゲートパターンが消えて、async/await で書けるのは可読性が劇的に違います。
Rork が生成するコードと相性が良いのも実利として大きいです。Rork は SwiftUI 主体のコードを吐くので、StoreKit 2 と組み合わせると「Rork が骨格を作り、StoreKit 2 部分だけ手で書き足す」という分業が綺麗に成立します。
次に手を入れる予定
このパターンでも残課題があります。
第一に、サーバーサイドのレシート検証 です。素の StoreKit 2 は JWS の署名検証をデバイス側で行うので、Jailbreak されたデバイスでは理論的にバイパスされる可能性があります。私のアプリ群は無料壁紙が主なので影響は小さいですが、コンテンツ単価が高いアプリならサーバーサイド検証を入れる検討余地があります。Apple の App Store Server API(旧 Server-to-Server Notifications V2)で実装可能です。
第二に、SwiftUI Paywall の A/B テスト化 です。Antigravity の並列エージェントで AdMob 最適化を回しているのと同じ要領で、Paywall UI の A/B 検証も自動化できそうです。Remote Config で Paywall バリアントを切り替えて、転換率を Evaluator が判定する流れです。
第三に、RevenueCat 移行のリハーサル です。月収が伸びて分析が必要になったタイミングで素早く移行できるよう、移行手順を 1 度書いておきたいと考えています。
Rork で iOS アプリを作っていて、課金部分で詰まっている方の参考になれば嬉しいです。私自身、Rork のスピード感と StoreKit 2 の async/await が組み合わさると、個人開発の生産性が一段上がる感覚があります。同じように Rork で個人開発しているクリエイターの方の参考になれば嬉しいです。