複数デバイスで同じデータを、と言われて詰まった夜
手元の 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 に入力できる形式で出力してください。」
エージェントが生成するスキーマ例:
Record Type Field Type
Task title String
Task isCompleted Int(64)
Task dueDate Date/Time
Task priority Int(64)
Task tagReference Reference
Tag name String
Tag color String
Comment body String
Comment taskReference Reference
Comment createdAt Date/Time
データベースゾーンの選択
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 が向いているのは、次の条件が揃うときです。
観点 CloudKit が有利 別の選択肢を検討
ユーザー Apple プラットフォームに閉じる Android/Web も同時に出す
データの所有 ユーザー個人のデータ(Private DB) サーバ側で集計・分析したい
ランニングコスト ユーザーの iCloud 容量を使うため実質無料 自前で従量課金を許容できる
認証 Apple ID をそのまま使いたい 独自アカウント基盤が必要
運用体制 バックエンドを持ちたくない(個人開発) 専任でサーバを保守できる
とくに個人開発では「ユーザーの iCloud 容量を使うため、開発者側にストレージ課金が発生しない」という一点が大きく効きます。Firebase や自前 API では、ユーザーが増えるほど費用が積み上がります。一方で、サーバ側でデータを横断的に見たい(ランキング、レコメンド、管理画面)要件が一つでもあると、Private Database だけでは詰みます。ここは妥協せず、要件表に「サーバがデータを読む必要があるか」を先に書き出しておくことをおすすめします。
私自身は、まず Private Database だけで完結する MVP を出し、サーバ側の集計が本当に必要になった段階で一部だけ自前 API に寄せる、という順序を好みます。最初から両方を抱えると、認証と同期の二重管理でエージェントへの指示も複雑になり、開発速度が落ちるためです。
Antigravity での開発効率化ポイント
Manager Surface でのマルチエージェント並列活用
Antigravity の Manager Surface を使うと、複数のエージェントを並列実行できます。CloudKit 統合では次のような分業が効果的です:
エージェント A :CloudKit スキーマ設計と CKRecord モデル生成
エージェント B :SwiftUI ビュー層の実装(TaskListView, AddTaskView 等)
エージェント C :単体テスト(XCTest)の自動生成と CloudKit モックの作成
この並列アプローチにより、従来2〜3日かかっていた CloudKit 統合作業を数時間に短縮できます。
AGENTS.md でコンテキストを共有する
プロジェクトルートの AGENTS.md に CloudKit のコンテナ識別子、スキーマ設計、アーキテクチャの決定事項を記述することで、すべてのエージェントが同一コンテキストで作業します。Antigravity SwiftUI スキルガイド に AGENTS.md のテンプレートが掲載されています。
iOS 26 新 API への対応
iOS 26・Xcode 26 移行ガイド と組み合わせることで、iOS 26 で追加・変更された CloudKit 関連 API へのスムーズな移行が可能です。Antigravity エージェントは最新の iOS SDK 情報を保持しており、旧 API の使用を検出すると自動的に最新版への書き換えを提案します。
トラブルシューティング
よくあるエラーと対処法
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 件)。大量データを扱う場合は cursor を使ったページングが必要です:
// ページング対応クエリ(大量データ対応)
func fetchTasksPaged () async throws -> [CKRecord] {
var allRecords: [CKRecord] = []
let query = CKQuery (
recordType : RecordType.task,
predicate : NSPredicate ( value : true )
)
var cursor: CKQueryOperation.Cursor ? = nil
repeat {
let (results, newCursor) = try await privateDB. records (
matching : query,
resultsLimit : 200 ,
// cursor が nil の場合は最初から取得
// cursor がある場合は続きから取得(CloudKit API v2)
)
let batch = try results.matchResults. map { _ , r in try r. get () }
allRecords. append ( contentsOf : batch)
cursor = newCursor
} while cursor != nil
return allRecords
}
// 期待する出力: allRecords に全タスクが格納される(件数制限なし)
デルタ同期で通信量を削減
CKFetchRecordZoneChangesOperation を使うと、前回同期以降に変更されたレコードのみを取得する「デルタ同期」が可能です。全件取得と比べて通信量・処理時間を大幅に削減できます。同期トークン(serverChangeToken)は UserDefaults または SwiftData に保存し、次回起動時に再利用します。
本番デプロイで空クエリになる、二つの落とし穴
ローカルの 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 では動くが配布ビルドでは空」という典型的な症状になります。
環境 スキーマ更新 参照するビルド
Development コード実行で自動追加 Xcode から実機/Simulator 実行
Production Dashboard で手動 Deploy が必須 TestFlight・App Store 配布
運用としては、スキーマを変更したらリリース前に必ず CloudKit Dashboard で「Deploy Schema Changes」を実行する、という手順をチェックリスト化しておきます。私は AGENTS.md にこの一文を書き込み、エージェントがマイグレーションを生成するたびにリマインドされるようにしています。スキーマは一度 Production へ入れたフィールドを削除できない(型変更も不可)ため、フィールド追加は慎重に、後方互換を保てる設計を心がけます。
フィールド追加時の後方互換
すでに配布済みのアプリがある状態でフィールドを足すときは、CKRecord からの読み出しを常に「なければデフォルト値」で受ける設計にしておきます。前掲の TaskItem(record:) が ?? 0 で受けているのは、この後方互換のためです。旧バージョンが保存したレコードには新フィールドが存在しないため、nil 前提で書くと本番でクラッシュします。
まとめ
ここでは Antigravity を使った SwiftUI + CloudKit の本番実装について解説しました。
@Observable + CKRecord による型安全でリアクティブなデータストア設計
CKSubscription サイレントプッシュ によるデバイス間リアルタイム同期
コンフリクト解決 とオフライン対応 のロジック実装
Manager Surface でのマルチエージェント並列開発による開発時間の大幅短縮
CloudKit の難しさは、コードよりもダッシュボードと環境差分に潜んでいます。逆に言えば、queryable インデックスとスキーマ昇格の二点さえ手順化してしまえば、あとはエージェントに任せられる範囲が大きく広がります。
次の一歩として、まずは Private Database だけの最小構成で @Observable データストアを組み、実機二台でタスクが往復するところまでを確認してみてください。同期が目の前で動く瞬間の手応えは、設定の複雑さを越えていく力になります。お読みいただきありがとうございました。