cloudkit-sync

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

CloudKit and iCloud Sync

CloudKit与iCloud同步

Sync data across devices using CloudKit, iCloud key-value storage, and iCloud Drive. Covers container setup, record CRUD, queries, subscriptions, CKSyncEngine, SwiftData integration, conflict resolution, and error handling. Targets iOS 26+ with Swift 6.2; older availability noted where relevant.
借助CloudKit、iCloud键值存储和iCloud Drive实现跨设备数据同步。内容涵盖容器配置、记录CRUD、查询、订阅、CKSyncEngine、SwiftData集成、冲突解决和错误处理。目标平台为iOS 26+,使用Swift 6.2;相关旧版本兼容信息会在对应位置标注。

Contents

目录

Container and Database Setup

容器与数据库配置

Enable iCloud + CloudKit in Signing & Capabilities. A container provides three databases:
DatabaseScopeRequires iCloudStorage Quota
PublicAll usersRead: No, Write: YesApp quota
PrivateCurrent userYesUser quota
SharedShared recordsYesOwner quota
swift
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
在Signing & Capabilities中启用iCloud + CloudKit功能。一个容器包含三个数据库:
数据库作用范围是否需要iCloud存储配额
Public(公共)所有用户读取:无需,写入:需要应用配额
Private(私有)当前用户需要用户配额
Shared(共享)共享记录需要所有者配额
swift
import CloudKit

let container = CKContainer.default()
// 或指定容器:CKContainer(identifier: "iCloud.com.example.app")

let publicDB  = container.publicCloudDatabase
let privateDB = container.privateCloudDatabase
let sharedDB  = container.sharedCloudDatabase

CKRecord CRUD

CKRecord增删改查

Records are key-value pairs. Max 1 MB per record (excluding CKAsset data).
swift
// 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)
记录为键值对结构。单条记录最大1MB(不包含CKAsset数据)。
swift
// 创建记录
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)

// 通过ID获取记录
let recordID = CKRecord.ID(recordName: "unique-id-123")
let fetched = try await privateDB.record(for: recordID)

// 更新记录 -- 先获取,修改后保存
fetched["title"] = "Updated Title" as CKRecordValue
let updated = try await privateDB.save(fetched)

// 删除记录
try await privateDB.deleteRecord(withID: recordID)

Custom Record Zones (Private/Shared Only)

自定义记录区(仅私有/共享数据库可用)

Custom zones support atomic commits, change tracking, and sharing.
swift
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)
自定义记录区支持原子提交、变更追踪和共享功能。
swift
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

CKQuery查询

Query records with NSPredicate. Supported:
==
,
!=
,
<
,
>
,
<=
,
>=
,
BEGINSWITH
,
CONTAINS
,
IN
,
AND
,
NOT
,
BETWEEN
,
distanceToLocation:fromLocation:
.
swift
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")
])
使用NSPredicate查询记录。支持的操作符:
==
,
!=
,
<
,
>
,
<=
,
>=
,
BEGINSWITH
,
CONTAINS
,
IN
,
AND
,
NOT
,
BETWEEN
,
distanceToLocation:fromLocation:
swift
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 ?? "")
}

// 获取某类型的所有记录
let allQuery = CKQuery(recordType: "Note", predicate: NSPredicate(value: true))

// 对字符串字段进行全文搜索
let searchQuery = CKQuery(
    recordType: "Note",
    predicate: NSPredicate(format: "self CONTAINS %@", "roadmap")
)

// 复合谓词
let compound = NSCompoundPredicate(andPredicateWithSubpredicates: [
    NSPredicate(format: "createdAt > %@", cutoffDate as NSDate),
    NSPredicate(format: "tags CONTAINS %@", "work")
])

CKSubscription

CKSubscription订阅

Subscriptions trigger push notifications when records change server-side. CloudKit auto-enables APNs -- no explicit push entitlement needed.
swift
// 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)
Handle in AppDelegate:
swift
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
}
当服务器端记录发生变更时,订阅会触发推送通知。CloudKit会自动启用APNs -- 无需显式配置推送权限。
swift
// 查询订阅 -- 匹配的记录变更时触发
let subscription = CKQuerySubscription(
    recordType: "Note",
    predicate: NSPredicate(format: "tags CONTAINS %@", "urgent"),
    subscriptionID: "urgent-notes",
    options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notifInfo = CKSubscription.NotificationInfo()
notifInfo.shouldSendContentAvailable = true  // 静默推送
subscription.notificationInfo = notifInfo
try await privateDB.save(subscription)

// 数据库订阅 -- 数据库发生任何变更时触发
let dbSub = CKDatabaseSubscription(subscriptionID: "private-db-changes")
dbSub.notificationInfo = notifInfo
try await privateDB.save(dbSub)

// 记录区订阅 -- 指定记录区内发生变更时触发
let zoneSub = CKRecordZoneSubscription(
    zoneID: CKRecordZone.ID(zoneName: "NotesZone"),
    subscriptionID: "notes-zone-changes"
)
zoneSub.notificationInfo = notifInfo
try await privateDB.save(zoneSub)
在AppDelegate中处理通知:
swift
func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
    let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)
    guard notification?.subscriptionID == "private-db-changes" else { return .noData }
    // 使用CKSyncEngine或CKFetchRecordZoneChangesOperation获取变更
    return .newData
}

