Nick Harris

CloudKit + Core Data + NSOperations – Saving Changes

with 4 comments

This is post two of four about CloudKit syncing with Core Data. The original post is here. Sample code is here. You’ll need to enable CloudKit and other entitlements in your own environment to get it to work correctly.

Saving Core Data Changes to CloudKit

Saving core data changes to CloudKit was the principle reason I did the proof of concept. The work is done in a series of either CloudKit operation subclasses, custom operations and data transfer block operations. The real power to how these work though are a couple of protocols I created. The first is the CloudKitRecordIDObject protocol:

@objc protocol CloudKitRecordIDObject {
    var recordID: NSData? { get set }
}

The other is the CloudKitManagedObject protocol:

@objc protocol CloudKitManagedObject: CloudKitRecordIDObject {
    var lastUpdate: NSDate? { get set }
    var recordName: String? { get set }
    var recordType: String { get }
    func managedObjectToRecord(record: CKRecord?) -> CKRecord
    func updateWithRecord(record: CKRecord)
}

The protocols include the properties I need to do things with CloudKit along with a couple of functions to create a CKRecord and update with a CKRecord. I’ll explain the reason why I split the CloudKitRecordIDObject protocol out from the CloudKitManagedObject one when I talk about syncing offline changes. 

One of the tricks I learned from this post by Natasha the Robot is that you can add extensions to Swift protocols. That’s really awesome. It means you can create a default function that all objects that implement the protocol can use. I struggled with that for a while. I had been duplicating the same function on all my objects that implemented a protocol even though it was copy/paste code. Now I have only one implementation everyone can use. I do this for both of these protocols.

The CloudKitRecordIDObject protocol extension will unarchive the CKRecordID if it exists in Core Data:

extension CloudKitRecordIDObject {
    func cloudKitRecordID() -> CKRecordID? {
        guard let recordID = recordID else {
            return nil
        }
        
        return NSKeyedUnarchiver.unarchiveObjectWithData(recordID) as? CKRecordID
    }
}

The CloudKitManagedObject protocol extension will create a CKRecord:

extension CloudKitManagedObject {
    func cloudKitRecord(record: CKRecord?, parentRecordZoneID: CKRecordZoneID?) -> CKRecord {
        
        if let record = record {
            return record
        }
 
        var recordZoneID: CKRecordZoneID
        if parentRecordZoneID != .None {
            recordZoneID = parentRecordZoneID!
        }
        else {
            guard let cloudKitZone = CloudKitZone(recordType: recordType) else {
                fatalError("Attempted to create a CKRecord with an unknown zone")
            }
            
            recordZoneID = cloudKitZone.recordZoneID()
        }
        
        let uuid = NSUUID()
        let recordName = recordType + "." + uuid.UUIDString
        let recordID = CKRecordID(recordName: recordName, zoneID: recordZoneID)
        
        return CKRecord(recordType: recordType, recordID: recordID)
    }
}

The reason for the optional CKRecord and CKRecordZoneID will be more apparent in a bit. You’ll notice these functions are not declared within the protocol. If they were the compiler would insist on them being included in any class that implements the protocol. With them just in the extension they’re still available but they don’t require an implementation in each class.

The CloudKitManagedObject protocol does includes a function called managedObjectToRecord. This needs to be implemented in any NSManagedObject subclass that implements the CloudKitManagedObject protocol since each object would theoretically have different properties. That’s not the case in this app but you get the point. The same is true with the updateWithRecord function that also exists in the protocol.

Generating NSManagedObject subclasses in Xcode now gives you two files. One that’s a <subclass>+CoreDataProperties.swift class that holds the property declarations and another <subclass>.swift file that you can add any custom code you want into. Its very similar to what mogenerator does. I’ve stopped using mogenerator in favor of the built in Xcode functionality. 

With that in mind, here’s what the Car.swift file looks like:

class Car: NSManagedObject, CTBRootManagedObject, CloudKitManagedObject {
    
    var recordType: String { return  ModelObjectType.Car.rawValue }
    
    func managedObjectToRecord(record: CKRecord?) -> CKRecord {
        guard let name = name,
              let added = added,
              let lastUpdate = lastUpdate else {
            fatalError("Required properties for record not set")
        }
        
        let carRecord = cloudKitRecord(record, parentRecordZoneID: nil)
        
        recordName = carRecord.recordID.recordName
        recordID = NSKeyedArchiver.archivedDataWithRootObject(carRecord.recordID)
        
        carRecord["name"] = name
        carRecord["added"] = added
        carRecord["lastUpdate"] = lastUpdate
        
        return carRecord
    }
 
    func updateWithRecord(record: CKRecord) {
        name = record["name"] as? String
        added = record["added"] as? NSDate
        lastUpdate = record["lastUpdate"] as? NSDate
        recordName = record.recordID.recordName
        recordID = NSKeyedArchiver.archivedDataWithRootObject(record.recordID)
    }
}

Bus and Truck in the proof of concept are basically the same. Note is different though:

