Skip to content

Implementing iCloud Sync by Combining SwiftData with CKSyncEngine

Introduction

Data synchronization is a common requirement in iOS app development. While SwiftData provides native CloudKit sync support, we can choose to combine it with CKSyncEngine for more flexible synchronization when fine-grained control over the sync process is needed. This article will detail how to combine SwiftData with CKSyncEngine.

How CKSyncEngine Works

Let's first understand CKSyncEngine's workflow through a sequence diagram:

Implementation Steps

1. Data Model Design

First, we need to design a SwiftData model that supports CloudKit synchronization:

swift
@Model
class YourModel {
    // Basic properties
    var id: UUID
    var title: String
    var date: Date
    
    // CloudKit sync related
    private var _lastKnownRecord: CKRecord?
    
    // Persist CKRecord using encoded data
    var lastKnownRecordData: Data? {
        get {
            guard let record = _lastKnownRecord else { return nil }
            return try? NSKeyedArchiver.archivedData(
                withRootObject: record,
                requiringSecureCoding: true
            )
        }
        set {
            guard let data = newValue else {
                _lastKnownRecord = nil
                return
            }
            _lastKnownRecord = try? NSKeyedUnarchiver.unarchivedObject(
                ofClass: CKRecord.self,
                from: data
            )
        }
    }
    
    // CloudKit record conversion methods
    func populateRecord(_ record: CKRecord) {
        if let lastRecord = lastKnownRecord {
            record.recordChangeTag = lastRecord.recordChangeTag
        }
        record["title"] = title
        record["date"] = date
    }
    
    func mergeFromServerRecord(_ record: CKRecord) {
        title = record["title"] as? String ?? title
        date = record["date"] as? Date ?? date
        lastKnownRecord = record
    }
}

2. Sync Manager Implementation

Create a dedicated sync manager to coordinate SwiftData and CKSyncEngine:

swift
@MainActor
final class SyncManager: ObservableObject {
    private let ckContainer: CKContainer
    private var syncEngine: CKSyncEngine?
    private let modelContainer: ModelContainer
    
    @Published private(set) var isSyncEnabled: Bool
    
    enum RecordZone: String {
        case yourModel = "YourModel"
        // RecordZone related to other models
    }
    
    init(modelContainer: ModelContainer) {
        self.modelContainer = modelContainer
        self.ckContainer = CKContainer(identifier: "iCloud.com.yourapp.identifier")
        self.isSyncEnabled = UserDefaults.standard.bool(forKey: "iCloudSyncEnabled")
        
        setupSyncEngine()
    }
    
    private func setupSyncEngine() {
        let configuration = CKSyncEngine.Configuration(
            database: ckContainer.privateCloudDatabase,
            stateSerialization: loadSyncEngineState(),
            delegate: self
        )
        syncEngine = CKSyncEngine(configuration)
    }
}

3. Handling Sync Events

Implement CKSyncEngineDelegate to handle various sync events:

swift
extension SyncManager: CKSyncEngineDelegate {
    func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
        switch event {
        case .stateUpdate(let update):
            saveSyncEngineState(update.stateSerialization)
            
        case .fetchedRecordZoneChanges(let changes):
            await handleFetchedRecordZoneChanges(changes)
            
        case .sentRecordZoneChanges(let results):
            await handleSentRecordZoneChanges(results)
            
        // ... Handle other events
        }
    }
    
    func nextRecordZoneChangeBatch(
        _ context: CKSyncEngine.SendChangesContext,
        syncEngine: CKSyncEngine
    ) async -> CKSyncEngine.RecordZoneChangeBatch? {
        let pendingChanges = syncEngine.state.pendingRecordZoneChanges
            .filter { context.options.scope.contains($0) }
        
        guard !pendingChanges.isEmpty else { return nil }
        
        return await CKSyncEngine.RecordZoneChangeBatch(
            pendingChanges: pendingChanges
        ) { recordID in
            // Build records to sync
            return await self.createRecord(for: recordID)
        }
    }
}

4. Handling Server Changes

When receiving server data, we need to update local SwiftData:

swift
private func handleFetchedRecordZoneChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) {
    let context = ModelContext(modelContainer)
    
    // Handle modified records
    for modification in changes.modifications {
        let record = modification.record
        let recordID = record.recordID
        
        do {
            let descriptor = FetchDescriptor<TrainingRecord>(
                predicate: #Predicate<TrainingRecord> { 
                    $0.id.uuidString == recordID.recordName 
                }
            )
            
            if var existingRecord = try context.fetch(descriptor).first {
                existingRecord.mergeFromServerRecord(record)
            } else {
                var newRecord = TrainingRecord()
                newRecord.id = UUID(uuidString: recordID.recordName) ?? UUID()
                newRecord.mergeFromServerRecord(record)
                context.insert(newRecord)
            }
        } catch {
            logger.error("Failed to process server data: \(error)")
        }
    }
    
    // Handle deleted records
    for deletion in changes.deletions {
        // ... Handle deletion logic
    }
    
    try? context.save()
}

5. Error Handling and Conflict Resolution

Implement robust error handling mechanisms:

swift
private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) {
    for failedSave in changes.failedRecordSaves {
        switch failedSave.error.code {
        case .serverRecordChanged:
            if let serverRecord = failedSave.error.serverRecord {
                resolveConflict(
                    local: failedSave.record,
                    server: serverRecord
                )
            }
            
        case .zoneNotFound:
            createZone(for: failedSave.record.recordID.zoneID)
            
        case .networkFailure, .networkUnavailable:
            // Network errors will auto-retry
            logger.debug("Network error, waiting for retry")
            
        default:
            logger.error("Unhandled error: \(failedSave.error)")
        }
    }
}

Key Points Explained

Importance of lastKnownRecord

The lastKnownRecord mechanism is key to reliable synchronization. Its purposes are:

  1. Maintain Record Versions:

    • Track record versions using recordChangeTag
    • Ensure proper handling of concurrent modifications
  2. Avoid Data Duplication:

    • Without saving lastKnownRecord, each sync would be treated as a new record
    • Leads to data duplication and sync errors
  3. Support Conflict Resolution:

    • Provides baseline version for conflict detection
    • Helps implement reliable conflict resolution strategies

Sync State Management

Good sync state management is crucial for user experience:

swift
@Published private(set) var syncStatus: SyncStatus = .idle
@Published private(set) var lastSyncError: Error?

enum SyncStatus {
    case idle
    case syncing
    case error
}

private func updateSyncStatus(_ status: SyncStatus) {
    DispatchQueue.main.async {
        self.syncStatus = status
    }
}

Best Practice Recommendations

  1. Incremental Sync

    • Only sync changed data
    • Use lastKnownRecord to track changes
  2. Error Handling

    • Implement comprehensive error handling
    • Provide clear error feedback
  3. Performance Optimization

    • Batch sync operations
    • Avoid unnecessary data transfers
  4. User Experience

    • Provide sync status indicators
    • Implement graceful offline support

Conclusion

While combining SwiftData and CKSyncEngine for iCloud sync requires more code, it provides:

  • Complete sync control
  • Reliable error handling
  • Flexible conflict resolution
  • Better user experience

By properly using these tools and following best practices, we can build stable and reliable data synchronization systems.

License

This article is licensed under CC BY-NC-SA 4.0 . You are free to:

  • Share — copy and redistribute the material in any medium or format
  • Adapt — remix, transform, and build upon the material

Under the following terms:

  • Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
  • NonCommercial — You may not use the material for commercial purposes.
  • ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.

Last updated at: