複数デバイスで同じデータを、と言われて詰まった夜
手元の iPhone で追加したタスクが、iPad にいつまでも現れませんでした。個人開発でそんな声を受け取ったとき、CloudKit の同期がどこで止まっているのかを掴むまでに、ずいぶん時間を使いました。ドキュメントは厚く、エラーは寡黙で、Development と Production でスキーマが食い違う——CloudKit を敬遠したくなる理由は、たいていこの「見えなさ」にあります。
ここから、Antigravity IDE のエージェントを相棒にしながら、SwiftUI + CloudKit でデバイス間リアルタイム同期アプリを本番まで運ぶ道筋を追います。単に動かすのではなく、なぜその設計にしたのかを実装の判断ごとに添えていきます。
本記事の対象読者:
iOS 開発経験があり、CloudKit を使ったことがない・難しさを感じている方
Antigravity による iOS 開発効率化に関心がある方
SwiftUI + CloudKit の構成でアプリを本番リリースしたい方
本記事を読み終えると、以下が実装できるようになります:
CloudKit コンテナのセットアップと CKRecord モデル設計
@Observable + CloudKit によるリアルタイムデータ同期
オフライン対応とコンフリクト(競合)解決ロジック
CKSubscription を使ったサイレントプッシュ通知連携
前提知識・環境構築
必要な環境
Xcode 26 (macOS 15.x Sequoia 以上)
Apple Developer Program 登録済み
Antigravity 最新版(1.20 以降)
iOS 18 以上を対象
Xcode でのセットアップ
まず Antigravity で新規 iOS プロジェクトを作成し、エージェントに以下の指示を出します:
Antigravity プロンプト例:
「SwiftUI を使った iOS アプリを作成してください。iCloud の CloudKit を使ってタスクデータを
デバイス間で同期します。Signing & Capabilities に CloudKit と Push Notifications を追加し、
com.example.myapp.cloudkit という名前のコンテナを設定してください。」
Antigravity は次の手順を自動実行します:
CloudKit ケイパビリティを Xcode プロジェクトに追加
Push Notifications ケイパビリティを追加
Info.plist に必要なエントリを追記
CloudKit コンテナ識別子を .entitlements に設定
Xcode 26 では Signing & Capabilities の設定がテキストベースで扱えるようになり、Antigravity エージェントが .entitlements を直接編集できます。従来は GUI 操作が前提でしたため、この変更で自動化の精度が大きく上がりました。
CloudKit データモデルの設計思想
Record Type のスキーマ設計
CloudKit では RDB のテーブルに相当する「Record Type」を定義します。Antigravity のエージェントに次のようにスキーマ設計を依頼できます:
「タスク管理アプリ用の CloudKit スキーマを設計してください。Task、Tag、Comment の
3つの Record Type を作成し、それぞれのフィールドとリレーションを
CloudKit Dashboard に入力できる形式で出力してください。」
エージェントが生成するスキーマ例:
データベースゾーンの選択
CloudKit の権限管理は「Public / Private / Shared」の3ゾーンで行います。
Private Database :ユーザー固有データの保存に使用。ユーザー自身の iCloud ストレージを消費するため、開発者に追加コストなし
Shared Database :チームや家族間でのデータ共有に使用(iOS 15 以降)
Public Database :全ユーザーが読み取れるグローバルデータ(App Store 公開コンテンツなど)
コード例 1:CKRecord データストアの実装
以下は @Observable マクロを使った CloudKit データストアの完全実装です。Antigravity に「CKRecord を使った Observable データストアを Swift Concurrency で実装してください」と指示することで生成できます。
import CloudKit
import Observation
// CloudKit コンテナ識別子(プロジェクトに合わせて変更)
private let containerIdentifier = "iCloud.com.example.myapp.cloudkit"
// Task の CloudKit Record Type 定数
enum RecordType {
static let task = "Task"
static let tag = "Tag"
}
@Observable
final class TaskStore {
var tasks: [TaskItem] = []
var isLoading = false
var errorMessage: String ?
private let container: CKContainer
private let privateDB: CKDatabase
init () {
self .container = CKContainer ( identifier : containerIdentifier)
self .privateDB = container.privateCloudDatabase
}
// --- データ取得(一覧) ---
func fetchTasks () async {
isLoading = true
defer { isLoading = false }
do {
let predicate = NSPredicate ( value : true )
let query = CKQuery ( recordType : RecordType.task, predicate : predicate)
query.sortDescriptors = [ NSSortDescriptor ( key : "createdAt" , ascending : false )]
let results = try await privateDB. records ( matching : query)
let records = try results.matchResults. compactMap { _ , result in
try result. get ()
}
await MainActor. run {
self .tasks = records. compactMap (TaskItem. init )
}
} catch {
await MainActor. run {
self .errorMessage = error.localizedDescription
}
}
}
// --- 新規タスクの保存 ---
func saveTask ( _ item: TaskItem) async throws {
let record = item. toCKRecord ()
let savedRecord = try await privateDB. save (record)
let savedItem = TaskItem ( record : savedRecord) !
await MainActor. run {
if let index = self .tasks. firstIndex ( where : { $0 .id == item.id }) {
self .tasks[index] = savedItem
} else {
self .tasks. insert (savedItem, at : 0 )
}
}
}
}
// --- TaskItem モデル ---
struct TaskItem : Identifiable , Hashable {
let id: CKRecord.ID
var title: String
var isCompleted: Bool
var dueDate: Date ?
var priority: Int
// CKRecord から初期化
init? ( record : CKRecord) {
guard let title = record[ "title" ] as? String else { return nil }
self .id = record.recordID
self .title = title
self .isCompleted = (record[ "isCompleted" ] as? Int64 ?? 0 ) == 1
self .dueDate = record[ "dueDate" ] as? Date
self .priority = Int (record[ "priority" ] as? Int64 ?? 0 )
}
// CKRecord へ変換
func toCKRecord () -> CKRecord {
let record = CKRecord ( recordType : RecordType.task, recordID : id)
record[ "title" ] = title
record[ "isCompleted" ] = isCompleted ? 1 : 0
record[ "dueDate" ] = dueDate
record[ "priority" ] = Int64 (priority)
return record
}
}
このコードのポイントは3つです。まず、Swift Concurrency(async/await)で CloudKit の非同期 API をシンプルに扱えています。次に、@Observable マクロにより SwiftUI ビューが自動的に変更を検知して再レンダリングされます。そして CKRecord.ID を TaskItem.id として使うことで、CloudKit との ID 整合性を保っています。
コード例 2:SwiftUI リアルタイム同期ビューの実装
次は、TaskStore を使った SwiftUI ビューと、CKSubscription による他デバイスからの変更検知を実装します。
import SwiftUI
import CloudKit
// --- タスク一覧ビュー ---
struct TaskListView : View {
@State private var store = TaskStore ()
@State private var showAddTask = false
var body: some View {
NavigationStack {
Group {
if store.isLoading && store.tasks. isEmpty {
ProgressView ( "読み込み中..." )
} else {
List {
ForEach (store.tasks) { task in
TaskRowView ( task : task) { updated in
Task { try? await store. saveTask (updated) }
}
}
}
}
}
. navigationTitle ( "タスク" )
. toolbar {
ToolbarItem ( placement : .primaryAction) {
Button { showAddTask = true } label : {
Image ( systemName : "plus" )
}
}
}
. sheet ( isPresented : $showAddTask) {
AddTaskView { newTask in
Task { try? await store. saveTask (newTask) }
}
}
. task {
// 初回データ取得
await store. fetchTasks ()
// CKSubscription でリアルタイム変更を購読
await store. subscribeToChanges ()
}
. onReceive (NotificationCenter.default. publisher (
for : .CKAccountChanged
)) { _ in
// iCloud アカウント変更時に再取得
Task { await store. fetchTasks () }
}
}
}
}
// --- TaskStore に購読機能を追加 ---
extension TaskStore {
func subscribeToChanges () async {
let subscriptionID = "task-changes"
// 既存の購読を確認(重複登録防止)
do {
_ = try await privateDB. subscription ( for : subscriptionID)
print ( "✅ 既存の CKSubscription を確認" )
} catch {
// 未登録の場合は新規作成
let subscription = CKQuerySubscription (
recordType : RecordType.task,
predicate : NSPredicate ( value : true ),
subscriptionID : subscriptionID,
options : [
.firesOnRecordCreation,
.firesOnRecordUpdate,
.firesOnRecordDeletion
]
)
// サイレントプッシュ設定(バックグラウンドでデータ取得)
let notificationInfo = CKSubscription. NotificationInfo ()
notificationInfo.shouldSendContentAvailable = true
subscription.notificationInfo = notificationInfo
do {
try await privateDB. save (subscription)
print ( "✅ CKSubscription 登録完了" )
} catch {
print ( "⚠️ CKSubscription 登録エラー: \( error. localizedDescription ) " )
}
}
}
}
shouldSendContentAvailable = true を設定することで、バックグラウンドでの「サイレントプッシュ」が有効になります。他デバイスでタスクが更新された際に、アプリがバックグラウンドで自動的にデータを再取得する仕組みです。ユーザーが通知を受け取ることはなく、次にアプリを開いた際には最新データが表示されます。
コード例 3:オフライン対応とコンフリクト解決
CloudKit でよく発生する問題が「コンフリクト(競合)エラー 」です。複数デバイスで同じレコードを同時編集した場合、CKError.serverRecordChanged が発生します。Antigravity で「CloudKit のコンフリクト解決ロジックを実装してください」と指示すると次のようなコードが生成されます。
extension TaskStore {
// --- コンフリクト対応付きの保存 ---
func saveWithConflictResolution ( _ item: TaskItem) async throws {
do {
try await saveTask (item)
} catch let error as CKError where error.code == . serverRecordChanged {
// サーバー側の最新レコードを取得
guard
let serverRecord = error.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord,
let clientRecord = error.userInfo[CKRecordChangedErrorClientRecordKey] as? CKRecord
else { throw error }
// マージ戦略:
// - タイトルはクライアント(ユーザーが今編集中)を優先
// - 完了状態はサーバー最新値を維持(他デバイスでの完了を尊重)
let mergedRecord = serverRecord
mergedRecord[ "title" ] = clientRecord[ "title" ]
// isCompleted はサーバー値をそのまま使用
let savedRecord = try await privateDB. save (mergedRecord)
let savedItem = TaskItem ( record : savedRecord) !
await MainActor. run {
if let index = self .tasks. firstIndex ( where : { $0 .id == item.id }) {
self .tasks[index] = savedItem
}
}
}
}
// --- ネットワーク不通時のオフライン待機 ---
func saveOfflineIfNeeded ( _ item: TaskItem) async {
do {
try await saveWithConflictResolution (item)
} catch let error as CKError
where error.code == .networkUnavailable || error.code == .networkFailure
{
// オフライン時はローカルキューに追加
PendingChangesQueue.shared. enqueue (item)
print ( "⚡ オフラインキューに追加: \( item. title ) (待機件数: \( PendingChangesQueue. shared . count ) )" )
} catch {
// その他のエラーは UI に表示
await MainActor. run {
self .errorMessage = error.localizedDescription
}
}
}
}
// --- オフライン待機キュー ---
final class PendingChangesQueue {
static let shared = PendingChangesQueue ()
private var queue: [TaskItem] = []
var count: Int { queue. count }
func enqueue ( _ item: TaskItem) {
// 同一 ID の既存エントリを上書き(最新変更のみ保持)
queue. removeAll { $0 .id == item.id }
queue. append (item)
}
// ネットワーク復帰時に一括フラッシュ
func flushAll ( to store: TaskStore) async {
let pending = queue
queue. removeAll ()
for item in pending {
try? await store. saveWithConflictResolution (item)
}
print ( "✅ オフラインキューをフラッシュ完了( \( pending. count ) 件)" )
}
}
本番アプリでは PendingChangesQueue を SwiftData または Core Data に永続化することを推奨します。アプリ終了・再起動をまたいでオフライン変更を保持できます。
ネットワーク状態の監視は Network.framework の NWPathMonitor と組み合わせ、接続復帰時に自動で flushAll を呼び出す設計が一般的です。
設計判断:CloudKit を選ぶ基準と、選ばない基準
同期機能を実装するとき、私が最初に立ち止まるのは「そもそも CloudKit でよいのか」という問いです。技術選定を曖昧にしたまま書き始めると、あとで移行コストを払うことになります。個人開発の限られた時間では、この最初の判断が全体の工数を左右します。
CloudKit が向いているのは、次の条件が揃うときです。
とくに個人開発では「ユーザーの iCloud 容量を使うため、開発者側にストレージ課金が発生しない」という一点が大きく効きます。Firebase や自前 API では、ユーザーが増えるほど費用が積み上がります。一方で、サーバ側でデータを横断的に見たい(ランキング、レコメンド、管理画面)要件が一つでもあると、Private Database だけでは詰みます。ここは妥協せず、要件表に「サーバがデータを読む必要があるか」を先に書き出しておくことをおすすめします。
私自身は、まず Private Database だけで完結する MVP を出し、サーバ側の集計が本当に必要になった段階で一部だけ自前 API に寄せる、という順序を好みます。最初から両方を抱えると、認証と同期の二重管理でエージェントへの指示も複雑になり、開発速度が落ちるためです。
Antigravity での開発効率化ポイント
並列化の前に、スキーマを一つのファイルへ固定する
CloudKit 統合をエージェントに分担させるとき、最初から並列に走らせると高い確率で破綻します。ビュー層を書くエージェントが "isCompleted"、データ層を書くエージェントが "completed" と、別々のフィールド名を素で書いてしまうためです。
CloudKit は未知のフィールドを含む保存を弾きませんし、クエリ側は静かに 0 件を返します。ビルドは通り、ユニットテストも通り、実機で初めて気づきます。
この手戻りを二度経験してから、順序を固定しました。スキーマだけを単独のエージェントに書かせ、成果物を一つのファイルへ落としてから、はじめて分担に入ります。
// CloudKitSchema.swift — 全エージェントが参照する唯一の契約
enum TaskSchema {
static let recordType = "Task"
enum Field {
static let title = "title"
static let isCompleted = "isCompleted"
static let dueDate = "dueDate"
static let modifiedAt = "modifiedAt"
}
/// CloudKit ダッシュボードで queryable インデックスが必要なフィールド
static let queryableFields = [Field.isCompleted, Field.dueDate, Field.modifiedAt]
}
このファイルを置いたうえで、指示に「フィールド名を文字列リテラルで書かないこと。必ず TaskSchema.Field を経由すること」と一行加えておくと、生成コードの取り違えはほぼ消えます。queryableFields を定数として持たせているのは、後述する本番昇格前の確認をコードから機械的に引けるようにするためです。
分担そのものは、スキーマが確定していれば素直に切れます。データ層(CKRecord 変換・保存・取得)、SwiftUI ビュー層、XCTest と CloudKit モックの三つです。逆に言えば、確定前にこの三つを走らせてはいけません。
複数のエージェントを束ねる側の一般的な設計は 複数の専門エージェントを一人で束ねる で扱っています。
AGENTS.md には「CloudKit の契約」だけを書く
AGENTS.md は長くすると読まれません。CloudKit の場合、書くべきものは契約に絞れます。
### CloudKit 契約
- コンテナ: `iCloud.net.example.tasks` (変更禁止)
- Database: Private のみ。Public / Shared は現時点で使用しない
- Record Type: `Task` (フィールド定義は `CloudKitSchema.swift` が正本)
- フィールド名は文字列リテラルで書かない。 `TaskSchema.Field` を経由する
- 保存は `modifyRecords(saving:deleting:savePolicy:atomically:)` を使う
( `CKModifyRecordsOperation` の completion block 版は使わない)
- 競合解決は `modifiedAt` の後勝ち。楽観ロックは導入しない
- 新規フィールドは必ず Optional。既存レコードは値を持たない前提で読む
肝は「使わないもの」を明示している点です。禁止事項を書かないと、エージェントは学習データに多く含まれる古い書き方へ自然に戻ります。CloudKit はクロージャ API の資料が長く蓄積されている領域なので、なおさらです。
旧 completion block 版を残さない
CloudKit のクロージャ API は iOS 15 で大きく置き換わりました。CKQueryOperation の recordFetchedBlock / queryCompletionBlock、CKModifyRecordsOperation の perRecordCompletionBlock / modifyRecordsCompletionBlock、CKFetchRecordZoneChangesOperation の recordChangedBlock は、いずれも非推奨です。
厄介なのは、非推奨のままコンパイルが通ることです。警告に紛れて残り続け、部分失敗のハンドリングだけが古い形のまま取り残されます。
着手前に一度だけ棚卸しをしておくと安全です。
grep -rn --include= '*.swift' \
-e 'recordFetchedBlock' -e 'queryCompletionBlock' \
-e 'perRecordCompletionBlock' -e 'modifyRecordsCompletionBlock' \
-e 'recordChangedBlock' \
Sources/
置き換え先は次のとおりです。
records(matching:) の matchResults は、レコードごとに Result を返します。全体が成功か失敗かではなく、どのレコードが落ちたかがそのまま手に入るため、次章の CKError.partialFailure の扱いを素直に書けます。旧 API の完了ハンドラで同じことをしようとすると、userInfo を掘り返す処理を自分で書くことになります。
同期の進行状況をロック画面へ出す、といった要件が乗ってくると設計は別方向へ広がります。その領域は Antigravity × SwiftUI Live Activities & Dynamic Island で扱っています。
トラブルシューティング
よくあるエラーと対処法
CKError.notAuthenticated — iCloud にサインインしていない
// アプリ起動時に iCloud ログイン状態を確認
let status = try await CKContainer. default (). accountStatus ()
if status != .available {
// ユーザーに iCloud サインインを促すアラートを表示
showICloudSignInAlert = true
}
CKError.quotaExceeded — ストレージ上限超過
ユーザーの iCloud ストレージが不足しています。画像や動画などの大容量アセットは CKAsset で管理し、テキストデータと分けて設計することで容量を最適化できます。また、使用済みデータの定期的なクリーンアップ機能を実装することを推奨します。
CKError.partialFailure — 一部レコードの保存失敗
error.userInfo[CKPartialErrorsByItemIDKey] でどのレコードが失敗したかを確認し、失敗したレコードのみ再試行する設計にしてください。全件再試行すると正常に保存済みのデータを上書きするリスクがあります。
プッシュ通知が届かない
Push Notifications ケイパビリティが追加されているか確認
Simulator ではサイレントプッシュが動作しないため、実機テストが必須
開発環境と本番環境で APN エンドポイントが異なることに注意
TestFlight ビルドは本番 APN を使用するため、リリース前に必ず TestFlight でのテストを実施
パフォーマンス考慮事項
ページングクエリで大量データを効率処理
CloudKit では一度のクエリで返るレコード数に上限があります(既定で 400 件)。続きを取るには、前回の停止位置を指す CKQueryOperation.Cursor を 次の呼び出しへ渡す 必要があります。ここが抜けていると、同じ先頭ページを取り直したまま cursor が nil にならず、ループが終わりません。二周目以降は records(matching:) ではなく records(continuingMatchFrom:) を呼びます。
// ページング対応クエリ(1 周目と 2 周目以降で呼ぶメソッドが違う)
func fetchTasksPaged () async throws -> [CKRecord] {
var allRecords: [CKRecord] = []
let query = CKQuery (
recordType : RecordType.task,
predicate : NSPredicate ( value : true )
)
// 1 周目: クエリそのものを渡す
let (firstPage, firstCursor) = try await privateDB. records (
matching : query,
resultsLimit : 200
)
allRecords += try firstPage. map { _ , result in try result. get () }
// 2 周目以降: 直前のカーソルを渡して続きから取る
var cursor = firstCursor
while let current = cursor {
let (page, nextCursor) = try await privateDB. records (
continuingMatchFrom : current,
resultsLimit : 200
)
allRecords += try page. map { _ , result in try result. get () }
cursor = nextCursor
}
return allRecords
}
// 期待する出力: allRecords に全タスクが入り、cursor が nil になってループが終わる
返ってくるタプルは (matchResults:, queryCursor:) で、matchResults はレコードごとの Result の配列です。try result.get() を map の中で呼んでいるのは、壊れたレコードが 1 件でもあればその場で気づきたいからです。握り潰して残りを返すほうが親切に見える場面もあります。ただ同期の文脈では、欠けたまま成功したことにするほうが後で高くつくのだと感じています。
デルタ同期で通信量を削減
CKFetchRecordZoneChangesOperation を使うと、前回同期以降に変更されたレコードのみを取得する「デルタ同期」が可能です。全件取得と比べて通信量・処理時間を大幅に削減できます。同期トークン(serverChangeToken)は UserDefaults または SwiftData に保存し、次回起動時に再利用します。
リトライの秒数は、サーバが教えてくれます
同期の実装で最後まで残った不具合は、実機二台を並べて続けざまに編集したときだけ保存が落ちる、というものでした。ログに出ていたのは CKError.requestRateLimited です。
最初のうち、私はここに素朴な指数バックオフ(1 秒、2 秒、4 秒……)を置いて済ませようとしました。結果は芳しくありませんでした。待ち時間が足りなければ弾かれ続け、余らせれば同期が体感で遅くなります。自分で秒数を決めに行っていたことが、そもそもの間違いでした。
CloudKit は待つべき秒数を返してくれます。CKError の retryAfterSeconds(Objective-C では userInfo の CKErrorRetryAfterKey)がそれで、requestRateLimited のほか zoneBusy・serviceUnavailable でも入ってきます。
待ち時間はサーバに決めてもらい、あきらめる回数だけをこちらで決めます。 同期に限らず、外部サービスを叩く場所ではこの線引きにしています。
// retryAfterSeconds を尊重するリトライ。上限回数だけこちらで持つ
func withCloudKitRetry < T >(
maxAttempts : Int = 3 ,
_ body: () async throws -> T
) async throws -> T {
var attempt = 0
while true {
do {
return try await body ()
} catch let error as CKError {
attempt += 1
// 秒数が返らないエラーは、待っても状況が変わらない
guard let wait = error.retryAfterSeconds, attempt < maxAttempts else {
throw error
}
try await Task. sleep ( for : . seconds (wait))
}
}
}
// 呼び出し側
let saved = try await withCloudKitRetry {
try await privateDB. save (record)
}
// 期待する出力: レート制限時はサーバ指定の秒数だけ待って再送し、
// 上限回数で成功しなければ CKError をそのまま上位へ投げる
retryAfterSeconds が nil のエラー——notAuthenticated や quotaExceeded——で待たないようにしているのは、待っても結果が変わらないためです。ここを「とりあえず全部リトライ」にすると、iCloud にサインインしていない端末で、理由を出さないまま数十秒固まるアプリになります。
保存処理をエージェントに書かせると、教科書どおりの指数バックオフを提案してくることがほとんどです。生成されたコードを読むとき、私はこの一点だけを先に探すようにしています。
本番デプロイで空クエリになる、二つの落とし穴
ローカルの Simulator では完璧に動くのに、TestFlight に載せた瞬間クエリが空を返す——CloudKit で最も多くの時間を溶かしたのが、この現象でした。原因はほぼ二つに絞られます。どちらも Antigravity のエージェントに任せきりにすると見落としやすい、ダッシュボード側の設定です。
落とし穴 1:queryable インデックスの付け忘れ
CloudKit は、スキーマ上のフィールドに「Queryable」「Sortable」「Searchable」のインデックス属性を明示しない限り、そのフィールドで検索・並び替えができません。コードでは書けてしまうため、本番でだけ次のエラーが返ります。
// 本番でだけ発生しやすいエラー
// CKError.invalidArguments
// "Field 'createdAt' is not marked queryable"
//
// 対処: CloudKit Dashboard → Schema → Indexes で
// createdAt を Sortable / Queryable に設定してから
// Development スキーマを Production へ Deploy する
query.sortDescriptors = [ NSSortDescriptor ( key : "createdAt" , ascending : false )]
recordName(システムフィールド)で並び替える場合も同様に、Queryable 指定が必要です。開発中は自動でインデックスが付くように見えることがありますが、本番環境には引き継がれません。ここは手作業で確認する前提にしておくと安全です。
落とし穴 2:Development スキーマを Production へ昇格し忘れる
CloudKit のスキーマには Development と Production の二つの環境があります。コードから新しい Record Type やフィールドを保存すると、Development 側には自動でスキーマが追加されます。しかし Production へは自動で反映されません 。TestFlight・App Store のビルドは Production スキーマを参照するため、昇格を忘れると「Simulator では動くが配布ビルドでは空」という典型的な症状になります。
運用としては、スキーマを変更したらリリース前に必ず CloudKit Dashboard で「Deploy Schema Changes」を実行する、という手順をチェックリスト化しておきます。私は AGENTS.md にこの一文を書き込み、エージェントがマイグレーションを生成するたびにリマインドされるようにしています。スキーマは一度 Production へ入れたフィールドを削除できない(型変更も不可)ため、フィールド追加は慎重に、後方互換を保てる設計を心がけます。
フィールド追加時の後方互換
すでに配布済みのアプリがある状態でフィールドを足すときは、CKRecord からの読み出しを常に「なければデフォルト値」で受ける設計にしておきます。前掲の TaskItem(record:) が ?? 0 で受けているのは、この後方互換のためです。旧バージョンが保存したレコードには新フィールドが存在しないため、nil 前提で書くと本番でクラッシュします。
まとめ
CloudKit の難しさは、コードよりもダッシュボードと環境差分に潜んでいます。逆に言えば、queryable インデックス、スキーマ昇格、そしてカーソルの受け渡し——この三点を手順として固定してしまえば、エージェントに任せられる範囲は目に見えて広がります。
いま振り返ると、私がつまずいた場所はどれも「コードが間違っていた」のではなく、「コードの外側にある状態を見ていなかった」場所でした。同期という機能の性質上、これからも同じ種類の見落としをするのかもしれません。だからこそ、気づくたびにチェックリストへ一行だけ足すようにしています。
次の一歩として、まずは Private Database だけの最小構成で @Observable データストアを組み、実機二台でタスクが往復するところまでを確認してみてください。同期が目の前で動く瞬間の手応えは、設定の複雑さを越えていく力になります。
同じ場所で手が止まっていた方の遠回りが、ひと区間でも短くなれば幸いです。