class Note: NSManagedObject, CloudKitManagedObject {
    
    var recordType: String { return  ModelObjectType.Note.rawValue }
 
    func managedObjectToRecord(record: CKRecord?) -> CKRecord {
        guard let text = text,
            let added = added,
            let lastUpdate = lastUpdate else {
                fatalError("Required properties for record not set")
        }
        
        var noteRecord: CKRecord
        if let record = record {
            noteRecord = record
        }
        else {
            noteRecord = createNoteRecord()
        }
        
        noteRecord["text"] = text
        noteRecord["added"] = added
        noteRecord["lastUpdate"] = lastUpdate
        
        return noteRecord
    }
    
    func updateWithRecord(record: CKRecord) {
        text = record["text"] as? String
        added = record["added"] as? NSDate
        lastUpdate = record["lastUpdate"] as? NSDate
        recordName = record.recordID.recordName
        recordID = NSKeyedArchiver.archivedDataWithRootObject(record.recordID)
        
        if car == .None && truck == .None && bus == .None {
            // need to set the parent object based on the CKReference in the record
            // will include the code for this later in the post
            // setParentObjectFromCloudKitRecord(record)
        }
    }
    
    func createNoteRecord() -> CKRecord {
        // we need to figure out what type of object the parent is
        if let car = car as? CloudKitManagedObject {
            return createNoteRecordWithParent(car)
        }
        else if let truck = truck as? CloudKitManagedObject {
            return createNoteRecordWithParent(truck)
        }
        else if let bus = bus as? CloudKitManagedObject {
            return createNoteRecordWithParent(bus)
        }
        else {
            fatalError("ERROR Have a note without a parent")
        }
    }
    
    func createNoteRecordWithParent(parentObject: CloudKitManagedObject) -> CKRecord {
        guard let cloudKitZone = CloudKitZone(recordType: parentObject.recordType),
              let parentRecordID = parentObject.cloudKitRecordID() else {
            fatalError("ERROR - not enough information to create a CKReference for a Note")
        }
        
        let noteRecord = cloudKitRecord(nil, parentRecordZoneID: cloudKitZone.recordZoneID())
        let parentReference = CKReference(recordID: parentRecordID, action: .DeleteSelf)
        
        recordName = noteRecord.recordID.recordName
        recordID = NSKeyedArchiver.archivedDataWithRootObject(noteRecord.recordID)
        
        noteRecord["parent"] = parentReference
        
        return noteRecord
    }
}

 

Dealing with the Core Data relationship is the reason its so much bigger. A relationship in Core Data is analogous to a CKReference. As I stated previously, I created the Core Data relationship to cascade on delete meaning that if I delete a Car, any and all Notes associated with that Car will also be deleted. CKReference can do the same. If the reference is created with the CKReferenceAction of DeleteSelf, the record will be deleted if its parent is deleted.

The important things to know about CKReference:
1. The records in the reference must be in the same zone
2. The reference is stored as a property on the child record and not the parent (in this case the Note) 

The reason I needed to pass in an optional parentRecordZoneID in the cloudKitRecord method should be more clear now. The Note must be saved in the same zone as its parent record so the code in Note figures out which relationship is not nil and uses that zone as the parent.

That’s it for the object specific code that translates my NSManagedObjects to CKRecords. Relationships/References make them more difficult but its easier to do a little code up front and have the cascading deletes handled for you on both sides.

CoreDataManager Save

With the translation details out of the way, lets look at how the Core Data changes make it to CloudKit. It starts with the save() method on the CoreDataManager. Initially I was passing the actual NSManagedObject sets into the CloudKitManager. This works but its not a good idea. It assumes the mainThreadManagedObjectContext will always be around. If I ever refactored the code to move the calls in my CloudKitManager operations into a background task when the app moves to the background, I would run into problems with the NSManagedObjects having nil properties. Instead it needs to pass NSManagedObjectIDs and let the operations create their own child NSManagedObjectContext to do the work within the operation.

With that said, the code in the CoreDataManager looks like this:

func save() {
        
    let insertedObjects = mainThreadManagedObjectContext.insertedObjects
    let modifiedObjects = mainThreadManagedObjectContext.updatedObjects
    let deletedRecordIDs = mainThreadManagedObjectContext.deletedObjects.flatMap { ($0 as? CloudKitManagedObject)?.cloudKitRecordID() }
        
    if privateObjectContext.hasChanges || mainThreadManagedObjectContext.hasChanges {
            
        self.mainThreadManagedObjectContext.performBlockAndWait {
            [unowned self] in
                
            do {
                try self.mainThreadManagedObjectContext.save()
                self.savePrivateObjectContext()
            }
            catch let error as NSError {
                fatalError("CoreDataManager - SAVE MANAGEDOBJECTCONTEXT FAILED: \(error.localizedDescription)")
            }
                
            let insertedManagedObjectIDs = insertedObjects.flatMap { $0.objectID }
            let modifiedManagedObjectIDs = modifiedObjects.flatMap { $0.objectID }
              
            self.cloudKitManager?.saveChangesToCloudKit(insertedManagedObjectIDs, modifiedManagedObjectIDs: modifiedManagedObjectIDs, deletedRecordIDs: deletedRecordIDs)
        }
    }
}

 

