肘子的 Swift 记事本

Switching Core Data Cloud Sync Status in Real-Time

Published on

Get weekly handpicked updates on Swift and SwiftUI!

At WWDC 2019, Apple launched the Core Data with CloudKit API, significantly lowering the threshold for cloud synchronization of Core Data data. Since this service is almost free for developers, more and more developers have integrated this service into their applications in the following years, bringing a good cross-device and cross-platform experience to users. This article will discuss and explain the implementation principles, operational details, and considerations for switching the Core Data cloud sync status in real-time.

If you are not familiar with Core Data with CloudKit, please read my article about Core Data with CloudKit.

Non-Real-Time Switching

Non-real-time switching refers to the scenario where modifications to the Core Data cloud sync status do not take effect immediately. The sync status changes only after the application is cold-started again. If there is no urgent need for real-time switching of sync status, this method should be preferred.

Not Setting cloudKitContainerOptions

Developers can associate a persistent store in NSPersistentStoreDescription (created in Data Model Editor through Configuration) with a CloudKit container by setting the cloudKitContainerOptions property of NSPersistentStoreDescription. If we do not set cloudKitContainerOptions (or set it to nil), then NSPersistentCloudKitContainer will not enable network synchronization functionality on this NSPersistentStoreDescription. We can use this to set the sync status of NSPersistentCloudKitContainer.

Since the settings for NSPersistentStoreDescription must be completed before loadPersistentStores, the settings made in this way usually take effect after the next cold start of the app (in theory, it can also be achieved by creating a new NSPersistentCloudKitContainer instance, but in the case of a single container, to ensure the integrity of the data in the managed object context, too many possibilities need to be taken care of, which is more difficult).

Swift
lazy var container: NSPersistentCloudKitContainer = {
    let container = NSPersistentCloudKitContainer(name: "Model")
    let enableMirror = UserDefaults.standard.bool(forKey: "enableMirror")
    if enableMirror {
        container.persistentStoreDescriptions.first?.cloudKitContainerOptions = .init(containerIdentifier: "YourCloudKitContainerID")
    }
    // Other settings
    container.loadPersistentStores { desc, error in
        // ..
    }
    // Other settings
    return container
}()

Unifying as NSPersistentContainer

If your app only uses the feature of syncing a private database, you can also take advantage of the fact that NSPersistentCloudKitContainer is a subclass of NSPersistentContainer to achieve a similar purpose:

Swift
lazy var container1: NSPersistentContainer = {
    let container: NSPersistentContainer
    let enableMirror = UserDefaults.standard.bool(forKey: "enableMirror")
    if enableMirror {
        container = NSPersistentCloudKitContainer(name: "Model")
        container.persistentStoreDescriptions.first?.cloudKitContainerOptions = .init(containerIdentifier: "YourCloudKitContainerID")
    } else {
        container = NSPersistentContainer(name: "Model")
    }
    // Other settings
    container.loadPersistentStores { desc, error in
        // ..
    }
    // Other settings
    return container
}()

How NSPersistentCloudKitContainer Works

Before discussing how to implement real-time switching of sync status, we first need to understand the composition and working mechanism of NSPersistentCloudKitContainer.

NSPersistentCloudKitContainer is composed of the following functional modules:

NSPersistentContainer

NSPersistentCloudKitContainer is a subclass of NSPersistentContainer and possesses all the capabilities of NSPersistentContainer. Apart from a few APIs for sharing and public data authentication, developers interact almost entirely with the NSPersistentContainer portion within NSPersistentCloudKitContainer. Thus, broadly speaking, NSPersistentCloudKitContainer is essentially NSPersistentContainer plus a network handling component.

Persistent History Tracking and Format Conversion Module

By default enabling Persistent History Tracking, NSPersistentCloudKitContainer can track all operations performed on the SQLite database by the application. It then converts these operations into the corresponding CloudKit format and stores them in specific tables on SQLite (like ANSCKEXPORT…, ANSCKMIRROREDRELATIONSHIP, etc.), waiting for the network synchronization module to sync (Export) them to the cloud.

Similarly, for data synced (Imported) from the cloud, this module converts it into the corresponding Core Data format and modifies the relevant data in SQLite. All modification operations are recorded in the Persistent History Tracking’s Transaction data under the identity of NSCloudKitMirroringDelegate.import (Transaction author).

Since this process is carried out in a private context created by NSPersistentContainer, setting viewContext.automaticallyMergesChangesFromParent to true is sufficient to automatically merge data in the view context without needing to process the Transactions created by Persistent History Tracking.

The use of Persistent History Tracking, a mechanism supporting cross-process level data modification alerts, allows NSPersistentContainer to be decoupled from network synchronization functionality.

For more on Persistent History Tracking, see the article Using Persistent History Tracking in CoreData. To understand how Core Data organizes data in SQLite, refer to How Core Data Saves Data in SQLite.

Network Synchronization Module

For Export data, this module opportunistically (depending on network conditions, data update frequency, etc.) uploads the converted data to iCloud.

For Import data, upon receiving cloud data change notifications (activated through Remote notifications), this module saves the changes from the network to SQLite for the conversion module to use.

All network synchronization operations are saved as logs in SQLite. In the event of iCloud account status changes, NSPersistentCloudKitContainer uses these synchronization records as credentials for data reset.

Data Authorization Module

When enabling synchronization of shared or public databases with NSPersistentCloudKitContainer, this module, to improve the efficiency of data operation authorization, backs up the original data from the shared or public databases on iCloud (such as CKRecordType, record tokens, etc.) in the local SQLite. It also provides authentication APIs for developers to use.

The Principle of Real-Time Switching

The modular composition of NSPersistentCloudKitContainer provides a foundation for implementing real-time synchronization status switching.

By creating dual containers (NSPersistentContainer + NSPersistentCloudKitContainer), we separate the operations related to Core Data in the application from the network synchronization functionality.

Both containers use the same Data Model and enable Persistent History Tracking to detect each other’s data modification operations on SQLite. Operations related to data business logic in the program are performed on the NSPersistentContainer instance, while the NSPersistentCloudKitContainer instance is solely responsible for the network synchronization of data.

Thus, by enabling or disabling the NSPersistentCloudKitContainer instance responsible for network synchronization, real-time switching of network synchronization status can be achieved. Since all data operations in the application are only performed on the NSPersistentContainer, real-time switching of sync status during runtime does not affect the safety and stability of the data.

In theory, using an NSPersistentCloudKitContainer without configured cloudKitContainerOptions in place of NSPersistentContainer is also possible. However, as it has not been thoroughly tested, this article still uses the combination of NSPersistentContainer + NSPersistentCloudKitContainer.

Implementation Details Reminder

The demo code based on the above analysis can be obtained here.

This section will explain some implementation details based on the demo code.

Multiple Containers Using the Same Data Model

In an application, Core Data’s Data Model (the model file created using the data model editor) can only be loaded once. Therefore, we need to load this file first and create an NSManagedObjectModel instance for use by multiple containers before creating the containers.

Swift
private let model: NSManagedObjectModel
private let modelName: String

init(modelName: String) {
    self.modelName = modelName
    // load Data Model
    guard let url = Bundle.main.url(forResource: modelName, withExtension: "momd"),
          let model = NSManagedObjectModel(contentsOf: url) else {
        fatalError("Can't get \(modelName).momd in Bundle")
    }
    self.model = model
    
    ...
}

lazy var container: NSPersistentContainer = {
    // Create container using NSManagedObjectModel
    let container = NSPersistentContainer(name: modelName, managedObjectModel: model)
    ...
    return container
}()

This method is also applied in the memory mode section of the article Mastering Core Data Stack.

Declaring NSPersistentCloudKitContainer as an Optional Value

By declaring the container used for network synchronization as an optional value, we can easily implement enabling and disabling the sync functionality:

Swift
final class CoreDataStack {
    var cloudContainer: NSPersistentCloudKitContainer?
    lazy var container: NSPersistentContainer = {
        let container = NSPersistentContainer(name: modelName, managedObjectModel: model)
        ...
        return container
    }
    
    init(modelName: String) {
        ....
        
        // Decide whether to create a sync container
        if UserDefaults.standard.bool(forKey: enableCloudMirrorKey) {
            setCloudContainer()
        } else {
            print("Cloud Mirror is closed")
        }
    }
    // Create a container for synchronization
    func setCloudContainer() {
        if cloudContainer != nil {
            removeCloudContainer()
        }
        let container = NSPersistentCloudKitContainer(name: modelName, managedObjectModel: model)
        ....
        cloudContainer = container
    }

    // Remove the container used for synchronization
    func removeCloudContainer() {
        guard cloudContainer != nil else { return }
        cloudContainer = nil
        print("Turn off the cloud mirror")
    }
}

Through these methods, developers can effectively manage the real-time switching of Core Data cloud synchronization, enabling dynamic adjustment based on the application’s requirements and user settings.

Both Containers Must Enable Persistent History Tracking

Both containers need to enable Persistent History Tracking so they can detect each other’s modifications to the Core Data data and process them.

Swift
container.persistentStoreDescriptions.first?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
container.persistentStoreDescriptions.first?.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

At the same time, to resolve merge conflicts, both must set the correct merge policy:

Swift
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

Responding to Persistent History Tracking Notifications in NSPersistentContainer

When the NSPersistentCloudKitContainer instance retrieves data from the network and updates it in SQLite, it creates a Transaction in SQLite and sends an NSPersistentStoreRemoteChange notification via NotificationCenter. We need to respond to this notification in the NSPersistentContainer instance and merge the synchronized data into the current view context.

If using the Persistent History Tracking Kit to handle Transactions, as in this article’s example, you need to enable the includingCloudKitMirroring option to merge the change data obtained by NSPersistentCloudKitContainer from the network:

Swift
persistentHistoryKit = .init(container: container,
                             currentAuthor: AppActor.app.rawValue,
                             allAuthors: [AppActor.app.rawValue],
                             includingCloudKitMirroring: true, // Merge network sync data
                             userDefaults: UserDefaults.standard,
                             cleanStrategy: .none)

Please refer to Using Persistent History Tracking in CoreData for detailed usage of Persistent History Tracking. For information about Persistent History Tracking Kit, refer to its accompanying ReadMe document.

Do Not Clear Transaction Records

Unlike using Persistent History Tracking solely among App group members, it’s best not to clear the Transaction records created by Persistent History Tracking when the network sync status can be switched at any time.

This is because NSPersistentCloudKitContainer determines which data has changed based on Transactions. If we delete Transactions while network sync is off, when syncing is enabled again, NSPersistentCloudKitContainer will not be able to recognize the local data changes that occurred during the off period, leading to permanent desynchronization between local and cloud data.

The reason why it’s possible to delete Transaction records when using Persistent History Tracking only among App group members is that each member updates its corresponding timestamp after merging data. When performing Transaction deletion, we can delete only those records that have been merged by all members. Since it’s not simple to know the last update time of NSPersistentCloudKitContainer and the position of synchronized data, retaining Transaction records is the best choice.

In the example of this article, the cleaning of Transactions is prohibited by setting the cleanStrategy of PersistentHistoryTrackingKit to none:

Swift
persistentHistoryKit = .init(container: container,
                             currentAuthor: AppActor.app.rawValue,
                             allAuthors: [AppActor.app.rawValue],
                             includingCloudKitMirroring: true,
                             userDefaults: UserDefaults.standard,
                             cleanStrategy: .none) // Do not clear transactions

If your app will only switch the sync status once (from off to on, and then never off again), you can clear the Transactions generated by your App group members after enabling sync.

How to Handle Synchronization of Shared and Public Databases

Given that NSPersistentContainer does not provide APIs for data authentication, when your application uses shared or public database synchronization functions, a similar method as below can be adopted:

Swift
import CloudKit

final class CoreDataStack {
    let localContainer: NSPersistentContainer
    let cloudContainer: NSPersistentCloudKitContainer?
    var container: NSPersistentContainer {
        guard let cloudContainer else {
            return localContainer
        }
        return cloudContainer
    }
    
    // Some permission check work, for example only
    func checkPermission(id: NSManagedObjectID) -> Bool {
        guard enableMirror, let container = self.container as? NSPersistentCloudKitContainer else { return false }
        return container.canUpdateRecord(forManagedObjectWith: id)
    }
}

It is strongly recommended to disable features in your app that might modify shared and public databases when the network sync status is off.

Handling iCloud Account Status Changes

The following content changes the preset behavior of Apple regarding iCloud data consistency. Do not attempt this unless you are sure of what you are doing and have a specific need for it!

For apps using NSPersistentCloudKitContainer for data synchronization, when the user logs out of the iCloud account, switches accounts, or turns off iCloud sync for the app on the device, NSPersistentCloudKitContainer will clear all data associated with the account on the device upon restart (if such operations are performed while the app is running, the iOS app will restart automatically). This clearing operation is a preset behavior and is normal.

Some system apps provide the ability to retain local data after logging out of iCloud. However, NSPersistentCloudKitContainer does not by default design to retain data.

Upon restarting, NSPersistentCloudKitContainer activates the data deletion operation by acquiring the noAccount status from CKContainer’s accountStatus. The deletion is based on the data sync logs saved in the network synchronization module mentioned earlier.

If you want to modify the default data handling behavior of NSPersistentCloudKitContainer, you can check the accountStatus of the CloudKit container before creating the NSPersistentCloudKitContainer instance and create the instance only if the status is not noAccount. For example:

Swift
import CloudKit

func setCloudContainerWhenOtherStatus() {
    let container = CKContainer(identifier: "YourCloudKitContainerID")
    container.accountStatus { status, error in
        if status != .noAccount {
            self.setCloudContainer()
        }
    }
}

Alternatively, when accountStatus is noAccount, set the cloudKitContainerOptions of NSPersistentCloudKitContainer’s NSPersistentStoreDescription to nil, thereby blocking its automatic clearing behavior.

If we retain data that should have been automatically cleared, and the user switches iCloud accounts, it could lead to confusion of data among multiple accounts if not handled properly.

Conclusion

As the saying goes, “no gain without loss.” Using dual containers and not clearing transactions to implement real-time sync status switching will inevitably lead to some performance loss and resource occupation. However, if your application has such a need, this trade-off is very worthwhile.

Persistent History Tracking is increasingly appearing in various scenarios. Besides sensing data changes among App group members, it is also applied in Batch Operations, cloud data synchronization, Spotlight, and other areas. It’s advisable for Core Data users to have a thorough understanding of it and apply it to your programs as soon as possible.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways