ITパスポート300問アプリのStoreKit 2サブスクリプション実装で踏んだ地雷
前回(Core Data + CloudKit で設計した話)の続きです。
個人開発でリリースした iOS アプリ「情シスの教科書」(ITパスポート学習アプリ、約300問収録)に、StoreKit 2 で月額/年額サブスクリプションを実装した際にハマったポイントを共有します。
StoreKit 2 の API そのものの入門は 別記事 で書いたので、今回は 「動くようになってからリリースまでに踏んだ地雷」 に絞ります。
-
ハマり①:
Transaction.currentEntitlementsを信じすぎてオフライン起動でロックされた - ハマり②: 家族共有(Family Sharing)を有効にした瞬間に検証ロジックが破綻
- ハマり③: イントロオファー(無料トライアル)の eligibility 判定で課金済みユーザーをトライアル誘導してしまった
- ハマり④: Sandbox では通るのに本番審査でリジェクトされた Restore Purchases の罠
学習・コンテンツ系の個人開発で StoreKit 2 サブスクを実装する方の参考になれば。
前提: 何を売っているか
- 月額プラン (
com.okojyosoft.joshis.monthly) - 年額プラン (
com.okojyosoft.joshis.yearly) - 同一 Subscription Group に所属(アップグレード/ダウングレードを Apple 側に任せる)
- 7日間の無料トライアル付き(イントロオファー)
- 課金で解放される機能: 全300問解放、SQL実践モード、詳細解説
サブスク状態は前回記事で書いた Core Data + CloudKit の ユーザーデータ側ではなく、StoreKit 2 の Transaction を都度参照する 設計にしました。これは正解だったと思います(後述)。
ハマり① currentEntitlements を信じすぎてオフライン起動でロック
最初に踏んだ地雷です。サブスク状態の判定をこう書いていました。
// ❌ 最初の実装
@MainActor
final class SubscriptionStore: ObservableObject {
@Published private(set) var isPremium = false
func refresh() async {
var active = false
for await result in Transaction.currentEntitlements {
if case .verified(let tx) = result, tx.revocationDate == nil {
active = true
}
}
isPremium = active
}
}
これでテストすると一見動きます。が、機内モードで起動したらサブスク済みユーザーが「未課金扱い」になり、全コンテンツがロックされる事象が発生しました。
何が起きていたか
Transaction.currentEntitlements は、ローカルにキャッシュされた最新の有効レシートを返してくれる API です。ここまでは認識通り。問題は アプリ起動直後の数百ミリ秒、まだキャッシュがロードされていないタイミングがある こと。
私の refresh() は起動と同時に走り、active = false の状態で isPremium を確定させて UI を描画してしまっていました。その後しばらくしてキャッシュが効いて true に戻るのですが、その「ロック表示の一瞬」がオフラインでは確定状態になります。
解決策: 「不明」状態を持つ
サブスク状態は2値ではなく3値で持つべきでした。
enum PremiumState {
case unknown // 起動直後・確定前
case premium
case free
}
@MainActor
final class SubscriptionStore: ObservableObject {
@Published private(set) var state: PremiumState = .unknown
func refresh() async {
var active = false
var sawAnyTransaction = false
for await result in Transaction.currentEntitlements {
sawAnyTransaction = true
if case .verified(let tx) = result, tx.revocationDate == nil {
active = true
}
}
// currentEntitlements は購入履歴があれば必ず何か返す
// (期限切れも含めて)。一度も返って来なければ「未確定」
if !sawAnyTransaction && !hasEverPurchased {
state = .free // 購入履歴フラグが立ったことが無いなら free 確定
} else {
state = active ? .premium : .free
}
}
}
そして UI 側で unknown のときは ロックも解放もしない "ローディング表示" を出す。
switch store.state {
case .unknown:
ProgressView() // 確定するまでロックも解放もしない
case .premium:
PremiumContentView()
case .free:
PaywallView()
}
学んだこと
StoreKit のサブスク状態を Bool で持つな。
「まだ分からない」を表現できる型にする。
特にオフライン起動・低速回線・iCloudサインアウト直後など、「キャッシュはあるが確定情報がまだない」状態は実機ではよく起きます。
ハマり② 家族共有(Family Sharing)を ON にした瞬間に破綻
App Store Connect でサブスクリプションを設定する際、「Family Sharing」のチェックを ON にしました。せっかくなので家族にも使ってもらえる方が良いと思って。
これが審査前日に判明した地雷を生みます。
何が起きていたか
家族共有の購入は Transaction.ownershipType が .familyShared で返ってきます。.purchased ではない。
これ自体は問題ないのですが、私のコードは購入者本人かどうかを判定する処理で ownershipType を見ておらず、家族共有ユーザーに対しても「あなたのサブスク」として manageSubscriptionsSheet を表示 しようとしていました。家族共有メンバーは購入元 Apple ID の管理画面を開けないので、シートが空白になります。
さらに厄介だったのが、サンドボックスの家族共有テストが Xcode の StoreKit Configuration File では再現できない こと。実機+本物の Family Sharing でないと出ない挙動でした。
解決策
struct EntitlementInfo {
let isActive: Bool
let isFamilyShared: Bool
let productID: String?
let expirationDate: Date?
}
func currentEntitlement() async -> EntitlementInfo {
for await result in Transaction.currentEntitlements {
guard case .verified(let tx) = result,
tx.revocationDate == nil else { continue }
return EntitlementInfo(
isActive: true,
isFamilyShared: tx.ownershipType == .familyShared,
productID: tx.productID,
expirationDate: tx.expirationDate
)
}
return EntitlementInfo(isActive: false, isFamilyShared: false,
productID: nil, expirationDate: nil)
}
UI 側で家族共有メンバーには「サブスクの管理は購入者の Apple ID で行ってください」というメッセージを出すように分岐。
if entitlement.isFamilyShared {
Text("家族共有で利用中です。プランの変更・解約は購入者の方が「設定 > Apple ID > サブスクリプション」から行えます。")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Button("サブスクリプションを管理") {
showManageSheet = true
}
}
学んだこと
Family Sharing を ON にするなら ownershipType を必ず分岐する。
そして実機の家族共有テスト環境を1つ用意する。
サンドボックスアカウントだけでは家族共有の挙動は再現しきれません。家族の Apple ID を実機テスト用に1台借りるくらいの覚悟が必要です。
ハマり③ 無料トライアル誘導で課金済みユーザーに「7日間無料」を出してしまった
ペイウォール画面で「7日間無料、その後 月額○○円」と表示していました。これが、過去にトライアルを使い終わった離脱ユーザーが再訪したときも同じ表示になっていました。
App Store の規約上、トライアルは Subscription Group ごとに1回までです。離脱ユーザーが「無料」ボタンを押しても、実際は初回から課金されます。詐欺的UI と判定されかねません。
解決策: subscription.isEligibleForIntroOffer
import StoreKit
func isEligibleForIntroOffer(productID: String) async -> Bool {
guard let product = try? await Product.products(for: [productID]).first,
let subscription = product.subscription else {
return false
}
return await subscription.isEligibleForIntroOffer
}
これを使って表示を分岐:
if isEligibleForTrial {
Text("7日間無料、その後 \(product.displayPrice) / 月")
Button("無料で始める") { purchase() }
} else {
Text("\(product.displayPrice) / 月")
Button("購読する") { purchase() }
}
落とし穴がもう一つあって、isEligibleForIntroOffer は Subscription Group 単位で判定されること。月額でトライアルを使ったユーザーが年額に切り替えた場合も「再度のトライアル不可」になります。年額側のペイウォールでも同じ判定が必要でした。
学んだこと
トライアル表示を出す前に必ず isEligibleForIntroOffer を確認する。
判定は Subscription Group 単位であることを忘れない。
これは審査ガイドライン的にも結構厳しめに見られるところなので、リリース前に最優先でチェックすべきでした。
ハマり④ Sandbox では通るのに本番審査で Restore がリジェクト
Apple の審査ガイドライン 3.1.1 で 「購入の復元手段を必ず提供すること」 が義務付けられています。私は最初こう書いていました。
// ❌ リジェクトされた実装
Button("購入を復元") {
Task {
try? await AppStore.sync()
await store.refresh()
}
}
審査リジェクト理由の趣旨:
「購入の復元」ボタンをタップしたが、復元が完了したことを示すフィードバックが表示されない。
Sandbox では AppStore.sync() がほぼ瞬時に完了するため、refresh() で UI が即更新されて「動いてるように見える」だけでした。本番ネットワーク+本物のレシート確認では数秒かかることがあり、その間ユーザーに何も返らない。
解決策
enum RestoreState {
case idle, syncing, succeededWith(Bool), failed(Error)
}
@MainActor
final class RestoreCoordinator: ObservableObject {
@Published var state: RestoreState = .idle
func restore(store: SubscriptionStore) async {
state = .syncing
do {
try await AppStore.sync()
await store.refresh()
let restored = store.state == .premium
state = .succeededWith(restored)
} catch {
state = .failed(error)
}
}
}
UI 側で必ずフィードバックを返す:
switch coordinator.state {
case .syncing:
ProgressView("購入を復元中...")
case .succeededWith(true):
Text("購入を復元しました。プレミアム機能をお楽しみください。")
case .succeededWith(false):
Text("復元可能な購入が見つかりませんでした。")
case .failed(let error):
Text("復元に失敗しました: \(error.localizedDescription)")
case .idle:
EmptyView()
}
これで再申請を通しました。
学んだこと
Restore Purchases は「成功・該当なし・失敗」の3パターンの
ユーザー向けフィードバックを必ず実装する。
Sandbox の速さに騙されない。
おまけ: サブスク状態を Core Data に保存しなかった判断
前回記事で書いた通り、このアプリのユーザーデータは Core Data + CloudKit で同期しています。「サブスク状態も同期したい」と最初は思いましたが、結局やめました。
理由:
- 真実の源は常に Apple のサーバー。同期した値が古くなった瞬間、誤動作する
- CloudKit 同期は数秒〜数分の遅延がある。
Transaction.currentEntitlementsを直接見たほうが速い - サブスクは Apple ID に紐づく。Core Data ユーザーデータは iCloud アカウントに紐づく。同じとは限らない(別 Apple ID で課金、同じ iCloud で学習進捗、というケースがある)
サブスク状態は毎回 StoreKit から取る。Core Data には「いつから有料機能を使い始めたか」程度のメタ情報のみ。これが正解でした。
まとめ
StoreKit 2 は StoreKit 1 と比べて格段に書きやすくなりましたが、「動く」と「リリースに通る」の間にはまだ距離があります。
- サブスク状態は Bool ではなく 3値以上で持つ(unknown を表現できる型)
- Family Sharing を有効にするなら
ownershipTypeで必ず分岐 - トライアル表示は
isEligibleForIntroOfferで必ず制御 - Restore Purchases は3パターンの完了フィードバックを実装
- サブスク状態の真実の源は Apple サーバー。アプリ DB には保存しない
特にハマり①(unknown 状態)とハマり③(イントロオファー判定)は、サンドボックスで動作確認しているだけだと気づきにくい タイプの地雷です。実機・本番ネットワーク・実 Apple ID での確認は、リリース前に必ずやっておきたいところです。
次回はリリース後に判明した「サブスク解約率を測るための分析設計」について書く予定です。StoreKit 2 だけでは churn 分析が難しいというのが現状の悩みなので、その辺の試行錯誤を共有できればと思います。
質問・ツッコミがあれば、コメントでお気軽にどうぞ。