Swiftネイティブで作るメリットで、BaaSも認証も課金基盤も使わない構成を紹介しました。あの記事は「なぜこの構成にしたか」。この記事は、そこからApp Store公開までに実際に何が起きたかの記録です。

結果だけ先に書くと、要件を凍結した日にPoCと本番実装を始め、4日後に審査へ提出し、7日後に一発で合格しました。速く進んだ理由の大半は、外部サービスがないことそのものではなく、つまずく場所が全部Apple側の仕組みに集約されていたことにあります。

この記事で分かること

  • 要件凍結→PoC→本番→審査提出→合格の実際の日数と、各段階でやったこと
  • CloudKit同期が「無言で止まる」3つの原因と、公開前チェックリスト
  • StoreKit 2を直接使った買い切り課金の実装と、ローカルテストでは検証できないこと
  • App Store Connectへの提出で実際に踏んだ3つの落とし穴
  • 公開後にモデルを変更するときの安全ルール

結論: リリースの難所は「コード」ではなく「Appleの環境の理解」だった

10日間でコードに起因する手戻りはほぼありませんでした。時間を使ったのは、CloudKitのDevelopment/Production環境の違い、StoreKitのテスト環境の制約、App Store Connectの提出物の仕組み——つまりAppleが用意した仕組みを正しく理解することです。

一度理解すれば次からは再現しない種類の問題ばかりなので、この記事はその「一度」を先に済ませてもらうために書いています。

タイムライン

日付 出来事
8/14 要件定義 v0.4 を凍結。PoC 2本(年別写真ピッカー/保存・CloudKit同期)を実機で検証し報告書作成。本番プロジェクト着手・初回コミット(78ファイル・約10,000行)
8/15 初期リリースに含めない機能を判断(PDF出力を落とす)
8/16 Premium価格を¥500に決定。StoreKit Configurationで課金フローをテスト。CloudKit Productionデプロイ(2回目)。App Store Connect APIでメタデータ投入。LP公開
8/17 CloudKit Productionデプロイ(3回目)。スクショ8枚投入。Releaseビルド+Production CloudKitでスモークテスト。Archive→アップロード→19:28 審査提出(1.0+Premium・手動公開)
8/24 審査合格(1.0: READY_FOR_DISTRIBUTION / Premium: APPROVED)
8/26 App Store公開

コミット数は8/14に51、8/16に33、8/17に17。開発は実質4日で、その後の7日は待ち時間です。

PoCを先に2本やった

本番コードを書く前に、要件の中で「本当に成立するか分からない」2点だけを切り出して検証しました。

PoC 検証したこと 結論
年別写真ピッカー PhotoKitで撮影年ごとに写真を出せるか。14年分のライブラリで実用速度か。限定アクセスでも崩れないか 採用。ミドルクラス実機で体感遅延なし。フル・限定・拒否の3状態を確認
保存・CloudKit同期 写真をどの形式・サイズで保存するか。SwiftData→CloudKit同期が実機で成立するか 採用。12MP実写でJPEG q0.8は1.3MBと目標超過→HEIC 長辺2048px q0.7に決定

PoCの報告書には「本番に引き継ぐ実装パターン」と「未検証のまま残るリスク」を書き、本番はそれをコピーして始めました。設計編にも書いた「PoCで根拠を作ってから本番に入る」の実践です。

PoCで分かった、公式ドキュメントだけでは気づきにくい点を3つ挙げます。

  1. CloudKitコンテナの新規作成はCLIでは不可能。 XcodeのSigning & Capabilitiesから作る必要がある
  2. コンテナ作成直後は CKError 5 (badContainer) で失敗する(伝播遅延)。アプリ再起動で解消。本番では初回起動時の失敗を恒久エラーにせず「同期準備中」表示にした
  3. presentLimitedLibraryPickerPhotosUI のimportが必要Photos だけでは出てこない)。iCloudの「ストレージを最適化」対策に isNetworkAccessAllowed = true も必須

つまずき1: CloudKitの本番スキーマは「3回」デプロイした

仕組み

CloudKitはDevelopment環境とProduction環境でスキーマが別です。Xcodeからの開発ビルドはDevelopment、TestFlight・App Store版はProductionを使います。SwiftDataが開発中に自動生成したスキーマは、手動でProductionにデプロイしない限り本番に存在しません。そしてデプロイし忘れてもエラーは出ず、ただ同期されないだけです。