The code first makes a copy of the insertedObjects. Inserted NSManagedObjects have a temporary objectID until they are saved into persistent storage. Using the temporary objectID in the operations will fail. Instead the code makes a copy then uses that after the savePrivateObjectContext() method is complete to get the real objectID of the new objects. ModifiedObjects are also copied off since the set on the mainThreadManagedObjectContext will be empty once the save is complete. The deletedRecordIDs array is created before hand as well since that set will also be empty after the save. After the save completes the code gets the objectID’s it needs and passes them to the saveChangesToCloudKit method.

Save to CloudKit Operations

Saving to CloudKit is done in three operations which perform the following tasks:

1. Create CKRecords for inserted objects
2. Fetch CKRecords from CloudKit for modified objects
3. Send the local changes along with deleted CKRecordIDs to CloudKit

Create CKRecords

The operation to create new CKRecords looks like this:

class CreateRecordsForNewObjectsOperation: NSOperation {
 
    var createdRecords: [CKRecord]?
    private let insertedManagedObjectIDs: [NSManagedObjectID]
    private let coreDataManager: CoreDataManager
    
    init(insertedManagedObjectIDs: [NSManagedObjectID], coreDataManager: CoreDataManager) {
        
        self.insertedManagedObjectIDs = insertedManagedObjectIDs
        self.coreDataManager = coreDataManager
        self.createdRecords = nil
        super.init()
    }
    
    override func main() {
        
        let managedObjectContext = coreDataManager.createBackgroundManagedContext()
        
        if insertedManagedObjectIDs.count > 0 {
            managedObjectContext.performBlockAndWait {
                [unowned self] in
                
                let insertedCloudKitObjects = self.coreDataManager.fetchCloudKitManagedObjects(managedObjectContext, managedObjectIDs: self.insertedManagedObjectIDs)
                self.createdRecords = insertedCloudKitObjects.flatMap() { $0.managedObjectToRecord(.None) }
                self.coreDataManager.saveBackgroundManagedObjectContext(managedObjectContext)
            }
        }
    }
}

 

It uses a helper method on the CoreDataManager which fetches NSManagedObjects on a specific context by their NSManagedObjectID and casts them to CloudKitManagedObjects if they conform to the protocol:

// MARK: Fetch CloudKitManagedObjects from Context by NSManagedObjectID
func fetchCloudKitManagedObjects(managedObjectContext: NSManagedObjectContext, managedObjectIDs: [NSManagedObjectID]) -> [CloudKitManagedObject] {
        
    var cloudKitManagedObjects: [CloudKitManagedObject] = []
    for managedObjectID in managedObjectIDs {
        do {
            let managedObject = try managedObjectContext.existingObjectWithID(managedObjectID)
                
            if let cloudKitManagedObject = managedObject as? CloudKitManagedObject {
                cloudKitManagedObjects.append(cloudKitManagedObject)
            }
        }
        catch let error as NSError {
            print("Error fetching from CoreData: \(error.localizedDescription)")
        }
    }
        
    return cloudKitManagedObjects
}

 

The operation itself is hopefully straight forward:

1. Takes in an array of NSManagedObjectID’s
2. Fetches all the conform to the CloudKitManagedObject protocol
3. Call managedObjectToRecord method on each of the fetched objects using flatMap and assign the resulting array of CKRecords to createdRecords
4. Save the child managedContext since the objects will now have a recordName and recordID

All of this work is based off the CloudKitManagedObject protocol. It doesn’t care that type of object it is as long as it conforms. Nice and generic!

Important Note: The managedObjectContext MUST be created in main() and not init(). This ensures its on the correct thread when the operation executes.

Fetch CKRecords

Modified records first need to be fetched from CloudKit before being updated. This drives me nuts but everything I read and everything I tried leads me to believe its true. I’d love to know if there’s a way to modify objects another way. The extra network call seems like such a waste just to get server specific meta data.

Anyway… the operation for fetching modified records:

class FetchRecordsForModifiedObjectsOperation: CKFetchRecordsOperation {
 
    var fetchedRecords: [CKRecordID : CKRecord]?
    private let coreDataManager: CoreDataManager
    private let modifiedManagedObjectIDs: [NSManagedObjectID]?
    
    init(coreDataManager: CoreDataManager, modifiedManagedObjectIDs: [NSManagedObjectID]) {
        
        self.coreDataManager = coreDataManager
        self.modifiedManagedObjectIDs = modifiedManagedObjectIDs
        
        super.init()
        
        // setup the CKFetchRecordsOperation blocks
        setOperationBlocks()
    }
    
