SwiftData + CKSyncEngine

Hi,

I'm building a habit tracking app for iOS and macOS. I want to use up to date technologies, so I'm using SwiftUI and SwiftData.

I want to store user data locally on device and also sync data between device and iCloud server so that the user could use the app conveniently on multiple devices (iPhone, iPad, Mac).

I already tried SwiftData + NSPersistentCloudKitContainer, but I need to control when to sync data, which I can't control with NSPersistentCloudKitContainer. For example, I want to upload data to server right after data is saved locally and download data from server on every app open, on pull-to-refresh etc. I also need to monitor sync progress, so I can update the UI and run code based on the progress. For example, when downloading data from server to device is in progress, show "Loading..." UI, and when downloading finishes, I want to run some app business logic code and update UI.

So I'm considering switching from NSPersistentCloudKitContainer to CKSyncEngine, because it seems that with CKSyncEngine I can control when to upload and download data and also monitor the progress.

My database schema (image below) has relationships - "1 to many" and "many to many" - so it's convenient to use SwiftData (and underlying CoreData).

Development environment: Xcode 16.1, macOS 15.1.1

Run-time configuration: iOS 18.1.1, macOS 15.1.1

My questions:

1-Is it possible to use SwiftData for local data storage and CKSyncEngine to sync this local data storage with iCloud?

2-If yes, is there any example code to implement this?

I've been studying the "CloudKit Samples: CKSyncEngine" demo app (https://github.com/apple/sample-cloudkit-sync-engine), but it uses a very primitive approach to local data storage by saving data to a JSON file on disk.

It would be very helpful to have the same demo app with SwiftData implementation!

3-Also, to make sure I don't run into problems later - is it okay to fire data upload (sendChanges) and download (fetchChanges) manually with CKSyncEngine and do it often? Are there any limits how often these functions can be called to not get "blocked" by the server?

4-If it's not possible to use SwiftData for local data storage and CKSyncEngine to sync this local data storage with iCloud, then what to use for local storage instead of SwiftData to sync it with iCloud using CKSyncEngine? Maybe use SwiftData with the new DataStore protocol instead of the underlying CoreData?

All information highly appreciated!

Thanks, Martin

Answered by DTS Engineer in 817501022

1-Is it possible to use SwiftData for local data storage and CKSyncEngine to sync this local data storage with iCloud?

Yes. CKSyncEngine takes care the synchronization. Making a local cache for the CloudKit data is in a separate layer, which can be done with whatever persistence technology you like, including SwiftData.

2-If yes, is there any example code to implement this?

We don't have any SwiftData + CKSyncEngine sample code as of today.

3-Also, to make sure I don't run into problems later - is it okay to fire data upload (sendChanges) and download (fetchChanges) manually with CKSyncEngine and do it often? Are there any limits how often these functions can be called to not get "blocked" by the server?

It is OK to call fetchChanges(_:) and sendChanges(_:) in your app. Doing that frequently is much less so, because that can trigger CloudKit throttles, as discussed in TN3162: Understanding CloudKit throttles.

We don't have a clear documentation about how often is too often, but the technote covers how to identify and handle CloudKit throttles, which hopefully helps.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

1-Is it possible to use SwiftData for local data storage and CKSyncEngine to sync this local data storage with iCloud?

Yes. CKSyncEngine takes care the synchronization. Making a local cache for the CloudKit data is in a separate layer, which can be done with whatever persistence technology you like, including SwiftData.

2-If yes, is there any example code to implement this?

We don't have any SwiftData + CKSyncEngine sample code as of today.

3-Also, to make sure I don't run into problems later - is it okay to fire data upload (sendChanges) and download (fetchChanges) manually with CKSyncEngine and do it often? Are there any limits how often these functions can be called to not get "blocked" by the server?

It is OK to call fetchChanges(_:) and sendChanges(_:) in your app. Doing that frequently is much less so, because that can trigger CloudKit throttles, as discussed in TN3162: Understanding CloudKit throttles.

We don't have a clear documentation about how often is too often, but the technote covers how to identify and handle CloudKit throttles, which hopefully helps.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks for the reply.

I've been trying to get CKSyncEngine working with SwiftData. Since you don't any sample code, here's my code and some questions about it.

CKSyncEngineDelegate has a function called "handleEvent". In that function I handle the "fetchedRecordZoneChanges" event amongst other events. Here's my function to handle that event (it's basically copy paste from the sample app):

func handleFetchedRecordZoneChanges(_ event: CKSyncEngine.Event.FetchedRecordZoneChanges) {
    
    guard let localContainer else { return }
    let newContext = ModelContext(localContainer)
    let localHabits = SwiftDataPersistence.fetch(FetchDescriptor<Habit>(), predicate: nil, sortBy: [], fetchLimit: nil, context: newContext)
    
    for modification in event.modifications {
        
        // The sync engine fetched a record, and we want to merge it into our local persistence.
        let serverHabit = modification.record
        let serverHabitID = serverHabit.recordID.recordName
        
        // If we already have this object locally, let's merge the data from the server.
        if let localHabit = localHabits.first(where: { habit in
            habit.identifier == serverHabitID
        }) {
            localHabit.mergeFromServerData(serverHabit)
            localHabit.setLastKnownRecordIfNewer(serverHabit)
        }
        
        // Otherwise, let's create a new local object.
        else {
            let newHabit = Habit(record: serverHabit)
            newContext.insert(newHabit)
        }
    }
    
    for deletion in event.deletions {
        // TO DO
    }
    
    // If we had any changes, let's save to disk.
    if !event.modifications.isEmpty || !event.deletions.isEmpty {
        SwiftDataPersistence.save(newContext)
        NotificationCenter.default.post(name: newContextDidSaveNotification, object: nil)
    }
}

Last line of code: I'm sending a notification to my SwiftUI view (that shows habits) to fetch edited/new habits.

Here's my view code:

struct HabitsListView: View {
    @State private var habits = [Habit]()
    
    @Environment(\.modelContext) private var context
    
    var body: some View {
        LazyVStack(alignment: .leading, spacing: 8) {
            ForEach(habits) { habit in
                HabitRow(habit)
            }
        }
        .onNewContextDidSave {
            fetchHabits()
        }
    }
    
    func fetchHabits() {
        self.habits = SwiftDataPersistence.fetch(FetchDescriptor<Habit>(), predicate: nil, sortBy: [SortDescriptor(\Habit.name)], fetchLimit: nil, context: context)
    }
}

This approach seems to work, but it's not ideal and I'm not sure it's a correct way to do it. It works with only 1 custom notification, but I ran into problems when I added more notifications - then the notifications stopped firing or the view didn't catch them.

But my main question at this moment is: How to get @Query to update my habits list, so I don't have to manually fetch habits at every change.

I would like to use a view like this:

struct HabitsListView: View {
    @Query private var habits: [Habit]
    
    var body: some View {
        LazyVStack(alignment: .leading, spacing: 8) {
            ForEach(habits) { habit in
                HabitRow(habit)
            }
        }
    }
}

Thanks! Martin

SwiftData provides .willSave and .didSave notifications, and so you probably don't need to define your own (newContextDidSaveNotification in your code).

Secondly, @Query is supposed to detect the change from other model context and trigger a SwiftUI update for the relevant view. There may still have bugs in that area though that prevent SwiftDat from achieving that, as discussed in this post.

If you do see on the latest version systems that @Query doesn't detect the change and trigger the SwiftUI update, I’d suggest that you file a feedback report for the SwiftData team to take a look – If you do so, please share your report ID here for folks to track.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

SwiftData + CKSyncEngine
 
 
Q