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.

The General Workflow

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

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

  2. When a user modifies their data locally on Device A your app will send those changes to CloudKit.

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

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

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"

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 {
    createZoneGroup.enter()
    
    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
        createZoneGroup.leave()
    }
    createZoneOperation.qualityOfService = .userInitiated
    
    self.privateDB.add(createZoneOperation)
}

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
    }
    self.privateDB.add(createSubscriptionOperation)
}
 
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
    }
    self.sharedDB.add(createSubscriptionOperation)
}
 
// 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
}

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 {
    application.registerForRemoteNotifications()
    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) {
        completionHandler(.newData)
    }
}

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:

  1. Collect the IDs for the new and updated zones.

  2. 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:
        fatalError()
    }
}
 
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
        changedZoneIDs.append(zoneID)
    }
    
    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)
            completion()
            return
        }
        // 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
            completion()
        }
    }
    operation.qualityOfService = .userInitiated
    
    database.add(operation)
}

Next your app uses a CKFetchRecordZoneChangesOperation object with the set of zone IDs you just collected to do the following:

  1. Create and update any changed records

  2. Delete any records that no longer exist

  3. 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)
            return
        }
        // 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)
        }
        completion()
    }
    
    database.add(operation)
}

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:

Similarly, for zones:

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)
coder.finishEncoding()
 
// 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)
coder.finishDecoding()
// write your custom fields...

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.

See Also

Learn more about local caching in the WWDC 2016 CloudKit Best Practices video.