cloudkit-sync

安装量: 238
排名: #3668

安装

npx skills add https://github.com/dpearson2699/swift-ios-skills --skill cloudkit-sync
CloudKit and iCloud Sync
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.
Contents
Container and Database Setup
CKRecord CRUD
CKQuery
CKSubscription
CKSyncEngine (iOS 17+)
SwiftData + CloudKit
NSUbiquitousKeyValueStore
iCloud Drive File Sync
Account Status and Error Handling
Conflict Resolution
Common Mistakes
Review Checklist
References
Container and Database Setup
Enable iCloud + CloudKit in Signing & Capabilities. A container provides
three databases:
Database
Scope
Requires iCloud
Storage Quota
Public
All users
Read: No, Write: Yes
App quota
Private
Current user
Yes
User quota
Shared
Shared records
Yes
Owner quota
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
Records are key-value pairs. Max 1 MB per record (excluding CKAsset data).
// 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
)
Custom Record Zones (Private/Shared Only)
Custom zones support atomic commits, change tracking, and sharing.
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
Query records with NSPredicate. Supported:
==
,
!=
,
<
,
>
,
<=
,
>=
,
BEGINSWITH
,
CONTAINS
,
IN
,
AND
,
NOT
,
BETWEEN
,
distanceToLocation:fromLocation:
.
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
Subscriptions trigger push notifications when records change server-side.
CloudKit auto-enables APNs -- no explicit push entitlement needed.
// 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:
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
is the recommended sync approach. It handles scheduling,
transient error retries, change tokens, and push notifications automatically.
Works with private and shared databases only.
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.
SwiftData + CloudKit
ModelConfiguration
supports CloudKit sync. CloudKit models must use optional
properties and avoid unique constraints.
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. NSUbiquitousKeyValueStore Simple key-value sync. Max 1024 keys, 1 MB total, 1 MB per value. Stores locally when iCloud is unavailable. 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 File Sync Use FileManager ubiquity APIs for document-level sync. 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 ( ) Account Status and Error Handling Always check account status before sync. Listen for .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 Handling Error Code Strategy .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 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. 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 ) } } Common Mistakes DON'T: Perform sync operations without checking account status. DO: Check CKContainer.accountStatus() first; handle .noAccount . // 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. // 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. 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+) References See references/cloudkit-patterns.md for CKFetchRecordZoneChangesOperation incremental sync, CKShare collaboration, record zone management, CKAsset file storage, batch operations, and CloudKit Dashboard usage. CloudKit Framework CKContainer CKRecord CKQuery CKSubscription CKSyncEngine CKShare CKError NSUbiquitousKeyValueStore CKAsset

返回排行榜