    override func main() {
        
        print("FetchRecordsForModifiedObjectsOperation.main")
        
        let managedObjectContext = coreDataManager.createBackgroundManagedContext()
        
        managedObjectContext.performBlockAndWait {
            
            if let modifiedManagedObjectIDs = self.modifiedManagedObjectIDs {
                let modifiedCloudKitObjects = self.coreDataManager.fetchCloudKitManagedObjects(managedObjectContext, managedObjectIDs: modifiedManagedObjectIDs)
                self.recordIDs = modifiedCloudKitObjects.flatMap { $0.cloudKitRecordID() }
            }
            
            super.main()
        }
    }
    
    private func setOperationBlocks() {
        
        fetchRecordsCompletionBlock = {
            [unowned self]
            (fetchedRecords: [CKRecordID : CKRecord]?, error: NSError?) -> Void in
            
            self.fetchedRecords = fetchedRecords
            print("FetchRecordsForModifiedObjectsOperation.fetchRecordsCompletionBlock - fetched \(fetchedRecords?.count) records")
        }
    }
}

 

It’s a subclass of the CKFetchRecordsOperation for the same reason I mentioned before. It also uses the same CoreDataManager helper function to get the modified objects from their NSManagedObjectID’s.

Modify Records

Modify records is also a subclass of a CloudKit operation – CKModifyRecordsOperation:

class ModifyRecordsFromManagedObjectsOperation: CKModifyRecordsOperation {
    
    var fetchedRecordsToModify: [CKRecordID : CKRecord]?
    private let modifiedManagedObjectIDs: [NSManagedObjectID]?
    private let coreDataManager: CoreDataManager
    private let cloudKitManager: CloudKitManager
    
    init(coreDataManager: CoreDataManager, cloudKitManager: CloudKitManager, modifiedManagedObjectIDs: [NSManagedObjectID], deletedRecordIDs: [CKRecordID]) {
        
        // save off the modified objects and the fetch operation
        self.coreDataManager = coreDataManager
        self.cloudKitManager = cloudKitManager
        self.modifiedManagedObjectIDs = modifiedManagedObjectIDs
        self.fetchedRecordsToModify = nil
        
        super.init()
        
        // get the recordIDs for deleted objects
        recordIDsToDelete = deletedRecordIDs
    }
    
    override func main() {
        
        print("ModifyRecordsFromManagedObjectsOperation.main")
        
        // setup the CKFetchRecordsOperation blocks
        setOperationBlocks()
        
        let managedObjectContext = coreDataManager.createBackgroundManagedContext()
        
        managedObjectContext.performBlockAndWait {
 
            let modifiedRecords: [CKRecord]
 
            if let modifiedManagedObjectIDs = self.modifiedManagedObjectIDs {
                modifiedRecords = self.modifyFetchedRecordsIDs(managedObjectContext, modifiedManagedObjectIDs: modifiedManagedObjectIDs)
            }
            else {
                modifiedRecords = []
            }
            
            if modifiedRecords.count > 0 {
                if self.recordsToSave == nil {
                    self.recordsToSave = modifiedRecords
                }
                else {
                    self.recordsToSave?.appendContentsOf(modifiedRecords)
                }
            }
            
            print("ModifyRecordsFromManagedObjectsOperation.recordsToSave: \(self.recordsToSave)")
            print("ModifyRecordsFromManagedObjectsOperation.recordIDsToDelete: \(self.recordIDsToDelete)")
            
            super.main()
        }
    }
    
    private func modifyFetchedRecordsIDs(managedObjectContext: NSManagedObjectContext, modifiedManagedObjectIDs: [NSManagedObjectID]) -> [CKRecord] {
        
        guard let fetchedRecords = fetchedRecordsToModify else {
            return []
        }
        
        var modifiedRecords: [CKRecord] = []
        
        let modifiedManagedObjects = coreDataManager.fetchCloudKitManagedObjects(managedObjectContext, managedObjectIDs: modifiedManagedObjectIDs)
        for cloudKitManagedObject in modifiedManagedObjects {
            if let recordID = cloudKitManagedObject.cloudKitRecordID(),
               let record = fetchedRecords[recordID] {
                modifiedRecords.append(cloudKitManagedObject.managedObjectToRecord(record))
            }
        }
        
        return modifiedRecords
    }
 
   

private func setOperationBlocks() {

        

        perRecordCompletionBlock = {

            (record:CKRecord?, error:NSError?) -> Void in

            

            if let error = error {

                print(“ModifyRecordsFromManagedObjectsOperation.perRecordCompletionBlock error: \(error)”)

            }

            else {

                print(“Record modification successful for recordID: \(record?.recordID)”)

            }

        }

        

        modifyRecordsCompletionBlock = {

            [unowned self]

            (savedRecords: [CKRecord]?, deletedRecords: [CKRecordID]?, error:NSError?) -> Void in

            

            if let error = error {

                print(“ModifyRecordsFromManagedObjectsOperation.modifyRecordsCompletionBlock error: \(error)”)

            }

            else if let deletedRecords = deletedRecords {

                for recordID in deletedRecords {

                    print(“DELETED: \(recordID)”)

                }

            }

            self.cloudKitManager.lastCloudKitSyncTimestamp = NSDate()

            print(“ModifyRecordsFromManagedObjectsOperation modifyRecordsCompletionBlock”)

        }

    }

}

 

