Nick Harris

CloudKit + Core Data + NSOperations – Syncing

with one comment

This is post three 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.

Syncing Both CloudKit and Core Data Changes Together

So far I’ve talked about getting changes from Core Data and using operations to save them to CloudKit. I’ve also talked about using CKSubscriptions to get changes from CloudKit and save them to Core Data. But what happens if changes are made offline? Or if the a remote notification is never delivered (remember they’re not guaranteed)? These situations need to be handled as well and the process to do this is much more involved then anything else I’ve talked about.

I was pleasantly surprised to see that CloudKit operations will pause until the app comes back online. With the dependencies that also means that nothing in the chain will execute as well. Its not a full proof plan and I wouldn’t trust it. I’d be interested to hear what others think. Should the NSOperationQueue remove all pending operations when network connectivity changes are detected? How about when the app moves to the background? Let me know your thoughts.

Offline Deletes

Offline deletes are the most difficult. Once the Core Data persistent store saves the delete the object is gone forever. To handle this I added another entity to my Core Data model called DeletedCloutKitObject:

Deleted Cloud Kit Entity

extension DeletedCloudKitObject {
 
    @NSManaged var recordType: String?
    @NSManaged var recordID: NSData?
 
}
 

class DeletedCloudKitObject: NSManagedObjectCloudKitRecordIDObject {

 

}

 

Previously I wrote about separating the CloudKitRecordIDObject protocol and its extension away from the CloudKitManagedObject protocol. This is the reason why. I still need the functionality of getting a recordID from NSData in Core Data but I don’t want this Core Data entity synced to CloudKit. Because I use the CloudKitManagedObject protocol to handle all of the Core Data to CloudKit changes, these delete entities get filtered out by using flatMap while performing the casting of NSManagedObjects to CloudKitManagedObjects. This is the kind of flexibility I really like with protocols over subclasses!

The idea behind this entity is to keep a temporary store of CloudKitManagedObjects when they get deleted, then clear them all when a save to CloudKit is completed. Any CloudKitManagedObject should be able to do this so I added another function to the CloudKitManagedObject extension:

func addDeletedCloudKitObject() {
        
    if let managedObject = self as? NSManagedObject,
        let managedObjectContext = managedObject.managedObjectContext,
        let recordID = recordID,
        let deletedCloudKitObject = NSEntityDescription.insertNewObjectForEntityForName("DeletedCloudKitObject", inManagedObjectContext: managedObjectContext) as? DeletedCloudKitObject {
            deletedCloudKitObject.recordID = recordID
            deletedCloudKitObject.recordType = recordType
    }
}

This made adding the objects on delete user actions really easy both in the TableViewControllers and DetailViewControllers. Here’s the code for the ObjectTableViewController as an example:

override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {

    if editingStyle == .Delete {

        if let managedObject = fetchedResultsController?.objectAtIndexPath(indexPath) asNSManagedObject {

                

            if let cloudKitManagedObject = managedObject asCloudKitManagedObject {

                cloudKitManagedObject.addDeletedCloudKitObject()

            }

                

            coreDataManager?.mainThreadManagedObjectContext.deleteObject(managedObject)

            coreDataManager?.save()

        }

    }

}

 

Deleting all these entities after a successful save to CloudKit gave me the opportunity to finally use a Core Data batch request. In the past you would need to fetch all of the entities then delete them one at a time. With a batch delete its much simpler. I added this to its own operation so that I could add it to the end of any operation chain:

class ClearDeletedCloudKitObjectsOperation: NSOperation {
    
    let coreDataManager: CoreDataManager
    
    init(coreDataManager: CoreDataManager) {
        
        self.coreDataManager = coreDataManager
        
        super.init()
    }
    
