Rork Max が SwiftUI のコードを書いてくれる時代になっても、App Store に出して課金してもらうまでの壁は、画面を作る作業とは別の場所にあります。
先日、生成されたプロジェクトを実機に入れて購入ボタンを押したところ、サンドボックスの購入は通るのに、アプリを再起動すると有料状態が消えていました。購入した事実を端末の中で「いまの権利」として読み直す処理が、どこにも書かれていなかったのです。
見た目の完成度と、課金して使い続けられる状態は別物でした。この差を埋める作業を、審査・課金実装・価格・導線・継続の順にお伝えします。
生成物は「よくできた下書き」として扱い、審査と課金の部分だけは自分の目で読み直します。 これが、私がいまの線引きにしている原則です。
生成コードを審査前に点検する観点
Rork Max の出力は UI の骨格として十分に使えます。一方で、リリース前提で書かれていない部分が出やすいところは、毎回ほぼ同じです。
審査での指摘は、どれもこの表の延長線上にあります。以下、課金に直結する部分を中心に、直し方をお伝えします。
審査で確認される 5 つの項目と直し方
審査のガイドラインは更新されます。実装前に App Store Review Guidelines の最新版を開き、特にサブスクリプションに関する節(3.1.2 付近)を読み直してください。ここでは、生成コードで抜けやすい順に挙げます。
1. プライバシー情報の整合
必要なのは、アプリの収集内容と App Store Connect のプライバシー申告・プライバシーポリシー URL・Privacy Manifest(PrivacyInfo.xcprivacy)を一致させることです。起動時の同意画面そのものが必須というわけではありません。
私が最初に確認するのは、使っている SDK が収集するデータです。広告や解析の SDK を足したのに申告を直し忘れる——この食い違いが、いちばん起きやすいのです。
2. サブスクリプションの解約・管理導線
アプリ内に、購読の管理画面へ移れる入口を置きます。StoreKit 2 には専用の API があり、URL を自前で開くより確実です。
import StoreKit
import SwiftUI
struct ManageSubscriptionButton: View {
@State private var showManage = false
var body: some View {
Button("購読を管理する") { showManage = true }
.manageSubscriptionsSheet(isPresented: $showManage)
}
}
manageSubscriptionsSheet は iOS 15 以降で使えます。ユーザーが解約・プラン変更をアプリの外へ出ずに行えるため、審査の際にも導線を説明しやすくなります。
3. 失敗しても止まらないエラー処理
ネットワークや課金の失敗で画面が固まると、審査でも口コミでも不利になります。ポイントは、失敗を状態として持ち、画面に出すことです。
@MainActor
final class CatalogLoader: ObservableObject {
enum Phase { case idle, loading, loaded([String]), failed(String) }
@Published private(set) var phase: Phase = .idle
func load() async {
phase = .loading
do {
let url = URL(string: "https://api.example.com/items")!
let (data, response) = try await URLSession.shared.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
phase = .loaded(try JSONDecoder().decode([String].self, from: data))
} catch {
phase = .failed("読み込めませんでした。通信状況を確認して、もう一度お試しください。")
}
}
}
print で握りつぶさず、failed を View 側で受けて再試行ボタンを出す、というのが私の線引きです。
4. 権限は使う瞬間に求める
カメラや位置情報は、その機能を開く直前に要求します。許可の理由を示す文言(Info.plist の Usage Description)も、機能の言葉で具体的に書いてください。「アプリの向上のため」のような曖昧な文言は、差し戻しの典型です。
5. iPad での見え方
iPhone 専用でない設定のまま出すなら、iPad で主要画面が破綻しないことを確認します。NavigationSplitView を使うと、一覧と詳細を自然に分けられます。
struct RootView: View {
@State private var selection: String?
private let items = ["Item 1", "Item 2", "Item 3"]
var body: some View {
NavigationSplitView {
List(items, id: \.self, selection: $selection) { Text($0) }
} detail: {
Text(selection ?? "項目を選んでください")
}
}
}
StoreKit 2 でサブスクリプションを組み込む
ここが、冒頭の「再起動すると消える」問題を直す部分です。StoreKit 2 では、購入の瞬間ではなく**いまの権利(entitlement)**を基準に有料状態を判断します。
App Store Connect で、1 つのサブスクリプショングループに月額・年額を作成します。無料トライアルは別の商品ではなく、各商品の「導入オファー」として設定します。
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var isSubscribed = false
@Published var errorMessage: String?
private let productIDs = ["com.example.app.monthly", "com.example.app.yearly"]
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = Task { [weak self] in
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
}
await self?.refreshEntitlements()
}
}
Task { await refreshEntitlements() }
}
deinit { updatesTask?.cancel() }
func loadProducts() async {
do {
products = try await Product.products(for: productIDs)
.sorted { $0.price < $1.price }
} catch {
errorMessage = "商品情報を取得できませんでした。"
}
}
func purchase(_ product: Product) async {
do {
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
errorMessage = "購入の検証に失敗しました。"
return
}
await transaction.finish()
await refreshEntitlements()
case .userCancelled, .pending:
break
@unknown default:
break
}
} catch {
errorMessage = "購入を完了できませんでした。"
}
}
func refreshEntitlements() async {
var active = false
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
productIDs.contains(transaction.productID),
transaction.revocationDate == nil {
active = true
}
}
isSubscribed = active
}
func restore() async {
try? await AppStore.sync()
await refreshEntitlements()
}
}
読み直すと分かるとおり、有料状態の唯一の入口は refreshEntitlements() です。購入直後も、起動時も、Transaction.updates で更新を受けたときも、同じ関数を通します。これで「再起動すると消える」は起きなくなります。
「購入を復元」ボタンも必ず置いてください。審査で確認される項目であり、機種変更したユーザーの助けにもなります。
無料トライアルを表示に反映する
トライアルの有無は、商品の導入オファーから読み取れます。価格表示と一緒に、期間と更新後の金額を明記しておくと、審査でもユーザーの理解でも安全です。
func trialText(for product: Product) -> String? {
guard let offer = product.subscription?.introductoryOffer,
offer.paymentMode == .freeTrial else { return nil }
let unit = offer.period.unit
let value = offer.period.value
let span: String
switch unit {
case .day: span = "\(value)日間"
case .week: span = "\(value)週間"
case .month: span = "\(value)か月"
default: span = "\(value)年"
}
return "\(span)無料、その後 \(product.displayPrice)"
}
トライアルの対象者かどうかは、product.subscription?.isEligibleForIntroOffer で確認できます。対象外のユーザーに「無料」と見せない配慮が必要です。
購入画面
struct PaywallView: View {
@StateObject private var store = StoreManager()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(store.products) { product in
VStack(alignment: .leading, spacing: 4) {
Text(product.displayName).font(.headline)
if let trial = trialText(for: product) {
Text(trial).font(.subheadline)
} else {
Text(product.displayPrice).font(.subheadline)
}
Button("購入する") {
Task { await store.purchase(product) }
}
}
.padding(.vertical, 4)
}
.navigationTitle("プレミアム")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("閉じる") { dismiss() }
}
ToolbarItem(placement: .bottomBar) {
Button("購入を復元") { Task { await store.restore() } }
}
}
.task { await store.loadProducts() }
.onChange(of: store.isSubscribed) { _, subscribed in
if subscribed { dismiss() }
}
}
}
}
onChange の 2 引数形式は iOS 17 以降です。それ以前をサポートする場合は、1 引数形式に置き換えてください。
価格の決め方
価格は、数字を借りてくるより、自分のアプリの数字から決めるほうが外れにくいと考えています。
App Store Connect では、基準価格を決めると各国の価格が自動で展開され、国別に上書きもできます。特定の国の金額を「これが正解」と覚える必要はありません。確認したいのは次の点です。
- 月額と年額の差が、年額を選ぶ理由として読み取れる幅になっているか
- トライアル終了後の金額が、購入画面で最初に目に入る位置にあるか
- 主要な市場で、端数の見え方が不自然になっていないか
年額の割引率は、固定の正解がありません。月額 × 12 に対して年額を何割引にするかは、年額への移行が増えても月額の収益が崩れないかを、数か月の実測で見て調整してください。
導線の作り方 — 価値を体験してから提案する
生成されたオンボーディングは、機能紹介で終わりがちです。購入画面は、ユーザーが一度でも価値を感じた直後に出すほうが、納得して選んでもらえます。
私は次の順にしています。
- 最初の画面で、アプリが解決することを 1 行で示します
- 無料の範囲で、1 回だけ成果を出してもらいます
- 成果が出た直後に、有料機能の入口を見せます
- 閉じても、いつでも戻れる場所を残します
購入画面を最初に出さない、閉じるボタンを必ず置く、の 2 点は譲れない線です。審査の観点でも、押し付けに見えない構成のほうが安全です。
ASO の考え方
キーワードの検索数は、App Store Connect の分析や ASO ツールの実測で確認してください。記事に載っている数字は、時期や地域で変わります。
実装として押さえるのは、次の 3 点です。
- キーワード欄は 100 文字までです。タイトル・サブタイトルと重複する語は入れません
- スクリーンショットの 1 枚目で、得られる結果を言葉で示します
- 説明文の冒頭は、検索結果で切れる前提で書く
継続率を支える設計
解約を減らす最初の一歩は、離脱の理由を知ることです。App Store Connect の分析で、更新率と解約のタイミングを見ます。
通知を使う場合は、許可を得たうえで、内容に実際の新しい価値がある場合だけ送ってください。ローカル通知なら、次のように書けます。
import UserNotifications
func scheduleReminder(afterDays days: Int, title: String, body: String) async {
let center = UNUserNotificationCenter.current()
guard (try? await center.requestAuthorization(options: [.alert, .sound])) == true else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: TimeInterval(days * 24 * 60 * 60), repeats: false)
try? await center.add(UNNotificationRequest(
identifier: "retention-\(days)d", content: content, trigger: trigger))
}
解約したユーザーへの割引提案には、StoreKit のウィンバックオファーが使えます。対応する OS やオファーの設定条件は更新されるので、実装前に Apple の StoreKit のドキュメントで確認してください。
売上の見込みは、自分の数字で試算する
「月額 500 人で何ドル」という目標は、分かりやすい反面、そこへ至る転換率を他人の数字で置くと外れます。次の式を、自分のアプリの実測値で埋める形をおすすめします。
月の有料会員数は、概ね「ダウンロード × 開始率 × 転換率」で入り、継続率で目減りします。まず公開から 1〜2 か月の実測を取り、どの値が最も低いかを見つけてください。改善の効果が出やすいのは、たいてい最も低い値のところです。
最初の 10 人に届ける
最初の購読者は、広告より、顔の見える相手から来ることが多いと感じています。
- 自分が使っているコミュニティで、作った経緯と困りごとを正直に共有します
- 使ってくれた人に感想を直接うかがい、購入画面の言葉に反映します
- 公開後の改善点を短く書き残し、更新のたびにお知らせします
宣伝と共有は、書き方が違います。「買ってください」より「ここで困って、こう直しました」のほうが、読み手の立場に立てるのだと思います。
最後に
次の作業として、まず実機で「購入 → アプリを終了 → 再起動」を一度通してみてください。有料状態が残っていれば、課金の土台は整っています。
そこから、審査の項目を 1 つずつ確認し、数字を自分のアプリの実測で埋めていく。地味な作業ですが、この積み重ねが、生成されたコードを「商品」にしてくれます。