RevenueCatなしのStoreKit 2:インディー開発者の本番運用ガイド#
MeteoHealth——天候の変化に敏感な人向けのヘルスケアアプリで、フリーミアムモデルを採用している——のマネタイズを実装したとき、最初に考えたのは「どうコードを書くか」ではなく「アプリとApp Storeの間に本当に仲介者が必要なのか」だった。MeteoHealthの基本機能は無料で、月額$2.99からのPro購読で高度な分析と長期予報が解放される。この種の課題に対する業界標準の答えは、RevenueCatを30分で導入して細部を考えないことだ。私は意図的に別の道を選び、半年間プロダクション環境で純粋なStoreKit 2だけを運用してきた。この記事はAPIの理論的な解説ではなく、その選択が実際にどんなコストと見返りをもたらしたかの報告書だ——何が予想より簡単だったか、何が本当に注意を要したか、そしてどんな状況なら私の選択を真似すべきでないか。
なぜRevenueCatを導入しなかったのか#
「RevenueCatは月間売上$2,500まで無料」という主張は事実であり、多くのインディー開発者にとってはそれだけで話が終わる。しかしMeteoHealthには、純粋なStoreKit 2の方が合理的だと言える3つの事情があった。
- プラットフォームが1つだけ。 アプリはiOS専用で、AndroidもWeb版もない。RevenueCatの核心的な価値——App StoreとGoogle Playを覆う単一の抽象化レイヤー——は、抽象化すべき対象がなければ意味を持たない。
- サブスクリプションもグループも1つだけ。 収益モデルはシンプルで、無料ティアと単一のProレベルのみ。料金マトリクスも地域別の価格実験もなく、専用ダッシュボードを正当化する理由がない。
- エンタイトルメントのロジックを自分で制御したい。 Pro機能へのアクセスチェックは、サードパーティSDKのブラックボックスの中ではなく、自分のコードの中に置きたかった。メジャーアップデートで挙動が変わるリスクを負いたくなかった。
ここで事実と意見を分けておく。事実:StoreKit 2は、単一プラットフォーム・単一サブスクリプションのiOSアプリが必要とするもの——購入、復元、状態確認、オファーコード——を100%カバーする。意見:もしMeteoHealthに3つの料金ティアと地域別ペイウォール実験、そしてAndroid展開の計画があったなら、私は迷わずRevenueCatを選んでいた。理由は後述する。
アーキテクチャ:Product、Transaction、currentEntitlements#
StoreKit 2のサブスクリプションロジックの核心は、UIでもApp Store Connectでもなく、3つのエンティティにある。Product(ストアから取得する商品情報)、Transaction(Appleが暗号署名した購入の事実)、そしてTransaction.currentEntitlements(手動キャッシュなしで得られる、現時点でのユーザーの有効な権利の集合)だ。
これらすべてを、アプリのライフタイム全体で生き続け、バックグラウンドでトランザクションの変化を監視する単一の@MainActorクラスにまとめた。
import StoreKit
@MainActor
final class SubscriptionManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private let productIDs = ["pro.monthly", "pro.yearly"]
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = observeTransactionUpdates()
Task {
await loadProducts()
await refreshEntitlements()
}
}
deinit {
updatesTask?.cancel()
}
func loadProducts() async {
do {
products = try await Product.products(for: productIDs)
} catch {
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
case .userCancelled, .pending:
break
@unknown default:
break
}
}
func refreshEntitlements() async {
var active: Set<String> = []
for await result in Transaction.currentEntitlements {
guard let transaction = try? checkVerified(result) else { continue }
if transaction.revocationDate == nil {
active.insert(transaction.productID)
}
}
purchasedProductIDs = active
}
private func observeTransactionUpdates() -> Task<Void, Never> {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
guard let transaction = try? self?.checkVerified(result) else { continue }
await transaction.finish()
await self?.refreshEntitlements()
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
}
enum StoreError: Error {
case failedVerification
}一度だけ痛い目にあった点がある。Transaction.currentEntitlementsはスナップショットではなく非同期シーケンスだということだ。コールドスタート後にStoreKitがApp Storeとの同期を終える前にrefreshEntitlements()を呼ぶと、一瞬だけ空のエンタイトルメント集合が返り、すでに課金済みのユーザーにペイウォールを見せてしまうことがある。解決策は起動時の一回だけの呼び出しに頼ることではなく、上のコードのようにTransaction.updatesを監視するTaskをアプリのライフタイム全体で生かし続けることだ。
サブスクリプション状態の確認:グレースピリオドとbilling retry#
自作実装で最もよくある間違いは、.subscribedだけを確認して他のすべての状態でアクセスを打ち切ることだ。これはグレースピリオドとbilling retryを壊してしまう——どちらもAppleが、カードの期限切れで課金ユーザーを失わないよう意図的に設計した状態だ。
Product.SubscriptionInfo.Statusはそのために必要な粒度を提供する。
extension SubscriptionManager {
/// Returns true if the user should keep Pro access,
/// including grace period and billing retry — not just `.subscribed`.
func hasActiveProAccess(for group: Product.SubscriptionInfo?) async -> Bool {
guard let statuses = try? await group?.status else { return false }
for status in statuses {
switch status.state {
case .subscribed, .inGracePeriod:
return true
case .inBillingRetryPeriod:
// Apple is still retrying the charge — don't cut access yet.
return true
case .expired, .revoked:
continue
default:
continue
}
}
return false
}
}MeteoHealthにとってこれは理論上の話ではなかった。期限切れや再発行されたカードを持つユーザーは珍しくなく、「支払いに問題があるという警告を表示する」ことと「Pro分析を即座に無効化する」ことの違いは、リテンションに直接影響する。アプリにはサブスクリプション用の独自バックエンドがないため、このロジックはすべて端末上で完結し、レシートを再検証するサーバーは必要ない。単一のサブスクリプション・単一のティアであれば、これはインフラを減らし障害点を減らすという、正当なトレードオフだ。
SubscriptionStoreView:自前のUI層を持たないペイウォール#
iOS 17より前は、ペイウォールを自分の手で組み立てる必要があった——独自のレイアウト、独自のローディング状態、独自の購入ハンドラー。SubscriptionStoreViewはその作業の大部分を取り除く。サブスクリプショングループIDを渡すだけで、価格表示・ローカライズ・restore・ローディング状態はStoreKitが引き受けてくれる。
struct PaywallView: View {
let groupID: String
var body: some View {
SubscriptionStoreView(groupID: groupID) {
VStack(spacing: 12) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.largeTitle)
Text("Unlock Pro Forecasts")
.font(.title2.bold())
Text("Advanced analytics and extended forecasts for $2.99/mo")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
.storeButton(.visible, for: .restorePurchases)
.subscriptionStoreControlStyle(.prominentPicker)
.onInAppPurchaseCompletion { product, result in
if case .success(.success) = result {
// refresh entitlements, dismiss paywall, etc.
}
}
}
}SubscriptionStoreViewを完全に手放したわけではなく、基本の骨格として使い、マーケティングコンテンツ(アイコン、見出し、Pro機能の説明)は上のクロージャーを通して渡した。ぶつかった唯一の制約は、ティアカードのレイアウトカスタマイズが用意されたsubscriptionStoreControlStyleのセットに限られる点で、本当に独自性の高いペイウォール(複雑な機能比較表やアニメーション)を作るなら、結局Productと.task(id:)の上にゼロからビューを組み立てる必要がある。
Win-back offer:離脱した購読者を呼び戻す#
iOS 18からAppleはwin-back offerを追加した。これはすでにサブスクリプションが失効したユーザー向けのオファーであり、まだアクティブなユーザー向けではない。特定のサブスクリプションごとにApp Store Connectで設定する独立したオファー種別で、promotional offerとは異なり、元購読者だけが対象になる。
extension SubscriptionManager {
func availableWinBackOffer(for product: Product) async -> Product.SubscriptionOffer? {
guard let status = try? await product.subscription?.status.first,
case .expired = status.state else { return nil }
let eligible = await product.subscription?.eligibleWinBackOffers ?? []
return eligible.first
}
func redeem(_ offer: Product.SubscriptionOffer, for product: Product) async throws {
let result = try await product.purchase(options: [.winBackOffer(offer)])
if case .success(let verification) = result {
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
}
}
}MeteoHealthのようなフリーミアムアプリにとって、これは具体的なシナリオを解決する。旅行前の予報を見るために1ヶ月だけProに加入し、その後解約したユーザーが、半年後にアプリの画面で割引付きの復帰オファーを目にする——自前のリテンション機構を書いて維持する必要は一切ない。条件は一つ:オファーが事前にApp Store Connectで設定されていること。そうでなければeligibleWinBackOffersは常に空配列を返す。
Xcodeでのテスト:実際のお金を使わずに検証する#
StoreKit 2の中でも過小評価されがちなのはAPIそのものではなくツール群だ。Xcodeの.storekit設定ファイルを使えば、App Store Connectに一度も触れず、実際の支払いも発生させずに、商品・サブスクリプショングループ・プロモーションオファー・win-back offerをローカルに記述できる。
実際に使っている機能は次の通り。
- StoreKit Configuration File ——商品のローカル定義。XcodeからシミュレーターまたはデバイスでStoreKitのスキーマがこのファイルと同期する。
- XcodeのTransaction Manager ——トランザクションを手動で無効化し、アプリがユーザーへの返金に正しく反応するかを確認できる。
- 決済失敗とbilling retryのシミュレーション ——スキームの設定から直接行え、
.inBillingRetryPeriodの処理を検証するために実際のApple IDのカードが期限切れになるのを待つ必要がない。 - win-back offerのローカルテスト ——
.storekitファイルにオファーを設定すれば、すでに解約したテストアカウントにどう表示されるかを、実際のサブスクリプションサイクルを待たずに確認できる。
実務的な結論として、初回購入から解約、グレースピリオド、win-back offerまでのパイプライン全体を、シミュレーター上での一日の作業で検証でき、App Storeへ実際のドルを一切送る必要がない。
StoreKit 2 vs RevenueCat:それでもRevenueCatを勧める場面#
ここは読者に「正解」を売りつけるのではなく、正直に話すべき部分だ。純粋なStoreKit 2がMeteoHealthに合っていたのは、まさに収益モデルがシンプルでiOS専用のアーキテクチャだったからにほかならない。これは万能の推奨ではない。
| 基準 | StoreKit 2(ネイティブ) | RevenueCat |
|---|---|---|
| コスト | 常に無料 | 月間売上$2,500まで無料、以降は収益の一部 |
| クロスプラットフォーム(iOS + Android + Web) | プラットフォームごとに個別実装が必要 | 全プラットフォーム共通のAPIとダッシュボード |
| 最初のサブスクリプション導入までの時間 | 数日(Product、Transaction、entitlements) | 数時間 |
| MRR・チャーン・コンバージョン分析 | 自分で構築する必要がある | ダッシュボードが最初から用意されている |
| ペイウォールのA/Bテスト | 実験の手動実装 | 組み込みの実験機能 |
| サーバーサイド同期(webhook) | App Store Server Notificationsを自前で | マネージドwebhook |
| ロジックとAPI更新への制御 | 完全にコントロール可能、Apple次第 | SDKのアップデートに依存 |
RevenueCatを勧めるのは次のような場合だ。(1)複数プラットフォームで展開しており、サブスクリプションロジックを重複実装したくない場合。(2)複数の料金ティアがあり、四半期に一度より頻繁に価格やオファーを実験したい計画がある場合。(3)StoreKitの深い専門知識を持たないチームで、細かな制御よりリリース速度を優先したい場合。(4)月間売上がRevenueCatが無料で使える$2,500の閾値を下回っている場合——この場合、自前で作っても節約にはならず、単に時間を浪費するだけだ。MeteoHealthにはこれらのどれも当てはまらなかったため、純粋なStoreKit 2は今もプロダクションで稼働している——ただし2つ目のプラットフォームが現れた日には、この判断を見直すつもりだ。
振り返ってみると、App Store Server Notifications v2をもっと早く導入すべきだったと思う。独自のバックエンドがなくても、シンプルなサーバーレスの通知ハンドラーがあれば、クライアント側だけでは遅れて見える一部のケース(銀行発の支払い異議申し立てなど)をカバーできる。とはいえ、この記事の結論は変わらない。単一ティアのiOSアプリにとって、仲介者なしのStoreKit 2は健全で正当化できるアーキテクチャ上の選択だ——サブスクリプションの状態を一度学んでしまい、それをサードパーティSDKに委ねない覚悟がある限りは。Transaction、currentEntitlements、SubscriptionStoreViewを学ぶのに費やした時間は、あるユーザーがなぜPro機能を見られる、あるいは見られないのかを正確に理解できるという形で回収される——そしてその理解は、次のiOSメジャーアップデートで消えることはない。