    override func main() {
        
        let managedObjectContext = coreDataManager.createBackgroundManagedContext()
        
        managedObjectContext.performBlockAndWait {
            [unowned self] in
            
            let fetchRequest = NSFetchRequest(entityName: ModelObjectType.DeletedCloudKitObject.rawValue)
            let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
            
            do {
                try managedObjectContext.executeRequest(deleteRequest)
            }
            catch let error as NSError {
                print("Error deleting from CoreData: \(error.localizedDescription)")
            }
            
            self.coreDataManager.saveBackgroundManagedObjectContext(managedObjectContext)
        }
    }
}

Offline Changes

Offline changes are a little easier. The main reason I added the lastUpdate property to my CloudKitManagedObjects was to know when something had changed after a certain point in time. There is a lastModified property on a CKRecord but I wanted something that I controlled. I also learned that you can have the same property in two different protocols. It makes sense but I was afraid the compiler might report some sort of conflict but its not an issue. Its why my CTBRootManagedObject protocol (used in the UI code) and my CloudKitManagedObject both have lastUpdate.

I decided the point in time to check against is after a successful modify CloudKit operation. I decided to also save this time stamp in NSUserDefaults but I added it to the CloudKitManager so that I could get to it from any operation by dependency injecting the CloudKitManager:

var lastCloudKitSyncTimestamp: NSDate {

        

    get {

        if let lastCloudKitSyncTimestamp = NSUserDefaults.standardUserDefaults().objectForKey(CloudKitUserDefaultKeys.LastCloudKitSyncTimestamp.rawValueasNSDate {

            return lastCloudKitSyncTimestamp

        }

        else {

            return NSDate.distantPast()

        }

    }

    set {

        NSUserDefaults.standardUserDefaults().setObject(newValue, forKey: CloudKitUserDefaultKeys.LastCloudKitSyncTimestamp.rawValue)

    }

}

 

The Process

Heres the full process now that I have a way of tracking offline changes and deletes:

1. Fetch changes from both Core Data and CloudKit
2. Process the changes from both Core Data and CloudKit
3. Fetch records from CloudKit that need to be modified with local changes
4. Modify records in CloudKit
5. Save changes to Core Data
6. Clear local cache of deleted objects 

Fetching Changes

Fetching changes from CloudKit is done using the FetchRecordChangesForZoneOperation I wrote about previously. Fetching local changes is done using a new FectchOfflineChangesFromCoreDataOperations:

class FetchOfflineChangesFromCoreDataOperation: NSOperation {
    
    var updatedManagedObjects: [NSManagedObjectID]
    var deletedRecordIDs: [CKRecordID]
    
    private let coreDataManager: CoreDataManager
    private let cloudKitManager: CloudKitManager
    private let entityNames: [String]
    
    init(coreDataManager: CoreDataManager, cloudKitManager: CloudKitManager, entityNames: [String]) {
        
        self.coreDataManager = coreDataManager
        self.cloudKitManager = cloudKitManager
        self.entityNames = entityNames
        
        self.updatedManagedObjects = []
        self.deletedRecordIDs = []
        
        super.init()
    }
 
    override func main() {
        
        let managedObjectContext = coreDataManager.createBackgroundManagedContext()
        
        managedObjectContext.performBlockAndWait {
            [unowned self] in
            
            let lastCloudKitSyncTimestamp = self.cloudKitManager.lastCloudKitSyncTimestamp
            
            for entityName in self.entityNames {
                self.fetchOfflineChangesForEntityName(entityName, lastCloudKitSyncTimestamp: lastCloudKitSyncTimestamp, managedObjectContext: managedObjectContext)
            }
            
            self.deletedRecordIDs = self.fetchDeletedRecordIDs(managedObjectContext)
        }
    }
    
    func fetchOfflineChangesForEntityName(entityName: String, lastCloudKitSyncTimestamp: NSDate, managedObjectContext: NSManagedObjectContext) {
        
        let fetchRequest = NSFetchRequest(entityName: entityName)
        fetchRequest.predicate = NSPredicate(format: "lastUpdate > %@", lastCloudKitSyncTimestamp)
        
        do {
            let fetchResults = try managedObjectContext.executeFetchRequest(fetchRequest)
            let managedObjectIDs = fetchResults.flatMap() { ($0 as? NSManagedObject)?.objectID
            
            updatedManagedObjects.appendContentsOf(managedObjectIDs)
        }
        catch let error as NSError {
            print("Error fetching from CoreData: \(error.localizedDescription)")
        }
    }
    
    func fetchDeletedRecordIDs(managedObjectContext: NSManagedObjectContext) -> [CKRecordID] {
        
        let fetchRequest = NSFetchRequest(entityName: ModelObjectType.DeletedCloudKitObject.rawValue)
        
        do {
            let fetchResults = try managedObjectContext.executeFetchRequest(fetchRequest)
            return fetchResults.flatMap() { ($0 as? DeletedCloudKitObject)?.cloudKitRecordID()  }
        }
        catch let error as NSError {
            print("Error fetching from CoreData: \(error.localizedDescription)")
        }
        
        return []
    }
}

 

The operation takes an array of entity names to compare lastUpdate properties on. To support this I added a static array of CloudKitManagedObject types to the ModelObjectType enum:

enum ModelObjectType: String {

    case Car = “Car”

    case Truck = “Truck”

    case Bus = “Bus”

    case Note = “Note”

    case DeletedCloudKitObject = “DeletedCloudKitObject”

    

    static let allCloudKitModelObjectTypes = [

        ModelObjectType.Car.rawValue,

        ModelObjectType.Truck.rawValue,

        ModelObjectType.Bus.rawValue,

        ModelObjectType.Note.rawValue

    ]

}

 

From there I can loop through all the entityNames and create fetch requests comparing lastUpdate to what I have stored in the lastCloudKitSyncTimestamp. Notice that I’m again saving off NSManagedObjectIDs instead of the objects themselves. In this case the managedObjectContext I’m using for the fetch will almost always be gone when the next operation executes.

Deletes with the DeletedCloudKitObject are easy. Just fetch them all and get their CKRecordID using the CloudKitRecordIDObject protocol extension.

Process Changes

I assumed that figuring out which changes need to go to CloudKit and which need to be saved in Core Data was going to be the most code intensive operation I was going to create. With the help of Sets in Swift it turned out to be less the 200 lines of code!

The main reason for that in this example though are the business rules around conflicts. Deletes always win. Last in always wins. More complex rules would mean more complex conflict resolution code.

class ProcessSyncChangesOperation: NSOperation {
 
    var preProcessLocalChangedObjectIDs: [NSManagedObjectID]
    var preProcessLocalDeletedRecordIDs: [CKRecordID]
    var preProcessServerChangedRecords: [CKRecord]
    var preProcessServerDeletedRecordIDs: [CKRecordID]
    
    var postProcessChangesToCoreData: [CKRecord]
    var postProcessChangesToServer: [CKRecord]
    var postProcessDeletesToCoreData: [CKRecordID]
    var postProcessDeletesToServer: [CKRecordID]
    
    private let coreDataManager: CoreDataManager
    private var changedCloudKitManagedObjects: [CloudKitManagedObject]
    
    init(coreDataManager: CoreDataManager) {
        
        self.coreDataManager = coreDataManager
        
        self.preProcessLocalChangedObjectIDs = []
        self.preProcessLocalDeletedRecordIDs = []
        self.preProcessServerChangedRecords = []
        self.preProcessServerDeletedRecordIDs = []
        
        self.postProcessChangesToCoreData = []
        self.postProcessChangesToServer = []
        self.postProcessDeletesToCoreData = []
        self.postProcessDeletesToServer = []
        
        self.changedCloudKitManagedObjects = []
        
        super.init()
    }
    
    override func main() {
        
        let managedObjectContext = coreDataManager.createBackgroundManagedContext()
        
        managedObjectContext.performBlockAndWait() {
            [unowned self] in
            
            print("------------------------------------------")
            print("preProcessLocalChangedObjectIDs: \(self.preProcessLocalChangedObjectIDs.count)")
            print("preProcessLocalDeletedRecordIDs: \(self.preProcessLocalDeletedRecordIDs.count)")
            print("preProcessServerChangedRecords: \(self.preProcessServerChangedRecords.count)")
            print("preProcessServerDeletedRecordIDs: \(self.preProcessServerDeletedRecordIDs.count)")
            print("------------------------------------------")
            
            // first we need CloudKitManagedObjects from NSManagedObjectIDs
            self.changedCloudKitManagedObjects = self.coreDataManager.fetchCloudKitManagedObjects(managedObjectContext, managedObjectIDs: self.preProcessLocalChangedObjectIDs)
            
            // deletes are the first thing we should process
            // anything deleted on the server should be removed from any local changes
            // anything deleted local should be removed from any server changes
            self.processServerDeletions(managedObjectContext)
            self.processLocalDeletions()
            
            // next process the conflicts
            self.processConflicts(managedObjectContext)
            
            // anything left in changedCloudKitManagedObjects needs to be added to postProcessChangesToServer
            let changedLocalRecords = self.changedCloudKitManagedObjects.flatMap { $0.managedObjectToRecord(nil) }
            self.postProcessChangesToServer.appendContentsOf(changedLocalRecords)
            
            // anything left in preProcessServerChangedRecords needs to be added to postProcessChangesToCoreData
            self.postProcessChangesToCoreData.appendContentsOf(self.preProcessServerChangedRecords)
            
            print("postProcessChangesToServer: \(self.postProcessChangesToServer.count)")
            print("postProcessDeletesToServer: \(self.postProcessDeletesToServer.count)")
            print("postProcessChangesToCoreData: \(self.postProcessChangesToCoreData.count)")
            print("postProcessDeletesToCoreData: \(self.postProcessDeletesToCoreData.count)")
            print("------------------------------------------")
            
            self.coreDataManager.saveBackgroundManagedObjectContext(managedObjectContext)
        }
    }
    
    // MARK: Process Deleted Objects
    func processServerDeletions(managedObjectContext: NSManagedObjectContext) {
 
        // anything deleted on the server needs to be removed from local change objects
        // and then added to the postProcessDeletesToCoreData array
        for deletedServerRecordID in preProcessServerDeletedRecordIDs {
            
            // do we have this record locally? We need to know so we can remove it from the changedCloudKitManagedObjects
            if let index = changedCloudKitManagedObjects.indexOf( { $0.recordName == deletedServerRecordID.recordName } ) {
                changedCloudKitManagedObjects.removeAtIndex(index)
            }
            
            // make sure to add it to the postProcessDeletesToCoreData array so we delete it from core data
            postProcessDeletesToCoreData.append(deletedServerRecordID)
        }
    }
    
    func processLocalDeletions() {
        
        // anything deleted locally needs to be removed from the server change objects
        // and also added to the postProcessDeletesToServer array
        
        for deletedLocalRecordID in preProcessLocalDeletedRecordIDs {
            
            if let index = preProcessServerChangedRecords.indexOf( { $0.recordID.recordName == deletedLocalRecordID.recordName} ) {
                preProcessServerChangedRecords.removeAtIndex(index)
            }
            
            // make sure to add it to the
            postProcessDeletesToServer.append(deletedLocalRecordID)
        }
    }
    
    // MARK: Process Conflicts
    func processConflicts(managedObjectContext: NSManagedObjectContext) {
        
        // make sets of the recordNames for both local and server changes
        let changedLocalRecordNamesArray = changedCloudKitManagedObjects.flatMap { $0.recordName }
        let changedServerRecordNamesArray = preProcessServerChangedRecords.flatMap { $0.recordID.recordName }
        let changedLocalRecordNamesSet = Set(changedLocalRecordNamesArray)
        let changedServerRecordNamesSet = Set(changedServerRecordNamesArray)
        
        // the interset of the sets are the recordNames we need to resolve conflicts with
        let conflictRecordNameSet = changedLocalRecordNamesSet.intersect(changedServerRecordNamesSet)
        
        for recordName in conflictRecordNameSet {
            resolveConflict(recordName, managedObjectContext: managedObjectContext)
        }
    }
    
    func resolveConflict(recordName: String, managedObjectContext: NSManagedObjectContext) {
        
        // only do the comparison if we have both objects. If we don't that's really bad
        guard let serverChangedRecordIndex = preProcessServerChangedRecords.indexOf( { $0.recordID.recordName == recordName } ),
            let localChangedObjectIndex = changedCloudKitManagedObjects.indexOf( { $0.recordName == recordName } ) else {
                fatalError("Could not find either the server record or local managed object to compare in conflict")
        }
        
        // get the objects from their respective arrays
        let serverChangedRecord = preProcessServerChangedRecords[serverChangedRecordIndex]
        let localChangedObject = changedCloudKitManagedObjects[localChangedObjectIndex]
        
        // also would be really bad if either of them don't have a lastUpdate property
        guard let serverChangedRecordLastUpdate = serverChangedRecord["lastUpdate"] as? NSDate,
              let localChangedObjectLastUpdate = localChangedObject.lastUpdate else {
            fatalError("Could not find either the server record or local managed object lastUpdate property to compare in conflict")
        }
    
        // we need to remove the change from their respective preProcess arrays so they don't end up there later in the process
        preProcessServerChangedRecords.removeAtIndex(serverChangedRecordIndex)
        changedCloudKitManagedObjects.removeAtIndex(localChangedObjectIndex)
        
        // finally we check which time stamp is newer
        if serverChangedRecordLastUpdate.compare(localChangedObjectLastUpdate) == NSComparisonResult.OrderedDescending {
            
            // server wins - add the record to those that will go to core data
            print("CONFLICT: \(recordName) - SERVER WINS. UPDATE COREDATA")
            postProcessChangesToCoreData.append(serverChangedRecord)
            
        } else if serverChangedRecordLastUpdate.compare(localChangedObjectLastUpdate) == NSComparisonResult.OrderedAscending {
            
            // local wins - add the NSManagedObjectID to those that will go to the server
            print("CONFLICT: \(recordName) - LOCAL WINS. UPDATE CLOUDKIT")
            postProcessChangesToServer.append(localChangedObject.managedObjectToRecord(serverChangedRecord))
            
        }
        else {
            // they're the same - we can just ignore these changes (curious how they would be the same ever though)
            print("CONFLICT: \(recordName) - SAME!! Will ignore")
        }
    }
}

 

The process in this operation:

1. Fetch all the NSManagedObjects I’ll need using my local managedObjectContext
2. Removed any object that was deleted either locally or in CloudKit from any changes made locally or in CloudKit. Deletes always wins.
3. Figure out which objects were updated both locally and in CloudKit.
4. Resolve those conflicts by using the lastUpdate timestamp. Last in always wins.
5. Move any local changes or CloudKit changes to their appropriate post process array for other operations in the chain. 

The first thing I’ll point out is that with Swift you can add a predicate for indexOf inline:

if let index = changedCloudKitManagedObjects.indexOf( { $0.recordName == deletedServerRecordID.recordName } ) {
    changedCloudKitManagedObjects.removeAtIndex(index)
}

 

The next is to again point out the power of Sets in swift compared to Objective-C. They’re much more powerful! The inclusion of the intersect function is fantastic.

// the interset of the sets are the recordNames we need to resolve conflicts with
let conflictRecordNameSet = changedLocalRecordNamesSet.intersect(changedServerRecordNamesSet)

 

Resolving any conflicts becomes pretty mundane once you have both objects. Whichever object has the newer lastUpdate timestamp wins. Obviously this assumes that the timestamp generated on different devices is correct instead of letting the server set it, but I think this is a fair assumption.

We now know which changes need to be saved where. The rest of the process relies on operations we already have but with some minor tweaks.

Fetch Modified Records from CloudKit

The FetchRecordsForModifiedObjectsOperation needs a little tweaking. First it needs a property for the CKRecords to fetch that can be set in a transfer operation. If that property is set then it should use it to set the recordIDs to fetch:

class FetchRecordsForModifiedObjectsOperation: CKFetchRecordsOperation {
 
    var fetchedRecords: [CKRecordID : CKRecord]?
    var preFetchModifiedRecords: [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()
    }
    
    init (coreDataManager: CoreDataManager) {
        self.coreDataManager = coreDataManager
        self.modifiedManagedObjectIDs = nil
        super.init()
    }
    
    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() }
            }
            else if let preFetchModifiedRecords = self.preFetchModifiedRecords {
                self.recordIDs = preFetchModifiedRecords.flatMap { $0.recordID }
            }
            
            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")
        }
    }
}
 

Send Modifications To Cloud Kit

The ModifyRecordsFromManagedObjects operation also needs a few simple changes. Initially it had NSManagedObjectIDs passed in based on changes to the mainThreadManagedObjectContext. The work for fetching and creating CKRecords was done in the ProcessSyncChangesOperation so I added a new CKRecord array that can be set in the operation chain. To be honest, I’m not happy with how this operation turned out and will probably refactor it a little differently in a production app.

class ModifyRecordsFromManagedObjectsOperation: CKModifyRecordsOperation {
    
    var fetchedRecordsToModify: [CKRecordID : CKRecord]?
    var preModifiedRecords: [CKRecord]?
    private let modifiedManagedObjectIDs: [NSManagedObjectID]?
    private let coreDataManager: CoreDataManager
    private let cloudKitManager: CloudKitManager
    
    init(coreDataManager: CoreDataManager, cloudKitManager: CloudKitManager) {
        
        self.coreDataManager = coreDataManager
        self.cloudKitManager = cloudKitManager
        self.modifiedManagedObjectIDs = nil
        self.fetchedRecordsToModify = nil
        self.preModifiedRecords = nil
        super.init()
    }
    
    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 {
            // before we run we need to map the records we fetched in the dependent operation into our records to save
            let modifiedRecords: [CKRecord]
            if let modifiedManagedObjectIDs = self.modifiedManagedObjectIDs {
                modifiedRecords = self.modifyFetchedRecordsIDs(managedObjectContext, modifiedManagedObjectIDs: modifiedManagedObjectIDs)
            } else if let preModifiedRecords = self.preModifiedRecords {
                modifiedRecords = preModifiedRecords
            }
            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("!!! ERROR !!! - ModifyRecordsFromManagedObjectsOperation.perRecordCompletionBlock: \(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("!!! ERROR !!! - ModifyRecordsFromManagedObjectsOperation.modifyRecordsCompletionBlock: \(error)")
            }
            else if let deletedRecords = deletedRecords {
                for recordID in deletedRecords {
                    print("DELETED: \(recordID)")
                }
            }
            self.cloudKitManager.lastCloudKitSyncTimestamp = NSDate()
            print("ModifyRecordsFromManagedObjectsOperation modifyRecordsCompletionBlock")
        }
    }
}

The operation for saving changes to Core Data did not need any modifications and I already covered cleaning up the DeletedCloudKitObjects entities.

Chaining the Operations

Chaining the 13 sync operations is almost 100 lines of code but is pretty straight forward:

private func queueFullSyncOperations() {
        
    // 1. Fetch all the changes both locally and from each zone
    let fetchOfflineChangesFromCoreDataOperation = FetchOfflineChangesFromCoreDataOperation(coreDataManager: coreDataManager, cloudKitManager: self, entityNames: ModelObjectType.allCloudKitModelObjectTypes)
    let fetchCarZoneChangesOperation = FetchRecordChangesForCloudKitZoneOperation(cloudKitZone: CloudKitZone.CarZone)
    let fetchTruckZoneChangesOperation = FetchRecordChangesForCloudKitZoneOperation(cloudKitZone: CloudKitZone.TruckZone)
    let fetchBusZoneChangesOperation = FetchRecordChangesForCloudKitZoneOperation(cloudKitZone: CloudKitZone.BusZone)
        
    // 2. Process the changes after transfering
    let processSyncChangesOperation = ProcessSyncChangesOperation(coreDataManager: coreDataManager)
    let transferDataToProcessSyncChangesOperation = NSBlockOperation {
        [unowned processSyncChangesOperation, unowned fetchOfflineChangesFromCoreDataOperation, unowned fetchCarZoneChangesOperation, unowned fetchTruckZoneChangesOperation, unowned fetchBusZoneChangesOperation] in
            
        processSyncChangesOperation.preProcessLocalChangedObjectIDs.appendContentsOf(fetchOfflineChangesFromCoreDataOperation.updatedManagedObjects)
        processSyncChangesOperation.preProcessLocalDeletedRecordIDs.appendContentsOf(fetchOfflineChangesFromCoreDataOperation.deletedRecordIDs)
            
        processSyncChangesOperation.preProcessServerChangedRecords.appendContentsOf(fetchCarZoneChangesOperation.changedRecords)
        processSyncChangesOperation.preProcessServerChangedRecords.appendContentsOf(fetchTruckZoneChangesOperation.changedRecords)
        processSyncChangesOperation.preProcessServerChangedRecords.appendContentsOf(fetchBusZoneChangesOperation.changedRecords)
            
        processSyncChangesOperation.preProcessServerDeletedRecordIDs.appendContentsOf(fetchCarZoneChangesOperation.deletedRecordIDs)
        processSyncChangesOperation.preProcessServerDeletedRecordIDs.appendContentsOf(fetchTruckZoneChangesOperation.deletedRecordIDs)
        processSyncChangesOperation.preProcessServerDeletedRecordIDs.appendContentsOf(fetchBusZoneChangesOperation.deletedRecordIDs)
    }
        
    // 3. Fetch records from the server that we need to change
    let fetchRecordsForModifiedObjectsOperation = FetchRecordsForModifiedObjectsOperation(coreDataManager: coreDataManager)
    let transferDataToFetchRecordsOperation = NSBlockOperation {
        [unowned fetchRecordsForModifiedObjectsOperation, unowned processSyncChangesOperation] in
            
        fetchRecordsForModifiedObjectsOperation.preFetchModifiedRecords = processSyncChangesOperation.postProcessChangesToServer
    }
        
    // 4. Modify records in the cloud
    let modifyRecordsFromManagedObjectsOperation = ModifyRecordsFromManagedObjectsOperation(coreDataManager: coreDataManager, cloudKitManager: self)
    let transferDataToModifyRecordsOperation = NSBlockOperation {
        [unowned fetchRecordsForModifiedObjectsOperation, unowned modifyRecordsFromManagedObjectsOperation, unowned processSyncChangesOperation] in
            
        if let fetchedRecordsDictionary = fetchRecordsForModifiedObjectsOperation.fetchedRecords {
            modifyRecordsFromManagedObjectsOperation.fetchedRecordsToModify = fetchedRecordsDictionary
        }
        modifyRecordsFromManagedObjectsOperation.preModifiedRecords = processSyncChangesOperation.postProcessChangesToServer
            
        // also set the recordIDsToDelete from what we processed
        modifyRecordsFromManagedObjectsOperation.recordIDsToDelete = processSyncChangesOperation.postProcessDeletesToServer
    }
        
    // 5. Modify records locally
    let saveChangedRecordsToCoreDataOperation = SaveChangedRecordsToCoreDataOperation(coreDataManager: coreDataManager)
    let transferDataToSaveChangesToCoreDataOperation = NSBlockOperation {
        [unowned saveChangedRecordsToCoreDataOperation, unowned processSyncChangesOperation] in
            
        saveChangedRecordsToCoreDataOperation.changedRecords = processSyncChangesOperation.postProcessChangesToCoreData
        saveChangedRecordsToCoreDataOperation.deletedRecordIDs = processSyncChangesOperation.postProcessDeletesToCoreData
    }
        
    // 6. Delete all of the DeletedCloudKitObjects
    let clearDeletedCloudKitObjectsOperation = ClearDeletedCloudKitObjectsOperation(coreDataManager: coreDataManager)
        
    // set dependencies
    // 1. transfering all the fetched data to process for conflicts
    transferDataToProcessSyncChangesOperation.addDependency(fetchOfflineChangesFromCoreDataOperation)
    transferDataToProcessSyncChangesOperation.addDependency(fetchCarZoneChangesOperation)
    transferDataToProcessSyncChangesOperation.addDependency(fetchTruckZoneChangesOperation)
    transferDataToProcessSyncChangesOperation.addDependency(fetchBusZoneChangesOperation)
        
    // 2. processing the data onces its transferred
    processSyncChangesOperation.addDependency(transferDataToProcessSyncChangesOperation)
        
    // 3. fetching records changed local
    transferDataToFetchRecordsOperation.addDependency(processSyncChangesOperation)
    fetchRecordsForModifiedObjectsOperation.addDependency(transferDataToFetchRecordsOperation)
        
    // 4. modifying records in CloudKit
    transferDataToModifyRecordsOperation.addDependency(fetchRecordsForModifiedObjectsOperation)
    modifyRecordsFromManagedObjectsOperation.addDependency(transferDataToModifyRecordsOperation)
        
    // 5. modifying records in CoreData
    transferDataToSaveChangesToCoreDataOperation.addDependency(processSyncChangesOperation)
    saveChangedRecordsToCoreDataOperation.addDependency(transferDataToModifyRecordsOperation)
        
    // 6. clear the deleteCloudKitObjects
    clearDeletedCloudKitObjectsOperation.addDependency(saveChangedRecordsToCoreDataOperation)
        
    // add operations to the queue
    operationQueue.addOperation(fetchOfflineChangesFromCoreDataOperation)
    operationQueue.addOperation(fetchCarZoneChangesOperation)
    operationQueue.addOperation(fetchTruckZoneChangesOperation)
    operationQueue.addOperation(fetchBusZoneChangesOperation)
    operationQueue.addOperation(transferDataToProcessSyncChangesOperation)
    operationQueue.addOperation(processSyncChangesOperation)
    operationQueue.addOperation(transferDataToFetchRecordsOperation)
    operationQueue.addOperation(fetchRecordsForModifiedObjectsOperation)
    operationQueue.addOperation(transferDataToModifyRecordsOperation)
    operationQueue.addOperation(modifyRecordsFromManagedObjectsOperation)
    operationQueue.addOperation(transferDataToSaveChangesToCoreDataOperation)
    operationQueue.addOperation(saveChangedRecordsToCoreDataOperation)
    operationQueue.addOperation(clearDeletedCloudKitObjectsOperation)
}

 

The transferDataToProcessSyncChangesOperation is dependent on the 4 fetch operations. My NSOperationQueue is setup with maxConcurrentOperationCount set to 1 so its basically a serial queue but I can easily change that so that all the fetch operations could happen at the same time if the system has the resources to do it. Another example of the power of NSOperations!

Next Post – Conclusions > 

 

Advertisements

Written by Nick Harris

February 9, 2016 at 6:53 am

Posted in Uncategorized

One Response

Subscribe to comments with RSS.

  1. […] CloudKit + Core Data + NSOperations – Syncing by Nick Harris (added 5th January 2017) […]


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: