mcpField
C
cloudkit

cloudkit

github.com/dpearson2699データ・データベース1.2k3.3k 回インストール13 週間前に更新
リポジトリ

cloudkit について

CloudKit、iCloud キーバリューストレージ、iCloud Drive を使って、アプリデータをデバイス間で同期する開発者向けスキルです。レコード操作、プッシュ駆動の同期、SwiftData 連携、競合解決、エラーからの復旧を扱います。

主な機能

  1. 01CloudKit コンテナとデータベースの設定
  2. 02レコード操作、クエリ、サブスクリプション
  3. 03CKSyncEngine の状態と変更トークン管理
  4. 04SwiftData と CloudKit の連携
  5. 05iCloud キーバリューと Drive のファイル同期
  6. 06エラー復旧と競合マージ

活用シーン

  1. 01ユーザーの複数デバイス間でアプリのレコードを同期
  2. 02CloudKit の変更通知からデータを更新
  3. 03同じレコードへの同時編集を解決
  4. 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 との統合、競合解決、エラー処理を扱います。

目次

ワークフロー

  1. データベースのスコープと同期の所有者を選び、レコードを書き込む前に、機能、コンテナ、アカウントの状態、スキーマ、環境を確認します。
  2. ローカルの変更を永続化してキューに追加し、ポーリングするのではなく、サブスクリプションまたは CKSyncEngine にリモート処理を任せます。
  3. 変更が正常に適用された後、変更トークンまたは同期エンジンの状態を永続化します。
  4. オフラインでの編集、一部の処理の失敗、レート制限、トークンの有効期限切れ、競合、アカウントの喪失、ゾーンの削除、再起動をテストします。
  5. 失敗した場合は、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ネットワークが復旧したら再試行するようキューに追加する
.serverRecordChanged3 者マージを行う(「競合解決」を参照)
.requestRateLimited、.zoneBusy、.serviceUnavailableretryAfterSeconds の経過後に再試行する
.quotaExceededユーザーに通知し、データ使用量を減らす
.notAuthenticatediCloud へのサインインを促す
.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 以降)

参考資料

コメント