Rorkで作ったアプリで月額¥980のサブスクリプションを公開してみたら、初月の解約率が60%を超えていて頭を抱えた、という話を最近いくつか聞きました。私自身も似たような数字を出したことがあります。原因を一つずつ潰していくと、ほぼ全てのケースで「Subscription Group の設計とイントロオファーの出し方」に問題が集中していました。
この記事は、Rorkで生成したアプリにStoreKit 2 ベースのサブスクを実装している方向けに、Subscription Group をどう階層化し、3種類のイントロオファー(Free Trial / Pay As You Go / Pay Up Front)をどう使い分けるかを、実装コード込みでまとめたものです。テクニカルな実装パターンを示しつつ、なぜそう設計するのかという判断基準まで踏み込みます。
Subscription Group はAppleが定めた「複数のサブスクリプションプランを互いに排他的なグループにまとめる仕組み」です。同じグループ内のプランは「ユーザーがいずれか1つだけアクティブにできる」という制約があり、プラン間の切り替え(アップグレード/ダウングレード)が自動で処理されます。
ここで重要なのは、Subscription Group の設計が単なる技術的な分類ではなく、「ユーザーの購入動線そのもの」を決定するということです。Rorkで生成されるStoreKit 2のひな形コードはシンプルなので、グループ設計を後から変えるのも比較的楽ですが、一度ストアに公開してアクティブな課金ユーザーがいる状態で大幅な再設計をすると、既存ユーザーの料金体系をどう移行するかで悩むことになります。最初の設計が後々まで効いてきます。
まず、アプリ起動時に App Store Connect で定義した全Productを読み込み、Subscription Group ごとに分類します。Rorkが出してくれるコードは「productIDの配列を渡してProductを取得する」までは書かれていますが、グループごとに分類するロジックまではないので、これを追加します。
// SubscriptionGroupManager.swift// 目的: App Store Connect から取得した Product を Subscription Group ごとに分類し、// UI が「グループ単位でプラン選択画面を出す」ことを可能にするimport StoreKit@MainActorfinal class SubscriptionGroupManager: ObservableObject { /// グループID(App Store Connect で発行される String)→ そのグループに属する Product @Published private(set) var groupedProducts: [String: [Product]] = [:] /// 取得対象の全 Product ID(Rork が environment 変数から読むなら .env で管理) private let allProductIDs: [String] init(productIDs: [String]) { self.allProductIDs = productIDs } /// アプリ起動時または Paywall 表示直前に呼ぶ func loadProducts() async { do { let products = try await Product.products(for: allProductIDs) // subscription が nil の Product(消耗型 IAP など)は除外する let subscriptionProducts = products.filter { $0.subscription != nil } // Subscription Group ID 単位で分類する let grouped = Dictionary(grouping: subscriptionProducts) { product in product.subscription?.subscriptionGroupID ?? "unknown" } // 各グループ内で価格の安い順に並べる(年額が一番安く、月額が次、最上位プランは別グループで) self.groupedProducts = grouped.mapValues { products in products.sorted { $0.price < $1.price } } } catch { // ネットワークエラーや App Store 障害時のフォールバック // → エラーログに送信(Sentry 等)し、ユーザーには「再試行ボタン付きエラー画面」を出す print("[SubscriptionGroupManager] Failed to load products: \(error)") self.groupedProducts = [:] } }}// 期待される動作:// loadProducts() 完了後、groupedProducts は次のような構造になる// [// "21482710": [Product(月額¥980), Product(年額¥9,800)], ← 基本グループ// "21482711": [Product(プロ月額¥1,980), Product(プロ年額¥19,800)] ← プロ版グループ// ]
subscriptionGroupID は App Store Connect でサブスクを作成した時点で自動採番される文字列です。コード上ではこの ID を groupID 定数として Constants.swift などに定義しておくと、UI 側で「このグループのプランだけを表示」というフィルタリングが楽になります。
// PlanRow.swift// 目的: 1つのプランカードを表示し、イントロオファー資格があれば// その内容を「人間が読みやすい文言」で表示するimport StoreKitstruct PlanRow: View { let product: Product let showIntroOffer: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { Text(product.displayName) .font(.headline) if showIntroOffer, let intro = product.subscription?.introductoryOffer { introOfferLabel(for: intro) .foregroundColor(.accentColor) Text("その後 \(product.displayPrice)/\(periodSuffix(for: product.subscription))") .font(.caption) .foregroundColor(.secondary) } else { Text("\(product.displayPrice)/\(periodSuffix(for: product.subscription))") .font(.subheadline) } } .padding() .background(Color(uiColor: .secondarySystemBackground)) .cornerRadius(12) } /// イントロオファーを「無料」「割引」「前払い」と人間語で出す @ViewBuilder private func introOfferLabel(for intro: Product.SubscriptionOffer) -> some View { switch intro.paymentMode { case .freeTrial: Text("最初の \(periodDescription(intro.period)) 無料") .font(.subheadline) .bold() case .payAsYouGo: Text("最初の \(periodDescription(intro.period)) は \(intro.displayPrice)/\(periodSuffix(intro.period))") .font(.subheadline) .bold() case .payUpFront: Text("\(periodDescription(intro.period))分まとめて \(intro.displayPrice)") .font(.subheadline) .bold() @unknown default: EmptyView() } } private func periodDescription(_ period: Product.SubscriptionPeriod) -> String { let unit: String switch period.unit { case .day: unit = "日間" case .week: unit = "週間" case .month: unit = "ヶ月" case .year: unit = "年" @unknown default: unit = "" } return "\(period.value)\(unit)" } private func periodSuffix(_ subscription: Product.SubscriptionInfo?) -> String { guard let period = subscription?.subscriptionPeriod else { return "" } return periodDescription(period) } private func periodSuffix(_ period: Product.SubscriptionPeriod) -> String { return periodDescription(period) }}// 期待される動作:// Free Trial の場合:// "最初の 7日間 無料"// "その後 ¥980/ヶ月"// Pay As You Go の場合:// "最初の 3ヶ月 は ¥480/ヶ月"// "その後 ¥980/ヶ月"// Pay Up Front の場合:// "6ヶ月分まとめて ¥3,980"// "その後 ¥980/ヶ月"
paymentMode を switch で分岐するのは Apple の API の構造上ほぼ必須です。これを忘れると「最初の3ヶ月無料」と表示しているのに実は「割引価格で課金される」というバグが起こり、レビューで★1の山になります。私は一度これをやらかして、リリース当日にホットフィックスを出した記憶があります。
具体的には、過去30日間のApp Store Connect レポートを見て、「ほぼ売れていないプラン」が含まれていれば、それは選択肢を増やしているだけで購入率を下げている可能性があります。Subscription Group 内のプラン数は2〜3が最もコンバージョンが安定する、というのが私の経験則です。