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:

sequenceDiagram
    participant App
    participant SyncEngine as CKSyncEngine
    participant Delegate as SyncEngineDelegate
    participant iCloud as CloudKit Server
    
    Note over App,iCloud: Initialization Phase
    App->>SyncEngine: Create CKSyncEngine instance
    App->>SyncEngine: Set delegate
    App->>SyncEngine: Load persistent state
    
    Note over App,iCloud: Send Local Changes
    App->>SyncEngine: Add pending changes
    SyncEngine->>Delegate: Call nextRecordZoneChangeBatch
    Delegate-->>SyncEngine: Return batch of records to sync
    SyncEngine->>iCloud: Send changes to server
    iCloud-->>SyncEngine: Return processing results
    
    Note over App,iCloud: Receive Server Changes
    iCloud->>SyncEngine: Server data changes
    SyncEngine->>Delegate: Notify received changes(Event.fetchedRecordZoneChanges)
    Delegate->>App: Update local data

Implementation Steps

1. Data Model Design

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

@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:

@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:

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:

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:

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:

@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.


Licensed under CC BY-NC-SA 4.0