11/30/2017 - 5:09 AM

Swift - CloudKit - Maintaining a Local Cache


01. Maintaining a Local Cache of CloudKit Records

You might want to add a local cache of CloudKit records to your app to support offline use of your 
app or to improve performance. Or you may already have a data store for your app and you'd like to 
add support for persisting that data in CloudKit as well.

02. The General Workflow

After you configure your app to maintain a local cache, here is the general flow your app will follow:

When your app launches for the first time on a new device it will subscribe to changes in the user's 
private and shared databases.
When a user modifies their data locally on Device A your app will send those changes to CloudKit.
Your app will receive a push notification on the same user's Device B notifying it that there was a 
change made on the server.
Your app on Device B will ask the server for the changes that occurred since the last time it spoke
with the server and then update its local cache with those changes.

03. Initializating the Container

Your app's initialization logic should run whenever your app launches. Your app should cache locally 
regardless of whether you've already created your zone(s) and subscriptions so that you aren't 
issuing unnecessary requests upon every launch.

First the code defines items to be used throughout this example.

let container = CKContainer.default()
let privateDB = container.privateCloudDatabase
let sharedDB = container.sharedCloudDatabase
// Use a consistent zone ID across the user's devices
// CKCurrentUserDefaultName specifies the current user's ID when creating a zone ID
let zoneID = CKRecordZoneID(zoneName: "Todos", ownerName: CKCurrentUserDefaultName)
// Store these to disk so that they persist across launches
var createdCustomZone = false
var subscribedToPrivateChanges = false
var subscribedToSharedChanges = false
let privateSubscriptionId = "private-changes"
let sharedSubscriptionId = "shared-changes"

04. Creating Custom Zone(s)

To use the change tracking functionality of CloudKit, you need to store your app data in a custom 
zone in the user's private database. You can create a custom zone by instantiating a 
CKModifyRecordZonesOperation object as shown below.

let createZoneGroup = DispatchGroup()
if !self.createdCustomZone {
    let customZone = CKRecordZone(zoneID: zoneID)
    let createZoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: [customZone], recordZoneIDsToDelete: [] )
    createZoneOperation.modifyRecordZonesCompletionBlock = { (saved, deleted, error) in
        if (error == nil) { self.createdCustomZone = true }
        // else custom error handling
    createZoneOperation.qualityOfService = .userInitiated

05. Subscribing to Change Notifications

Your app needs to subscribe to changes made from other devices. Subscriptions tell CloudKit 
which data you care about so that it can send push notifications to your app when that data changes.

Your app will need to create two subscriptions to database changes (CKDatabaseSubscription objects),
one for the private database and one for the shared database.

if !self.subscribedToPrivateChanges {
    let createSubscriptionOperation = self.createDatabaseSubscriptionOperation(subscriptionId: privateSubscriptionId)
    createSubscriptionOperation.modifySubscriptionsCompletionBlock = { (subscriptions, deletedIds, error) in
        if error == nil { self.subscribedToPrivateChanges = true }
        // else custom error handling
if !self.subscribedToSharedChanges {
    let createSubscriptionOperation = self.createDatabaseSubscriptionOperation(subscriptionId: sharedSubscriptionId)
    createSubscriptionOperation.modifySubscriptionsCompletionBlock = { (subscriptions, deletedIds, error) in
        if error == nil { self.subscribedToSharedChanges = true }
        // else custom error handling
// Fetch any changes from the server that happened while the app wasn't running
createZoneGroup.notify(queue: DispatchQueue.global()) {
    if self.createdCustomZone {
        self.fetchChanges(in: .private) {}
        self.fetchChanges(in: .shared) {}
These subscriptions tell CloudKit to send a push notification to your app on this device any time
a record or zone is added, modified, or deleted within the database you created the subscription in.

You likely want to configure your subscriptions to send silent push notifications. These 
notifications wake your app so that it can fetch changes, but the app will not present an 
alert to the user.

func createDatabaseSubscriptionOperation(subscriptionId: String) -> CKModifySubscriptionsOperation {
    let subscription = CKDatabaseSubscription.init(subscriptionID: subscriptionId)
    let notificationInfo = CKNotificationInfo()
    // send a silent notification
    notificationInfo.shouldSendContentAvailable = true
    subscription.notificationInfo = notificationInfo
    let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: [])
    operation.qualityOfService = .utility
    return operation

06. Listening for Push Notifications

As part of configuring your app to use CloudKit, you will need to configure your app to listen
for remote notifications.

Use CKNotification(fromRemoteNotificationDictionary: dict) on the userInfo dictionary to determine 
whether the remote notification your app receives was triggered by a CKSubscription.

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    return true
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    print("Received notification!")
    let viewController = self.window?.rootViewController as? ViewController
    guard let viewController = self.window?.rootViewController as? ViewController else { return }
    let dict = userInfo as! [String: NSObject]
    guard let notification:CKDatabaseNotification = CKNotification(fromRemoteNotificationDictionary:dict) as? CKDatabaseNotification else { return }
    viewController!.fetchChanges(in: notification.databaseScope) {

07. Fetching Changes

After app launch or the receipt of a push, your app uses CKFetchDatabaseChangesOperation and then 
CKFetchRecordZoneChangesOperation to ask the server for only the changes since the last time it updated.

The key to these operations is the previousServerChangeToken object, which tells the server when 
your app last spoke to the server, allowing the server to return only the items that were changed 
since that time.

First your app will use a CKFetchDatabaseChangesOperation to find out which zones have changed and:

Collect the IDs for the new and updated zones.
Clean up local data from zones that were deleted.
Here is some example code to fetch the database changes:

func fetchChanges(in databaseScope: CKDatabaseScope, completion: @escaping () -> Void) {
    switch databaseScope {
    case .private:
        fetchDatabaseChanges(database: self.privateDB, databaseTokenKey: "private", completion: completion)
    case .shared:
        fetchDatabaseChanges(database: self.sharedDB, databaseTokenKey: "shared", completion: completion)
    case .public:
func fetchDatabaseChanges(database: CKDatabase, databaseTokenKey: String, completion: @escaping () -> Void) {
    var changedZoneIDs: [CKRecordZoneID] = []
    let changeToken = … // Read change token from disk
    let operation = CKFetchDatabaseChangesOperation(previousServerChangeToken: changeToken)
    operation.recordZoneWithIDChangedBlock = { (zoneID) in
    operation.recordZoneWithIDWasDeletedBlock = { (zoneID) in
        // Write this zone deletion to memory
    operation.changeTokenUpdatedBlock = { (token) in
        // Flush zone deletions for this database to disk
        // Write this new database change token to memory
    operation.fetchDatabaseChangesCompletionBlock = { (token, moreComing, error) in
        if let error = error {
            print("Error during fetch shared database changes operation", error)
        // Flush zone deletions for this database to disk
        // Write this new database change token to memory
        self.fetchZoneChanges(database: database, databaseTokenKey: databaseTokenKey, zoneIDs: changedZoneIDs) {
            // Flush in-memory database change token to disk
    operation.qualityOfService = .userInitiated
Next your app uses a CKFetchRecordZoneChangesOperation object with the set of zone IDs you just 
collected to do the following:

Create and update any changed records
Delete any records that no longer exist
Update the zone change tokens
Here is some example code to fetch the zone changes:

func fetchZoneChanges(database: CKDatabase, databaseTokenKey: String, zoneIDs: [CKRecordZoneID], completion: @escaping () -> Void) {
    // Look up the previous change token for each zone
    var optionsByRecordZoneID = [CKRecordZoneID: CKFetchRecordZoneChangesOptions]()
    for zoneID in zoneIDs {
        let options = CKFetchRecordZoneChangesOptions()
        options.previousServerChangeToken = … // Read change token from disk
            optionsByRecordZoneID[zoneID] = options
    let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIDs, optionsByRecordZoneID: optionsByRecordZoneID)
    operation.recordChangedBlock = { (record) in
        print("Record changed:", record)
        // Write this record change to memory
    operation.recordWithIDWasDeletedBlock = { (recordId) in
        print("Record deleted:", recordId)
        // Write this record deletion to memory
    operation.recordZoneChangeTokensUpdatedBlock = { (zoneId, token, data) in
        // Flush record changes and deletions for this zone to disk
        // Write this new zone change token to disk
    operation.recordZoneFetchCompletionBlock = { (zoneId, changeToken, _, _, error) in
        if let error = error {
            print("Error fetching zone changes for \(databaseTokenKey) database:", error)
        // Flush record changes and deletions for this zone to disk
        // Write this new zone change token to disk
    operation.fetchRecordZoneChangesCompletionBlock = { (error) in
        if let error = error {
            print("Error fetching zone changes for \(databaseTokenKey) database:", error)
The code above has several comments about writing changes to memory and then later flushing those 
changes to disk. The general flow is as follows.

For databases:

When told about a zone deletion, write that into memory.
When told about a new change token for a database, write that token into memory, then either:
persist all in-memory zone changes to disk (deleting deleted zones, and recording a list of zones 
that need changes fetched), or
persist zone deletions to disk, and fetch changes for all modified record zones.
Note:  When you are fetching database changes, you need to persist all of the per-zone callbacks 
you received before getting the database change token.
Finally, flush the updated database change token to disk
Similarly, for zones:

When told about a record change in a zone, write that into memory.
When told about a new change token for a zone, commit all in-memory record changes in that zone 
as well as the updated change token for that zone to disk.

08. Storing Record Metadata

To relate records in your local data store to records on the server, you will likely need to store 
the metadata for your records (record name, zone ID, change tag, creation date, and so on). 
There is a handy method on CKRecord, encodeSystemFieldsWithCoder, that helps you do this for 
system fields. You will still have to handle your own custom fields separately.

Here is an example of how your app can read the metadata in order to store it locally:

// obtain the metadata from the CKRecord
let data = NSMutableData()
let coder = NSKeyedArchiver.init(forWritingWith: data)
coder.requiresSecureCoding = true
record.encodeSystemFields(with: coder)
// store this metadata on your local object
yourLocalObject.encodedSystemFields = data
When sending changes to CloudKit based on your local data, you can read the local cache back 
into CloudKit objects and manipulate them for storage in CloudKit:

// set up the CKRecord with its metadata
let coder = NSKeyedUnarchiver(forReadingWith: yourLocalObject.encodedSystemFields!)
coder.requiresSecureCoding = true
let record = CKRecord(coder: coder)
// write your custom fields...

09. Advanced Local Caching

A user can delete your app's data on the CloudKit servers through iCloud Settings->Manage Storage. 
Your app needs to handle this gracefully and re-create the zone and subscriptions on the server 
again if they don't exist. The specific error returned in this case is userDeletedZone.

The operation dependency system outlined in the Advanced NSOperations talk from WWDC2015 is a 
great way to manage your CloudKit operations so that account and network statuses are checked 
and zones and subscriptions are created at the right time.

The network connection may disappear at any time, so make sure to properly handle networkUnavailable errors from any operation.

Watch for network reachability, and retry the operation when the network becomes available again.