CKSyncEngine (iOS 17+)

CKSyncEngine(iOS 17+)

CKSyncEngine
is the recommended sync approach. It handles scheduling, transient error retries, change tokens, and push notifications automatically. Works with private and shared databases only.
swift
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) {
        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
    ) -> CKSyncEngine.RecordZoneChangeBatch? {
        let pending = syncEngine.state.pendingRecordZoneChanges
        return CKSyncEngine.RecordZoneChangeBatch(
            pendingChanges: Array(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()
Key point: persist
stateSerialization
across launches; the engine needs it to resume from the correct change token.
CKSyncEngine
是推荐的同步方案。它会自动处理调度、临时错误重试、变更令牌和推送通知。仅适用于私有和共享数据库。
swift
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) {
        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
    ) -> CKSyncEngine.RecordZoneChangeBatch? {
        let pending = syncEngine.state.pendingRecordZoneChanges
        return CKSyncEngine.RecordZoneChangeBatch(
            pendingChanges: Array(pending)
        ) { recordID in self.recordToSend(for: recordID) }
    }
}

// 调度变更
let zoneID = CKRecordZone.ID(zoneName: "NotesZone")
let recordID = CKRecord.ID(recordName: noteID, zoneID: zoneID)
syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])

// 触发立即同步(下拉刷新)
try await syncEngine.fetchChanges()
try await syncEngine.sendChanges()
关键点:跨启动会话持久化
stateSerialization
;引擎需要它从正确的变更令牌位置恢复同步。

SwiftData + CloudKit

SwiftData + CloudKit

ModelConfiguration
supports CloudKit sync. CloudKit models must use optional properties and avoid unique constraints.
swift
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)
CloudKit model rules: use optionals for all non-String properties; avoid
#Unique
; keep models flat; use
@Attribute(.externalStorage)
for large data; avoid complex relationship graphs.
ModelConfiguration
支持CloudKit同步。CloudKit模型必须使用可选属性,且避免唯一约束。
swift
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)
CloudKit模型规则:所有非String属性使用可选类型;避免使用
#Unique
;保持模型扁平化;对大数据使用
@Attribute(.externalStorage)
;避免复杂的关系图。

NSUbiquitousKeyValueStore

NSUbiquitousKeyValueStore键值存储

Simple key-value sync. Max 1024 keys, 1 MB total, 1 MB per value. Stores locally when iCloud is unavailable.
swift
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
    }
}
轻量级键值同步方案。最多支持1024个键,总存储1MB,单值最大1MB。当iCloud不可用时,数据会存储在本地。
swift
let kvStore = NSUbiquitousKeyValueStore.default

// 写入数据
kvStore.set("dark", forKey: "theme")
kvStore.set(14.0, forKey: "fontSize")
kvStore.set(true, forKey: "notificationsEnabled")
kvStore.synchronize()

// 读取数据
let theme = kvStore.string(forKey: "theme") ?? "system"

// 监听外部变更
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 File Sync

iCloud Drive文件同步

Use
FileManager
ubiquity APIs for document-level sync.
swift
guard let ubiquityURL = FileManager.default.url(
    forUbiquityContainerIdentifier: "iCloud.com.example.app"
) else { return }  // iCloud not available

let docsURL = ubiquityURL.appendingPathComponent("Documents")
let cloudURL = docsURL.appendingPathComponent("report.pdf")
try FileManager.default.setUbiquitous(true, itemAt: localURL, destinationURL: cloudURL)

// Monitor iCloud files
let query = NSMetadataQuery()
query.predicate = NSPredicate(format: "%K LIKE '*.pdf'", NSMetadataItemFSNameKey)
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
NotificationCenter.default.addObserver(
    forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main
) { _ in
    query.disableUpdates()
    for item in query.results as? [NSMetadataItem] ?? [] {
        let name = item.value(forAttribute: NSMetadataItemFSNameKey) as? String
        let status = item.value(
            forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
    }
    query.enableUpdates()
}
query.start()
使用
FileManager
的ubiquity API实现文档级同步。
swift
guard let ubiquityURL = FileManager.default.url(
    forUbiquityContainerIdentifier: "iCloud.com.example.app"
) else { return }  // iCloud不可用

let docsURL = ubiquityURL.appendingPathComponent("Documents")
let cloudURL = docsURL.appendingPathComponent("report.pdf")
try FileManager.default.setUbiquitous(true, itemAt: localURL, destinationURL: cloudURL)

// 监听iCloud文件
let query = NSMetadataQuery()
query.predicate = NSPredicate(format: "%K LIKE '*.pdf'", NSMetadataItemFSNameKey)
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
NotificationCenter.default.addObserver(
    forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main
) { _ in
    query.disableUpdates()
    for item in query.results as? [NSMetadataItem] ?? [] {
        let name = item.value(forAttribute: NSMetadataItemFSNameKey) as? String
        let status = item.value(
            forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
    }
    query.enableUpdates()
}
query.start()

Account Status and Error Handling

账户状态与错误处理

Always check account status before sync. Listen for
.CKAccountChanged
.
swift
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
    }
}
执行同步前务必检查账户状态。监听
.CKAccountChanged
通知。
swift
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 Handling

CKError错误处理

Error CodeStrategy
.networkFailure
,
.networkUnavailable
Queue for retry when network returns
.serverRecordChanged
Three-way merge (see Conflict Resolution)
.requestRateLimited
,
.zoneBusy
,
.serviceUnavailable
Retry after
retryAfterSeconds
.quotaExceeded
Notify user; reduce data usage
.notAuthenticated
Prompt iCloud sign-in
.partialFailure
Inspect
partialErrorsByItemID
per item
.changeTokenExpired
Reset token, refetch all changes
.userDeletedZone
Recreate zone and re-upload data
swift
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)
    }
}
错误码处理策略
.networkFailure
,
.networkUnavailable
网络恢复后重新排队执行
.serverRecordChanged
三方合并(见冲突解决章节)
.requestRateLimited
,
.zoneBusy
,
.serviceUnavailable
等待
retryAfterSeconds
后重试
.quotaExceeded
通知用户;减少数据占用
.notAuthenticated
提示用户登录iCloud
.partialFailure
通过
partialErrorsByItemID
逐个检查项的错误
.changeTokenExpired
重置令牌,重新获取所有变更
.userDeletedZone
重新创建记录区并重新上传数据
swift
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)
    }
}

Conflict Resolution

冲突解决

When saving a record that changed server-side, CloudKit returns
.serverRecordChanged
with three record versions. Always merge into
serverRecord
-- it has the correct change tag.
swift
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) }
}
当保存的记录在服务器端已发生变更时,CloudKit会返回
.serverRecordChanged
错误,并提供三个版本的记录。务必将变更合并到
serverRecord
中 -- 它包含正确的变更标记。
swift
func resolveConflict(_ error: CKError) {
    guard error.code == .serverRecordChanged,
          let ancestor = error.ancestorRecord,
          let client = error.clientRecord,
          let server = error.serverRecord
    else { return }

    // 将客户端变更合并到服务器记录
    for key in client.changedKeys() {
        if server[key] == ancestor[key] {
            server[key] = client[key]           // 服务器记录未变更,使用客户端值
        } else if client[key] == ancestor[key] {
            // 客户端记录未变更,保留服务器值(已存在)
        } else {
            server[key] = mergeValues(          // 双方均变更,执行自定义合并
                ancestor: ancestor[key], client: client[key], server: server[key])
        }
    }

    Task { try await CKContainer.default().privateCloudDatabase.save(server) }
}

Common Mistakes

常见误区