This operation follows along with the patterns used in the others so I won’t go into much detail expect to point out the call the managedObjectToRecord passes in the record that was fetched from CloudKit instead of .None which was used when creating new records.

Chaining the Operations

Finally here’s the saveChangesToCloudKit function which chains the operations together:

func saveChangesToCloudKit(insertedObjects: [NSManagedObjectID], modifiedManagedObjectIDs: [NSManagedObjectID], deletedRecordIDs: [CKRecordID]) {
 
    // create the operations
    let createRecordsForNewObjectsOperation = CreateRecordsForNewObjectsOperation(insertedManagedObjectIDs: insertedObjects, coreDataManager: coreDataManager)
    let fetchModifiedRecordsOperation = FetchRecordsForModifiedObjectsOperation(coreDataManager: coreDataManager, modifiedManagedObjectIDs: modifiedManagedObjectIDs)
    let modifyRecordsOperation = ModifyRecordsFromManagedObjectsOperation(coreDataManager: coreDataManager, cloudKitManager: self, modifiedManagedObjectIDs: modifiedManagedObjectIDs, deletedRecordIDs: deletedRecordIDs)
        
    let transferCreatedRecordsOperation = NSBlockOperation() {
        [unowned modifyRecordsOperation, unowned createRecordsForNewObjectsOperation] in
            
        modifyRecordsOperation.recordsToSave = createRecordsForNewObjectsOperation.createdRecords
    }
        
    let transferFetchedRecordsOperation = NSBlockOperation() {
        [unowned modifyRecordsOperation, unowned fetchModifiedRecordsOperation] in
            
        modifyRecordsOperation.fetchedRecordsToModify = fetchModifiedRecordsOperation.fetchedRecords
    }
        
    // setup dependencies
    transferCreatedRecordsOperation.addDependency(createRecordsForNewObjectsOperation)
    transferFetchedRecordsOperation.addDependency(fetchModifiedRecordsOperation)
    modifyRecordsOperation.addDependency(transferCreatedRecordsOperation)
    modifyRecordsOperation.addDependency(transferFetchedRecordsOperation)
        
    // add the operations to the queue
    operationQueue.addOperation(createRecordsForNewObjectsOperation)
    operationQueue.addOperation(transferCreatedRecordsOperation)
    operationQueue.addOperation(fetchModifiedRecordsOperation)
    operationQueue.addOperation(transferFetchedRecordsOperation)
    operationQueue.addOperation(modifyRecordsOperation)
}

 

This is the first example in the app of a single operation having multiple dependencies. ModifyRecordsOperation needs data from both the createRecordsForNewObjectsOperation and the fetchModifiedRecords operation. The good thing is that the way the dependencies are setup means that both the create and fetch can execute at the same time if the system allows it. That’s the power of NSOperation dependencies!

Saving from CloudKit to Core Data using CKSubscriptions

I mentioned earlier that I decided to create a CKSubscriptions per zone in order to get push notifications whenever something in a zone changes on a different device. I also described how I setup those subscriptions when initializing my CloudKitManager. The next thing to look at is the code in the AppDelegate that handles the push notification. I say push notification but technically its a remote notification in the documentation. You’ll need to include the remote notification background mode in your apps capabilities. If you want on screen notifications you’ll need to enable those too as well as prompt the user for the permission. I won’t get into all that but you can read the documentation here.

It’s important to note that the CKNotificationInfo object I created with each subscription sets the shouldSendContentAvailable flag to true. The operating system will only wake your app from the background if that flag is true:

func notificationInfo() -> CKNotificationInfo {
        
    let notificationInfo = CKNotificationInfo()
    notificationInfo.alertBody = "Subscription notification for \(self.rawValue)"
    notificationInfo.shouldSendContentAvailable = true
    notificationInfo.shouldBadge = false
    return notificationInfo
}

Here’s the code in my AppDelegate:

// MARK: remote notifications
func application(application: UIApplication, didReceiveRemoteNotification userInfo: [NSObject : AnyObject], fetchCompletionHandler completionHandler: (UIBackgroundFetchResult) -> Void) {
    print("application.didReceiveRemoteNotification")
        
    if let stringObjectUserInfo = userInfo as? [String : NSObject] {
        let cloudKitZoneNotificationUserInfo = CKRecordZoneNotification(fromRemoteNotificationDictionary: stringObjectUserInfo)
            
        if let recordZoneID = cloudKitZoneNotificationUserInfo.recordZoneID {
                
            let completionBlockOperation = NSBlockOperation {
                completionHandler(UIBackgroundFetchResult.NewData)
            }
                
            self.cloudKitManager?.syncZone(recordZoneID.zoneName, completionBlockOperation: completionBlockOperation)
        }
    }
    else {
        completionHandler(UIBackgroundFetchResult.NoData)
    }
}
 

The first thing this code does is cast the userInfo into a [String : NSObject] dictionary. Next it passes that dictionary to the CKRecordZoneNotification init method which will do the hard work of reading all the values in the dictionary and set the appropriate properties on the CKRecordZoneNotification object. Using that object I can get the recordZoneID that triggered the notification. 

The next part creates an NSBlockOperation in order to call the completionHandler. Its very important to call the completionHandler. If you don’t the operating system will eventually stop processing remote notifications for your app. I’m doing this in an NSBlockOperation so that I can easily add it to the end of my operation chain. Finally it calls syncZone on the CloudKitManager.

Operations

I mentioned before the power of CKFetchRecordChangesOperation and serverChangeTokens. Unfortunately I ran into the same issue as other CloudKit operations where I didn’t have a good way to get the fetched changes once the operation completed and again had to subclass.

class FetchRecordChangesForCloudKitZoneOperation: CKFetchRecordChangesOperation {
    
    var changedRecords: [CKRecord]
    var deletedRecordIDs: [CKRecordID]
    var operationError: NSError?
    private let cloudKitZone: CloudKitZone
    
    init(cloudKitZone: CloudKitZone) {
        
        self.cloudKitZone = cloudKitZone
        
        self.changedRecords = []
        self.deletedRecordIDs = []
        
        super.init()
        self.recordZoneID = cloudKitZone.recordZoneID()
        self.previousServerChangeToken = getServerChangeToken(cloudKitZone)
    }
    
    override func main() {
        
        print("FetchCKRecordChangesForCloudKitZoneOperation.main() - \(previousServerChangeToken)")
        setBlocks()
        super.main()
    }
    
    // MARK: Set operation blocks
    func setBlocks() {
        
        recordChangedBlock = {
            [unowned self]
            (record: CKRecord) -> Void in
            
            print("Record changed: \(record)")
            self.changedRecords.append(record)
        }
        
        recordWithIDWasDeletedBlock = {
            [unowned self]
            (recordID: CKRecordID) -> Void in
            
            print("Record deleted: \(recordID)")
            self.deletedRecordIDs.append(recordID)
        }
        
        fetchRecordChangesCompletionBlock = {
            [unowned self]
            (serverChangeToken: CKServerChangeToken?, clientChangeToken: NSData?, error: NSError?) -> Void in
            
            if let operationError = error {
                print("SyncRecordChangesToCoreDataOperation resulted in an error: \(error)")
                self.operationError = operationError
            }
            else {
                self.setServerChangeToken(self.cloudKitZone, serverChangeToken: serverChangeToken)
            }
        }
    }
 
    // MARK: Change token user default methods
    func getServerChangeToken(cloudKitZone: CloudKitZone) -> CKServerChangeToken? {
        
        let encodedObjectData = NSUserDefaults.standardUserDefaults().objectForKey(cloudKitZone.serverTokenDefaultsKey()) as? NSData
        
        if let encodedObjectData = encodedObjectData {
            return NSKeyedUnarchiver.unarchiveObjectWithData(encodedObjectData) as? CKServerChangeToken
        }
        else {
            return nil
        }
    }
    
    func setServerChangeToken(cloudKitZone: CloudKitZone, serverChangeToken: CKServerChangeToken?) {
        
        if let serverChangeToken = serverChangeToken {
            NSUserDefaults.standardUserDefaults().setObject(NSKeyedArchiver.archivedDataWithRootObject(serverChangeToken), forKey:cloudKitZone.serverTokenDefaultsKey())
        }
        else {
            NSUserDefaults.standardUserDefaults().setObject(nil, forKey:cloudKitZone.serverTokenDefaultsKey())
        }
    }
}

 

The operation includes an array of CKRecords that changed as well as an array of CKRecordIDs that were deleted. As the blocks are called the information is appended to the appropriate arrays. These blocks are called serially so its safe to use the arrays without needing to lock them.

The power of this operation is the serverChangeToken. Each zone needs to keep its own serverChangeToken. I decided to stash these in NSUserDefatuls and added the methods to get and set them directly in this operation since its the only placed they’re used.

One thing my proof of concept app does not do is handle the moreComing property. I’ve noticed though that after deleting and reinstalling the app I’ll get deletes for the first few calls to each zone. It makes sense and in production I will pay attention to that flag and continue to call syncZone until its done. I think it would be better to go ahead and process the changes returned and save them to core data instead of waiting for all of them to be downloaded.

The only other operation is my custom SavedChangedRecordsToCoreDataOperation:

class SaveChangedRecordsToCoreDataOperation: NSOperation {
    
    var changedRecords: [CKRecord]
    var deletedRecordIDs: [CKRecordID]
    private var rootRecords: [CKRecord]
    private var noteRecords: [CKRecord]
    private let coreDataManager: CoreDataManager
    
 
    init(coreDataManager: CoreDataManager) {
        
        // set the coreDataManager here
        self.coreDataManager = coreDataManager
        
        // set the default values
        self.changedRecords = []
        self.deletedRecordIDs = []
        self.rootRecords = []
        self.noteRecords = []
    }
    
    override func main() {
        
        print("SaveChangedRecordsToCoreDataOperation()")
        
        // this is where we set the correct managedObjectContext
        let managedObjectContext = self.coreDataManager.createBackgroundManagedContext()
        
        managedObjectContext.performBlockAndWait {
            [unowned self] in
            
            // loop through changed records and filter our child records
            for record in self.changedRecords {
                if let modelObjecType = ModelObjectType(rawValue: record.recordType) {
                    
                    if modelObjecType == ModelObjectType.Note {
                        self.noteRecords.append(record)
                    }
                    else {
                        self.rootRecords.append(record)
                    }
                }
            }
            
            // loop through all the changed root records first and insert or update them in core data
            for record in self.rootRecords {
                self.saveRecordToCoreData(record, managedObjectContext: managedObjectContext)
            }
            
            // loop through all the changed child records next and insert or update them in core data
            for record in self.noteRecords {
                self.saveRecordToCoreData(record, managedObjectContext: managedObjectContext)
            }
            
            // loop through all the deleted recordIDs and delete the objects from core data
            for recordID in self.deletedRecordIDs {
                self.deleteRecordFromCoreData(recordID, managedObjectContext: managedObjectContext)
            }
            
            // save the context
            self.coreDataManager.saveBackgroundManagedObjectContext(managedObjectContext)
        }
    }
    
    private func saveRecordToCoreData(record: CKRecord, managedObjectContext: NSManagedObjectContext) {
        
        print("saveRecordToCoreData: \(record.recordType)")
        let fetchRequest = createFetchRequest(record.recordType, recordName: record.recordID.recordName)
        
        if let cloudKitManagedObject = fetchObject(fetchRequest, managedObjectContext: managedObjectContext) {
            print("UPDATE CORE DATA OBJECT")
            cloudKitManagedObject.updateWithRecord(record)
        }
        else {
            print("NEW CORE DATA OBJECT")
            let cloudKitManagedObject = createNewCloudKitManagedObject(record.recordType, managedObjectContext: managedObjectContext)
            cloudKitManagedObject.updateWithRecord(record)
        }
    }
    
    private func createFetchRequest(entityName: String, recordName: String) -> NSFetchRequest {
        
        let fetchRequest = NSFetchRequest(entityName: entityName)
        fetchRequest.predicate = NSPredicate(format: "recordName LIKE[c] %@", recordName)
        
        return fetchRequest
    }
    
    private func fetchObject(fetchRequest: NSFetchRequest, managedObjectContext: NSManagedObjectContext) -> CloudKitManagedObject? {
        
        do {
            let fetchResults = try managedObjectContext.executeFetchRequest(fetchRequest)
            
            guard fetchResults.count <= 1 else {
                fatalError("ERROR: Found more then one core data object with recordName")
            }
            
            if fetchResults.count == 1 {
                return fetchResults[0] as? CloudKitManagedObject
            }
        }
        catch let error as NSError {
            print("Error fetching from CoreData: \(error.localizedDescription)")
        }
        
        return nil
    }
    
    private func createNewCloudKitManagedObject(entityName: String, managedObjectContext: NSManagedObjectContext) -> CloudKitManagedObject {
        
        guard let newCloudKitManagedObject = NSEntityDescription.insertNewObjectForEntityForName(entityName, inManagedObjectContext: managedObjectContext) as? CloudKitManagedObject else {
            fatalError("CloudKitManager: could not create object")
        }
        
        return newCloudKitManagedObject
    }
    
    private func deleteRecordFromCoreData(recordID: CKRecordID, managedObjectContext: NSManagedObjectContext) {
        
        let entityName = entityNameFromRecordName(recordID.recordName)
        let fetchRequest = createFetchRequest(entityName, recordName: recordID.recordName)
        
        if let cloudKitManagedObject = fetchObject(fetchRequest, managedObjectContext: managedObjectContext) {
            print("DELETE CORE DATA OBJECT: \(cloudKitManagedObject)")
            managedObjectContext.deleteObject(cloudKitManagedObject as! NSManagedObject)
        }
    }
    
    private func entityNameFromRecordName(recordName: String) -> String {
        
        guard let index = recordName.characters.indexOf(".") else {
            fatalError("ERROR - RecordID.recordName does not contain an entity prefix")
        }
        
        let entityName = recordName.substringToIndex(index)
        
        guard let managedObjectType = ModelObjectType(rawValue: entityName) else {
            fatalError("ERROR - unknown managedObjectType: \(entityName)")
        }
        
        return managedObjectType.rawValue
    }
}
 

There are a couple of things to discuss with this operation.

First is that the array of CKRecords is not ordered in a way that allows the operation to be guaranteed that a parent object already exists in Core Data before its relationship object is saved. To get around this the code looks for any Note objects and separates them into their own array which is saved to Core Data once the rest of the objects are saved. I’d like to find a better approach to this problem. The proof of concept app doesn’t have a big relationship graph but I see this approach getting even more messy with each layer of relationships added to the data model. For now this works though.

The second is how the deletes work. As I stated before, I’m prepending the entityName of an object to a UUID when creating its recordName. When processing deleted CKRecordID’s, I can split out the entityName from the recordName and know how to build my NSFetchRequest. Again, this might not be the best approach but it works. I’d love to hear other suggestions!

Operation Chaining

I queue the operations for syncing a zone in two functions. The reason will be explained in the next section about doing a full sync.

The syncZone() method called from the AppDelegate:

func syncZone(zoneName: String, completionBlockOperation: NSBlockOperation) {
        
    if let cloudKitZone = CloudKitZone(rawValue: zoneName) {
            
        // suspend the queue so nothing finishes before all our dependencies are setup
        operationQueue.suspended = true
            
        // queue up the change operations for a zone
        let saveChangedRecordsToCoreDataOperation = queueChangeOperationsForZone(cloudKitZone)
            
        // add our completion block to the queue as well to handle background fetches
        completionBlockOperation.addDependency(saveChangedRecordsToCoreDataOperation)
        operationQueue.addOperation(completionBlockOperation)
            
        // let the queue begin firing again
        operationQueue.suspended = false
    }
}
 

It’s a pretty simple function. I suspend the NSOperationQueue first so that I can guarantee all of the operations have their dependencies set before any execute. I use a separate function to queue up the fetch and save changes operations. The function passes back the save changes operation. I do that so I can set it as a dependency on the completionBlockOperation passed in from the AppDelegate. Finally it lets the queue begin processing again.

The queueChangeOperationsForZone function which at this point should be straight forward:

private func queueChangeOperationsForZone(cloudKitZone: CloudKitZone) -> SaveChangedRecordsToCoreDataOperation {
        
    // there are two operations that need to be chained together for each zone
    // the first is to fetch record changes
    // the second is to save those changes to CoreData
    // we'll also need a block operation to transfer data between them
    let fetchRecordChangesOperation = FetchRecordChangesForCloudKitZoneOperation(cloudKitZone: cloudKitZone)
    let saveChangedRecordsToCoreDataOperation = SaveChangedRecordsToCoreDataOperation(coreDataManager: coreDataManager)
        
    let dataTransferOperation = NSBlockOperation() {
        [unowned saveChangedRecordsToCoreDataOperation, unowned fetchRecordChangesOperation] in
            
        print("addChangeOperationsForZone.dataTransferOperation")
        saveChangedRecordsToCoreDataOperation.changedRecords = fetchRecordChangesOperation.changedRecords
        saveChangedRecordsToCoreDataOperation.deletedRecordIDs = fetchRecordChangesOperation.deletedRecordIDs
    }
        
    // set the dependencies
    dataTransferOperation.addDependency(fetchRecordChangesOperation)
    saveChangedRecordsToCoreDataOperation.addDependency(dataTransferOperation)
        
    // add the operations to the queue
    operationQueue.addOperation(fetchRecordChangesOperation)
    operationQueue.addOperation(dataTransferOperation)
    operationQueue.addOperation(saveChangedRecordsToCoreDataOperation)
        
    return saveChangedRecordsToCoreDataOperation
}

 

Next Post – Syncing Data >

 

Advertisements

Written by Nick Harris

February 9, 2016 at 6:53 am

Posted in Uncategorized

4 Responses

Subscribe to comments with RSS.

  1. […] Next Post – Saving Data > […]

  2. “I didn’t have a good way to get the fetched changes once the operation completed” In ObjC you can just mark a mutable array as __block outside and then add to it in the recordChangedBlock. Must be a similar thing in Swift.

    highflyingtv

    April 5, 2016 at 10:32 am

  3. https://developer.apple.com/reference/cloudkit?language=objc says “The classes of the CloudKit framework are not meant to be subclassed.” where does that leave us?

    Malcolm Hall

    January 13, 2017 at 11:52 am

  4. “Modified records first need to be fetched from CloudKit before being updated. This drives me nuts but everything I read and everything I tried leads me to believe its true. I’d love to know if there’s a way to modify objects another way”:
    https://developer.apple.com/library/content/documentation/DataManagement/Conceptual/CloudKitQuickStart/MaintainingaLocalCacheofCloudKitRecords/MaintainingaLocalCacheofCloudKitRecords.html des ribes how to store the CKRecord’s meta data in the cached object. This allows you to create a CKRecord from this meta data each time before sending it to ClodKit for updating it.
    In case that the same record has meanwhile been updated from another device the callback will receive an error with the
    – Record content you chched before updating it
    – Record content after updating by the other device
    – Record content you tried to store right now
    This allows you to decide in the code whether to update the cache with the last content received from CloudKit or to update that record with your changes.
    Thos way you don’t need to fetch the records before updating them and have round trips only with conflicting changes.

    Andreas R.

    July 15, 2017 at 11:03 am


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: