The Core Data CloudKit Paradox

The Core Data CloudKit Paradox

ยท

5 min read

What came first? the chicken or the egg? The ancient paradox that describes the problem of determining cause and effect. Maybe the answer is both? This oddly resonates with a common problem when it comes to projects that utilize the NSPersistentCloudKitContainer.

The Paradox

When an app is first launched, it can take sometime for the user's device to setup and import Core Data objects from the cloud. Let's imagine our app had some initial onboarding screens that set up the app experience. If a user has previously gone through the setup, we might find ourselves at the risk of merge conflicts as data is eventually synced on the device, not to mention a bad user experience.

How can we determine if a newly setup device has data to sync if we need to wait for the sync to occur in the first place?

There are number of ways we can go about it.

iCloud Key-Value Store

Apple provides a mechanism for storing app-state data on a user's iCloud account via NSUbiquitousKeyValueStore class:

let keyValueStore = NSUbiquitousKeyValueStore.default
keyValueStore.setValue(true, forKey: "setup-complete")
if !keyValueStore.synchronize() {
    fatalError("app was not built with proper entitlement requests")
}

We could then use the stored value to determine which experience to load for the user:

struct IntroView: View {
    var body: some View {
        if NSUbiquitousKeyValueStore.default.bool(forKey: "setup-complete") {
            WaitForSyncView()
        } else {
            OnboardingView()
        }
    }
}

Limitations to this approach:

  • NSUbiquitousKeyValueStore is limited to 1 MB of storage for the whole app. This should be fine for our use case, but something to consider if planning on storing other data.
  • Suffers from the same problem we are trying to solve. We can't control when the sync happens, meaning there could be a significant delay from app install to the value being synced.

Using Core Data

Another possible solution would be to store a Core Data object and then check for the existence of the record when the app launches.

Record user setup:

let viewContext = CoreDataStore.container.viewContext
let setup = InitialSetup(context: viewContext)
setup.createdAt = Date()
do {
    try viewContext.save()
} catch {
    debugPrint("an error occurred saving initial setup: \(error.localizedDescription)")
}

We could then fetch any instances of the InitialSetup object using the FetchRequest property wrapper, reacting to changes as they are synced:

struct IntroView: View {
    @FetchRequest(entity: InitialSetup.entity(), sortDescriptors: [])
    private var setups: FetchedResults<InitialSetup>
    var body: some View {
        if setups.isEmpty {
            OnboardingView()
        } else {
            WaitForSyncView()
        }
    }
}

Limitations to this approach:

  • Similar to our previous solution, sync is not in our control and could potentially take longer depending on other objects being imported.

Using CloudKit

When we use NSPersistentCloudKitContainer, our Core Data objects are magically transformed into CKRecords and persisted on a user's CloudKit database behind the scenes. As a result, the CloudKit database knows whether or not a user has used our app.

Unfortunately there isn't a straightforward method of querying those CKRecords directly. But since we are already using CloudKit, we could create our own record and check for its existence via the CloudKit framework:

let record = CKRecord(recordType: "InitialSetup")
let database = CKContainer.default().privateCloudDatabase
database.save(record) { _, error in
    if let error = error {
        debugPrint("an error occurred saving initial setup: \(error.localizedDescription)")
    }
}

We then query for record when view appears and display the appropriate flow based on the result:

struct IntroView: View {

    enum ViewState {
        case loading
        case content(setupComplete: Bool)
        case error(Error)
    }

    @State
    private var viewState: ViewState = .loading
    var body: some View {
        content(viewState)
            .onAppear {
                determineFlow()
            }
    }

    @ViewBuilder
    private func content(_ viewState: ViewState) -> some View {
        switch viewState {
        case .loading:
            ProgressView()
        case .error:
            Text("Oops! Something isn't right")
        case .content(let setupComplete):
            if setupComplete {
                WaitForSyncView()
            } else {
                OnboardingView()
            }
        }
    }

    private func determineFlow() {
        let query = CKQuery(recordType: "InitialSetup",
                            predicate: NSPredicate(value: true))
        let database = CKContainer.default().privateCloudDatabase
        database.perform(query,
                         inZoneWith: CKRecordZone.default().zoneID) { records, error in
            DispatchQueue.main.async {
                if let error = error {
                    viewState = .error(error)
                    return
                }
                guard let records = records else {
                    // suggests it's a new user
                    viewState = .content(setupComplete: false)
                    return
                }
                viewState = .content(setupComplete: !records.isEmpty)
            }
        }
    }
}

The benefit of using this approach compared to the previous two, is that the logic to determine a previous user does not rely on synchronization that isn't in our control. When we make the request to fetch the records, we are accessing the cloud database directly rather than waiting for the data to eventually make its way to the device.

Another aspect of this approach, is that it can be used for a variety of sync related use cases. We might find ourselves in a situation, for instance, where we would like to prevent multiple devices from syncing with an external server. Using a CKRecord type that host timestamps and vendor identifiers, we can prevent multiple devices from attempting to sync duplicate content.

Limitations to this approach:

  • An internet connection is required. As a result we would probably need to have a fallback experience in the event of no connection.

Conclusion

For syncable Core Data projects, determining if a new device is associated with a previous iCloud account can be tricky. In this article we learned three possible approaches each with their respective limitations. I prefer the CloudKit solution given its the only approach of the three that seems to solve the paradox

I hope you enjoyed this article and found it useful. If you have any questions, comments or feedback i'd love to hear them. Contact me or follow me on Twitter

ย