DON'T: Perform sync operations without checking account status. DO: Check
CKContainer.accountStatus()
first; handle
.noAccount
.
swift
// WRONG
try await privateDB.save(record)
// CORRECT
guard try await CKContainer.default().accountStatus() == .available
else { throw SyncError.noiCloudAccount }
try await privateDB.save(record)
DON'T: Ignore
.serverRecordChanged
errors. DO: Implement three-way merge using ancestor, client, and server records.
DON'T: Store user-specific data in the public database. DO: Use private database for personal data; public only for app-wide content.
DON'T: Assume data is available immediately after save. DO: Update local cache optimistically and reconcile on fetch.
DON'T: Poll for changes on a timer. DO: Use
CKDatabaseSubscription
or
CKSyncEngine
for push-based sync.
swift
// WRONG
Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in fetchAll() }
// CORRECT
let sub = CKDatabaseSubscription(subscriptionID: "db-changes")
sub.notificationInfo = CKSubscription.NotificationInfo()
sub.notificationInfo?.shouldSendContentAvailable = true
try await privateDB.save(sub)
DON'T: Retry immediately on rate limiting. DO: Use
CKError.retryAfterSeconds
to wait the required duration.
DON'T: Merge conflict changes into
clientRecord
. DO: Always merge into
serverRecord
-- it has the correct change tag.
DON'T: Pass nil change token on every fetch. DO: Persist change tokens to disk and supply them on subsequent fetches.
错误做法:未检查账户状态就执行同步操作。 正确做法:先检查
CKContainer.accountStatus()
;处理
.noAccount
情况。
swift
// 错误示例
try await privateDB.save(record)
// 正确示例
guard try await CKContainer.default().accountStatus() == .available
else { throw SyncError.noiCloudAccount }
try await privateDB.save(record)
错误做法:忽略
.serverRecordChanged
错误。 正确做法:使用祖先、客户端和服务器记录实现三方合并。
错误做法:在公共数据库中存储用户特定数据。 正确做法:使用私有数据库存储个人数据;仅将公共数据库用于应用级共享内容。
错误做法:假设保存后数据会立即可用。 正确做法:先乐观更新本地缓存,再在获取时进行协调。
错误做法:通过定时器轮询变更。 正确做法:使用
CKDatabaseSubscription
CKSyncEngine
实现基于推送的同步。
swift
// 错误示例
Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in fetchAll() }
// 正确示例
let sub = CKDatabaseSubscription(subscriptionID: "db-changes")
sub.notificationInfo = CKSubscription.NotificationInfo()
sub.notificationInfo?.shouldSendContentAvailable = true
try await privateDB.save(sub)
错误做法:遇到速率限制时立即重试。 正确做法:使用
CKError.retryAfterSeconds
等待指定时长后重试。
错误做法:将冲突变更合并到
clientRecord
中。 正确做法:务必合并到
serverRecord
中 -- 它包含正确的变更标记。
错误做法:每次获取时都传递nil变更令牌。 正确做法:将变更令牌持久化到磁盘,并在后续获取时提供。

Review Checklist

评审检查清单

  • iCloud + CloudKit capability enabled in Signing & Capabilities
  • Account status checked before sync;
    .noAccount
    handled gracefully
  • Private database used for user data; public only for shared content
  • CKError.serverRecordChanged
    handled with three-way merge into
    serverRecord
  • Network failures queued for retry;
    retryAfterSeconds
    respected
  • CKDatabaseSubscription
    or
    CKSyncEngine
    used for push-based sync
  • Change tokens persisted to disk;
    changeTokenExpired
    resets and refetches
  • .partialFailure
    errors inspected per-item via
    partialErrorsByItemID
  • .userDeletedZone
    handled by recreating zone and resyncing
  • SwiftData CloudKit models use optionals, no
    #Unique
    ,
    .externalStorage
    for large data
  • NSUbiquitousKeyValueStore.didChangeExternallyNotification
    observed
  • Sensitive data uses
    encryptedValues
    on CKRecord (not plain fields)
  • CKSyncEngine
    state serialization persisted across launches (iOS 17+)
  • 在Signing & Capabilities中启用了iCloud + CloudKit功能
  • 同步前检查账户状态;
    .noAccount
    情况已妥善处理
  • 用户数据使用私有数据库;公共数据库仅用于共享内容
  • CKError.serverRecordChanged
    错误已通过三方合并到
    serverRecord
    处理
  • 网络故障已排队等待重试;
    retryAfterSeconds
    已被遵守
  • 使用
    CKDatabaseSubscription
    CKSyncEngine
    实现基于推送的同步
  • 变更令牌已持久化到磁盘;
    changeTokenExpired
    时会重置并重新获取
  • .partialFailure
    错误已通过
    partialErrorsByItemID
    逐个检查
  • .userDeletedZone
    错误已通过重新创建记录区并重新同步处理
  • SwiftData CloudKit模型使用可选属性,无
    #Unique
    ,大数据使用
    .externalStorage
  • 已监听
    NSUbiquitousKeyValueStore.didChangeExternallyNotification
    通知
  • 敏感数据使用CKRecord的
    encryptedValues
    (而非普通字段)
  • CKSyncEngine
    的stateSerialization已跨启动会话持久化(iOS 17+)

References

参考资料