見落としやすい3点

これはやってみて初めて分かりました。

① 一度も値を入れていない項目はスキーマに存在しない。 開発環境のスキーマは「実際に同期されたデータ」から作られます。オプショナル項目に一度も値を入れていないと、その項目はスキーマにない。デプロイ前に、開発版アプリで全項目を一度使う必要がありました。具体的には「猫を2匹登録(片方は推定誕生日)」「なき声を2枠とも録音」「思い出の写真を2枚以上」——これを省くと estimatedBirthFromCD_MemoryImage レコードタイプごと本番に載りません。

② 大きなデータ用のフィールドは、実際に大きなデータを同期させないと生まれない。 .externalStorage を付けた属性は CD_imageData(BYTES)と CD_imageData_ckAsset(ASSET)の2フィールドに分かれ、約1MBを超えた値だけASSET側に逃げます。_ckAsset 側は実際に大きい値が同期されて初めてスキーマに載る。本番の画像は圧縮して1MB未満なので、一時的に圧縮を外したビルドを実機に入れて1枚同期させ、フィールドを作ってから元に戻しました(この一時変更はコミットしない)。

③ モデルにフィールドを足すたびにデプロイが必要。 今回、開発途中で memorialDate(お別れの日)と heroTextPlacement(ヒーロー文字の位置)を追加したため、合計3回デプロイしました。忘れると新機能だけ同期されないという分かりにくい不具合になります。

公開前チェックリスト(実際に使ったもの)

  • 開発版で全項目に一度値を入れ、設定画面で同期成功を確認
  • CloudKit Console → Schema → Deploy Schema Changes to Production
  • Productionに切り替えてRecord Typesとフィールド数を目視確認
  • スモークテスト前に開発版アプリを削除(環境が変わるとローカルの同期メタデータが食い違う)
  • entitlements に一時的に icloud-container-environment = Production を入れ、-configuration Release で実機へ。これで「リリース設定+本番CloudKit」を審査前に踏める

公開後のルール

Productionスキーマは追加のみ。フィールドの削除・型変更はCloudKitが拒否します。これは既存ユーザーを守る仕様で、デプロイ自体でデータが消えることはありません。

危険なのはデプロイではなくアプリ側のモデル変更です。SwiftData+CloudKitは軽量マイグレーションしか対応しておらず、既存プロパティのリネームや型変更を含む版を配布すると、アップデートしたユーザーのアプリが起動時にストアを開けなくなります。公開後は「オプショナルまたはデフォルト値付きのプロパティ追加」だけを許す、とルール化しました。

つまずき2: StoreKit 2の課金は「復元」だけローカルで検証できない

構成

RevenueCatを使わず、StoreKit 2を直接使いました。商品は「Premium」1点(非消耗型・¥500)。解放されるのはデザインテーマ6種と3匹目以降の猫の登録です。

判定はシンプルで、起動時に Transaction.currentEntitlements を舐めて、対象の商品IDが verified かつ revocationDate == nil なら所有とみなします。

func refreshEntitlements() async {
    var owned = false
    for await result in Transaction.currentEntitlements {
        if case .verified(let tx) = result,
           tx.productID == Self.themesProductID,
           tx.revocationDate == nil {
            owned = true
        }
    }
    isPremium = owned
}

func restore() async {
    do {
        try await AppStore.sync()
    } catch StoreKitError.userCancelled {
        return   // サインインをやめただけ。エラー扱いにしない
    } catch {
        lastError = error.localizedDescription   // 失敗しても手元の記録は読み直す
    }
    await refreshEntitlements()
}

ローカルテストでできること・できないこと

App Store Connectで商品を作る前でも、XcodeのStoreKit Configurationファイルで購入→解放→適用の一連はローカルで検証できます。ただし2つ落とし穴がありました。

  • 必ずXcodeの⌘Rで起動する。 ホーム画面のアイコンから起動すると設定が効かない
  • .storekitidentifier / internalID16進のUUID形式でないと読み込まれず、エラーも出さずに本物のApp Storeへフォールバックする

そして「購入を復元する」はローカルでは検証できません。実機のローカル環境ではアプリを削除すると取引記録も消えるため、削除→再インストール→復元をやっても戻すものがない。復元の検証は、App Store Connectで商品を作った後にSandboxテスターで行う必要があります。

Sandboxで確認した結果は少し意外でした。購入→アプリ削除→再インストールで、復元ボタンを押さなくても購入済み状態が戻った。起動時の currentEntitlements がApp Store側の記録を返すためで、機種変更時も同じ挙動になります。「購入を復元する」ボタンは、それでも戻らないときの保険(AppStore.sync())という位置づけです。

審査で用意したもの

  • IAPの審査用スクリーンショット(必須。ないと「準備完了」にならない)。有料テーマを選んで「Premiumにする(¥500)」が出ている画面
  • 審査メモを英日併記で。概要/5分で一通り触れる手順/IAPの出し方/権限(写真・マイク・通知)の理由
  • 独立した購入画面は作らず、設定→デザインテーマで有料テーマを選ぶと下部がPremium案内に変わる形。審査で指摘はなかった

つまずき3: App Store Connectへの提出で踏んだ3つ

提出作業はApp Store Connect APIで自動化していましたが(別記事)、それでも手作業と組み合わせで3つ踏みました。

  1. 提出物にIAPをAPIで追加できない。 公開APIに関係が定義されていないため、IAPはConnectのIAPページ「審査用に追加」で下書きに入れ、1.0はAPIで同じ下書きに追加、という組み合わせになった
  2. 最初に作った提出物(1.0のみ)が送信状態になっていた。 取り下げ(canceled)→1.0は DEVELOPER_REJECTED を経て再提出可能になった。初回はアプリ本体とIAPを必ず同じ提出物に入れる(別々だと審査で弾かれる)
  3. 取り下げ→再提出で releaseTypeAFTER_APPROVAL(自動公開)に戻っていた。 提出後に MANUAL へ再設定。手動公開にしておくと、審査通過後に公開タイミングを自分で決められる

細かいところでは、IAPの配信地域が日本のみになっていたのを全175地域に変更、「価格および配信状況」でMacとApple Vision Proを外す、Appプライバシーを「データを収集しない」にする(写真・記録・声はユーザー自身のiCloudプライベートDBに保存され、開発者はアクセスできないため)、といった作業もありました。

審査結果

一発合格。8/17 19:28に提出し、8/24に1.0が READY_FOR_DISTRIBUTION、Premiumが APPROVED になりました。リジェクトはゼロです。

要因として大きいと思うのは、審査メモに「5分で一通り触れる手順」を英日併記で書いたこと、権限(写真・マイク・通知)それぞれの理由を明記したこと、デモアカウントが不要な設計(サインインがない)だったことです。iOS審査で落ちやすい理由TOP10で挙げた項目は、提出前のQAチェックリストに組み込んでいました。

リリース後の運用コスト

外部サービスがないので、公開後に見るべきものはApp Store ConnectとCloudKit Consoleだけです。

項目 状況
月額固定費 ¥0(Apple Developer Program年会費のみ)
監視対象 CloudKitのエラー率・容量(Console)
分析ツール 未接続(Analytics.swift はDEBUGログのみ)。Appプライバシー「収集しない」と整合
規模 Swift 60ファイル・約10,500行。Domain層(時間軸計算・節目)は純粋関数+ユニットテスト

まとめ

  • 要件凍結・PoC・本番着手を同日に、4日で提出、7日待って一発合格。開発より待ち時間の方が長かった
  • CloudKitは本番スキーマのデプロイを忘れると無言で止まる。しかも「一度も値を入れていない項目はスキーマに載らない」「大きなデータ用フィールドは実際に大きなデータを流さないと生まれない」「モデルを足すたびに再デプロイ」の3点は、やってみないと気づけない
  • StoreKit 2直接でも審査要件はRevenueCat経由と同じ。復元だけはローカルで検証できないのでSandboxを通す
  • 提出は初回はアプリとIAPを同じ提出物に取り下げ後は releaseType を確認
  • 公開後のモデル変更は「オプショナル追加」のみ。リネーム・型変更は既存ユーザーのアプリを起動不能にする

設計編で「維持するものが少ないほど、アプリは長く生きられる」と書きました。リリースを通して分かったのは、それは運用だけでなくリリース作業そのものにも効く、ということです。つまずいた場所は全部Appleの仕組みの中にあり、この記事のチェックリストに落とし込めました。次のアプリではもう踏みません。


この構成で作った「猫の一生アルバム」は、猫との日々を年齢・一緒に暮らした日数・年の3つの時間軸で振り返れるアルバムアプリです。App StoreAppVillageで公開しています。