cloudkit について
CloudKit、iCloud キーバリューストレージ、iCloud Drive を使って、アプリデータをデバイス間で同期する開発者向けスキルです。レコード操作、プッシュ駆動の同期、SwiftData 連携、競合解決、エラーからの復旧を扱います。
主な機能
- 01CloudKit コンテナとデータベースの設定
- 02レコード操作、クエリ、サブスクリプション
- 03CKSyncEngine の状態と変更トークン管理
- 04SwiftData と CloudKit の連携
- 05iCloud キーバリューと Drive のファイル同期
- 06エラー復旧と競合マージ
活用シーン
- 01ユーザーの複数デバイス間でアプリのレコードを同期
- 02CloudKit の変更通知からデータを更新
- 03同じレコードへの同時編集を解決
- 04同期障害、アカウント喪失、トークン期限切れを調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill cloudkitインストール後、Claude Code で/cloudkitを入力して読み込まれたか確認します。
同梱ファイルevals/evals.jsonreferences/cloudkit-patterns.md
詳細
CloudKit
CloudKit、iCloud キーバリューストレージ、iCloud Drive を使用してデバイス間でデータを同期します。コンテナの設定、レコードの CRUD、クエリ、サブスクリプション、CKSyncEngine、 SwiftData との統合、競合解決、エラー処理を扱います。
目次
- コンテナとデータベースの設定
- ワークフロー
- CKRecord の CRUD
- CKQuery
- CKSubscription
- CKSyncEngine(iOS 17+)
- SwiftData + CloudKit
- NSUbiquitousKeyValueStore
- iCloud Drive のファイル同期
- アカウントの状態とエラー処理
- 競合解決
- よくある間違い
- レビュー用チェックリスト
- 参考資料
ワークフロー
- データベースのスコープと同期の所有者を選び、レコードを書き込む前に、機能、コンテナ、アカウントの状態、スキーマ、環境を確認します。
- ローカルの変更を永続化してキューに追加し、ポーリングするのではなく、サブスクリプションまたは
CKSyncEngineにリモート処理を任せます。 - 変更が正常に適用された後、変更トークンまたは同期エンジンの状態を永続化します。
- オフラインでの編集、一部の処理の失敗、レート制限、トークンの有効期限切れ、競合、アカウントの喪失、ゾーンの削除、再起動をテストします。
- 失敗した場合は、
CKErrorを分類し、影響を受けたフィクスチャまたはキュー項目を復元して、文書化された再試行/リセット/マージの処理を適用し、同じシナリオを再実行します。一部成功した後に、何も考えずに完全同期を再開してはいけません。
ゾーンの差分変更、共有、アセット、バッチ処理、Dashboard の手順については、references/cloudkit-patterns.md を読み込んでください。
コンテナとデータベースの設定
Signing & Capabilities で iCloud + CloudKit を有効にします。コンテナには次の 3 つのデータベースがあります。
| データベース | スコープ | iCloud が必要 | ストレージのクォータ |
|---|---|---|---|
| Public | すべてのユーザー | 読み取り:不要、書き込み:必要 | アプリのクォータ |
| Private | 現在のユーザー | 必要 | ユーザーのクォータ |
| Shared | 共有されたレコード | 必要 | 所有者のクォータ |
import CloudKit
let container = CKContainer.default()
// Or named: CKContainer(identifier: "iCloud.com.example.app")
let publicDB = container.publicCloudDatabase
let privateDB = container.privateCloudDatabase
let sharedDB = container.sharedCloudDatabase
CKRecord の CRUD
レコードはキーと値のペアです。レコードあたりの最大サイズは 1 MB です(CKAsset データを除く)。
// CREATE
let record = CKRecord(recordType: "Note")
record["title"] = "Meeting Notes" as CKRecordValue
record["body"] = "Discussed Q3 roadmap" as CKRecordValue
record["createdAt"] = Date() as CKRecordValue
record["tags"] = ["work", "planning"] as CKRecordValue
let saved = try await privateDB.save(record)
// FETCH by ID
let recordID = CKRecord.ID(recordName: "unique-id-123")
let fetched = try await privateDB.record(for: recordID)
// UPDATE -- fetch first, modify, then save
fetched["title"] = "Updated Title" as CKRecordValue
let updated = try await privateDB.save(fetched)
// DELETE
try await privateDB.deleteRecord(withID: recordID)
カスタムレコードゾーン
アプリはプライベートデータベースにカスタムゾーンを作成します。共有データベースには、他のユーザーが現在のユーザーと共有しているゾーンが公開されます。カスタムゾーンはアトミックなコミット、変更追跡、共有をサポートしますが、パブリックデータベースはカスタムゾーンをサポートしません。
let zoneID = CKRecordZone.ID(zoneName: "NotesZone")
let zone = CKRecordZone(zoneID: zoneID)
try await privateDB.save(zone)
let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "Note", recordID: recordID)
CKQuery
NSPredicate を使ってレコードをクエリします。サポート対象:==、!=、<、>、<=、>=、
BEGINSWITH、CONTAINS、IN、AND、NOT、BETWEEN、
distanceToLocation:fromLocation:。
CONTAINS は、self CONTAINS によるトークン化された全文検索を除き、リスト内の要素を検査します。BEGINSWITH は文字列の先頭一致演算子です。サポートされていない演算子、キーパス、フィールド型は、クエリの実行時に失敗します。
暗号化をレビューする際は、毎回、フィールドの適格性を明示してください。暗号化された値はクエリやソートに使用できません。CKAsset はデフォルトで暗号化されます。また、CloudKit がサーバー側で必要とするため、CKRecord.Reference は暗号化できません。
let predicate = NSPredicate(format: "title BEGINSWITH %@", "Meeting")
let query = CKQuery(recordType: "Note", predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
let (results, _) = try await privateDB.records(matching: query)
for (_, result) in results {
let record = try result.get()
print(record["title"] as? String ?? "")
}
// Fetch all records of a type
let allQuery = CKQuery(recordType: "Note", predicate: NSPredicate(value: true))
// Full-text search across string fields
let searchQuery = CKQuery(
recordType: "Note",
predicate: NSPredicate(format: "self CONTAINS %@", "roadmap")
)
// Compound predicate
let compound = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "createdAt > %@", cutoffDate as NSDate),
NSPredicate(format: "tags CONTAINS %@", "work")
])
CKSubscription
サブスクリプションは、サーバー側でレコードが変更されたときにプッシュ通知をトリガーします。CloudKit が有効になっていれば、CloudKit/Xcode が APNs のエンタイトルメントを処理するため、App ID で個別にプッシュ通知を設定する必要はありません。サイレント通知/バックグラウンド処理には、引き続き Background Modes > Remote notifications が必要です。
// Query subscription -- fires when matching records change
let subscription = CKQuerySubscription(
recordType: "Note",
predicate: NSPredicate(format: "tags CONTAINS %@", "urgent"),
subscriptionID: "urgent-notes",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notifInfo = CKSubscription.NotificationInfo()
notifInfo.shouldSendContentAvailable = true // silent push
subscription.notificationInfo = notifInfo
try await privateDB.save(subscription)
// Database subscription -- fires on any database change
let dbSub = CKDatabaseSubscription(subscriptionID: "private-db-changes")
dbSub.notificationInfo = notifInfo
try await privateDB.save(dbSub)
// Record zone subscription -- fires on changes within a zone
let zoneSub = CKRecordZoneSubscription(
zoneID: CKRecordZone.ID(zoneName: "NotesZone"),
subscriptionID: "notes-zone-changes"
)
zoneSub.notificationInfo = notifInfo
try await privateDB.save(zoneSub)
AppDelegate で処理します:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)
guard notification?.subscriptionID == "private-db-changes" else { return .noData }
// Fetch changes using CKSyncEngine or CKFetchRecordZoneChangesOperation
return .newData
}
CKSyncEngine(iOS 17+)
CKSyncEngine は、カスタムのモデルデータに推奨される同期方法です。スケジュール設定、一時的な再試行、変更トークン、データベースのサブスクリプションを処理しますが、アプリ固有の保存エラーは処理しません。sentRecordZoneChanges.failedRecordSaves からの CKError.serverRecordChanged には、引き続きカスタムの競合解決と再スケジュールが必要です。自動同期のタイミングは予測できません。CloudKit 機能とリモート通知が必要です。プライベートデータベースと共有データベースのみが対象です。
import CloudKit
final class SyncManager: CKSyncEngineDelegate {
let syncEngine: CKSyncEngine
init(container: CKContainer = .default()) {
let config = CKSyncEngine.Configuration(
database: container.privateCloudDatabase,
stateSerialization: Self.loadState(),
delegate: self
)
self.syncEngine = CKSyncEngine(config)
}
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
switch event {
case .stateUpdate(let update):
Self.saveState(update.stateSerialization)
case .accountChange(let change):
handleAccountChange(change)
case .fetchedRecordZoneChanges(let changes):
for mod in changes.modifications { processRemoteRecord(mod.record) }
for del in changes.deletions { processRemoteDeletion(del.recordID) }
case .sentRecordZoneChanges(let sent):
for saved in sent.savedRecords { markSynced(saved) }
for fail in sent.failedRecordSaves { handleSaveFailure(fail) }
default: break
}
}
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
let pending = syncEngine.state.pendingRecordZoneChanges
.filter { context.options.zoneIDs.contains($0) }
return await CKSyncEngine.RecordZoneChangeBatch(
pendingChanges: pending
) { recordID in self.recordToSend(for: recordID) }
}
}
// Schedule changes
let zoneID = CKRecordZone.ID(zoneName: "NotesZone")
let recordID = CKRecord.ID(recordName: noteID, zoneID: zoneID)
syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
// Trigger immediate sync (pull-to-refresh)
try await syncEngine.fetchChanges()
try await syncEngine.sendChanges()
重要なポイント:起動をまたいで stateSerialization を永続化してください。エンジンは、正しい変更トークンから再開するためにこれを必要とします。
SwiftData + CloudKit
ModelConfiguration は CloudKit 同期に対応しています。SwiftData と CloudKit の実装やレビューでは、必ず次の 2 つの判定を報告してください。
- モデルの互換性:
#Uniqueや一意制約がないこと、リレーションシップがオプショナルであること、.denyを使用していないこと、大きなDataには外部ストレージを使うこと。 - スキーマのロールアウト:非本番ビルドで開発用スキーマを初期化し、CloudKit Dashboard で検証してからリリース前に昇格してください。本番環境への昇格後はスキーマの追加のみを行い、モデルタイプの削除や既存属性の変更は行わないでください。
import SwiftData
@Model
class Note {
var title: String
var body: String?
var createdAt: Date?
@Attribute(.externalStorage) var imageData: Data?
init(title: String, body: String? = nil) {
self.title = title
self.body = body
self.createdAt = Date()
}
}
let config = ModelConfiguration(
"Notes",
cloudKitDatabase: .private("iCloud.com.example.app")
)
let container = try ModelContainer(for: Note.self, configurations: config)
NSUbiquitousKeyValueStore
シンプルなキーと値の同期です。最大 1024 個のキー、合計 1 MB、値ごとに 1 MB まで保存できます。iCloud が利用できない場合はローカルに保存します。
let kvStore = NSUbiquitousKeyValueStore.default
// Write
kvStore.set("dark", forKey: "theme")
kvStore.set(14.0, forKey: "fontSize")
kvStore.set(true, forKey: "notificationsEnabled")
kvStore.synchronize()
// Read
let theme = kvStore.string(forKey: "theme") ?? "system"
// Observe external changes
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: kvStore, queue: .main
) { notification in
guard let userInfo = notification.userInfo,
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
let keys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
else { return }
switch reason {
case NSUbiquitousKeyValueStoreServerChange:
for key in keys { applyRemoteChange(key: key) }
case NSUbiquitousKeyValueStoreInitialSyncChange:
reloadAllSettings()
case NSUbiquitousKeyValueStoreQuotaViolationChange:
handleQuotaExceeded()
default: break
}
}
iCloud Drive のファイル同期
ドキュメント単位の同期には FileManager のユビキティ API を使用します。url(forUbiquityContainerIdentifier:) と setUbiquitous はメインスレッド以外で呼び出してください。setUbiquitous は協調ファイル処理を実行し、ブロックする場合があります。ファイルを表示中の場合は、移動する前にアクティブなファイルプレゼンターを設定してください。
Task.detached {
guard let ubiquityURL = FileManager.default.url(
forUbiquityContainerIdentifier: "iCloud.com.example.app"
) else { return } // iCloud not available
let docsURL = ubiquityURL.appendingPathComponent("Documents")
try FileManager.default.createDirectory(at: docsURL, withIntermediateDirectories: true)
let cloudURL = docsURL.appendingPathComponent("report.pdf")
try FileManager.default.setUbiquitous(true, itemAt: localURL, destinationURL: cloudURL)
}
NSMetadataQueryUbiquitousDocumentsScope または NSMetadataQueryUbiquitousDataScope をスコープとして指定した NSMetadataQuery でファイルを監視します。
アカウントの状態とエラー処理
同期の前に必ずアカウントの状態を確認してください。.CKAccountChanged を監視します。
func checkiCloudStatus() async throws -> CKAccountStatus {
let status = try await CKContainer.default().accountStatus()
switch status {
case .available: return status
case .noAccount: throw SyncError.noiCloudAccount
case .restricted: throw SyncError.restricted
case .temporarilyUnavailable: throw SyncError.temporarilyUnavailable
case .couldNotDetermine: throw SyncError.unknown
@unknown default: throw SyncError.unknown
}
}
CKError の処理
| エラーコード | 対処方法 |
|---|---|
.networkFailure、.networkUnavailable | ネットワークが復旧したら再試行するようキューに追加する |
.serverRecordChanged | 3 者マージを行う(「競合解決」を参照) |
.requestRateLimited、.zoneBusy、.serviceUnavailable | retryAfterSeconds の経過後に再試行する |
.quotaExceeded | ユーザーに通知し、データ使用量を減らす |
.notAuthenticated | iCloud へのサインインを促す |
.partialFailure | アイテムごとに partialErrorsByItemID を確認する |
.changeTokenExpired | トークンをリセットし、すべての変更を再取得する |
.userDeletedZone | ゾーンを再作成し、データを再アップロードする |
func handleCloudKitError(_ error: Error) {
guard let ckError = error as? CKError else { return }
switch ckError.code {
case .networkFailure, .networkUnavailable:
scheduleRetryWhenOnline()
case .serverRecordChanged:
resolveConflict(ckError)
case .requestRateLimited, .zoneBusy, .serviceUnavailable:
let delay = ckError.retryAfterSeconds ?? 3.0
scheduleRetry(after: delay)
case .quotaExceeded:
notifyUserStorageFull()
case .partialFailure:
if let partial = ckError.partialErrorsByItemID {
for (_, itemError) in partial { handleCloudKitError(itemError) }
}
case .changeTokenExpired:
resetChangeToken()
case .userDeletedZone:
recreateZoneAndResync()
default: logError(ckError)
}
}
競合解決
サーバー側で変更されたレコードを保存すると、CloudKit は 3 つのレコードバージョンを含む .serverRecordChanged を返します。必ず serverRecord にマージしてください。これには正しい変更タグが含まれています。
func resolveConflict(_ error: CKError) {
guard error.code == .serverRecordChanged,
let ancestor = error.ancestorRecord,
let client = error.clientRecord,
let server = error.serverRecord
else { return }
// Merge client changes into server record
for key in client.changedKeys() {
if server[key] == ancestor[key] {
server[key] = client[key] // Server unchanged, use client
} else if client[key] == ancestor[key] {
// Client unchanged, keep server (already there)
} else {
server[key] = mergeValues( // Both changed, custom merge
ancestor: ancestor[key], client: client[key], server: server[key])
}
}
Task { try await CKContainer.default().privateCloudDatabase.save(server) }
}
よくある間違い
| 間違い | 修正方法 |
|---|---|
| アカウントの確認なしに同期する | accountStatus() を確認し、モデルの .noAccount をユーザーに表示する状態として扱います。 |
| 公開データベースに個人データを保存する | ユーザーデータにはプライベートスコープを使用します。公開スコープはアプリ全体で共有するコンテンツ向けです。 |
| タイマーでポーリングする | データベースのサブスクリプションまたは CKSyncEngine を使用します。 |
| スロットリング直後に再試行する | retryAfterSeconds を尊重し、保留中の作業を維持します。 |
| エンジンが競合を解決すると想定する | failedRecordSaves を 3 者マージしてから、保存を再スケジュールします。 |
| すべてのフェッチを nil トークンで開始する | トークン/状態を永続化し、文書化された有効期限切れの場合にのみリセットします。 |
レビューチェックリスト
- 「Signing & Capabilities」で iCloud + CloudKit 機能が有効になっている
- 同期前にアカウントの状態を確認し、
.noAccountを適切に処理している - ユーザーデータにはプライベートデータベースを使用し、共有コンテンツにのみパブリックデータベースを使用している
- プライベート DB にカスタムレコードゾーンを作成し、共有 DB のゾーンは共有から検出している
-
CKError.serverRecordChangedを処理し、3ウェイマージの結果をserverRecordに反映している - ネットワーク障害を再試行用にキューに入れ、
retryAfterSecondsを遵守している - プッシュベースの同期に
CKDatabaseSubscriptionまたはCKSyncEngineを使用し、バックグラウンド配信にリモート通知を有効にしている - 変更トークンをディスクに保持し、
changeTokenExpiredが発生した場合はリセットして再取得している -
.partialFailureエラーをpartialErrorsByItemIDでアイテムごとに確認している -
.userDeletedZoneを処理し、ゾーンを再作成して再同期している - SwiftData の CloudKit レビューでモデルの互換性とスキーマの展開を報告している。開発用スキーマを初期化・検証し、リリース前に昇格させ、本番環境での変更は追加のみとしている
-
NSUbiquitousKeyValueStore.didChangeExternallyNotificationを監視している - 暗号化レビューで、CloudKit がサーバー側で必要とするため
CKRecord.ReferenceにはencryptedValuesを使用できないこと、暗号化フィールドではクエリやソートを行わないこと、CKAssetはデフォルトで暗号化されることを確認している -
CKSyncEngineの状態のシリアライズを起動間で保持している(iOS 17 以降)
参考資料
- 増分同期、CKShare、ゾーン、CKAsset の保存、バッチ操作、Dashboard の使用方法については、references/cloudkit-patterns.md を参照してください。
- CloudKit フレームワーク
- CKContainer
- CKRecord
- CKQuery
- CKSubscription
- CKSyncEngine
- CKShare
- CKError
- NSUbiquitousKeyValueStore
- SwiftData の CloudKit 同期