Adelmaer Adelmaer - 9 months ago 34
iOS Question

How to update data in TableView without the delay using CloudKit when Creating new Records

There are 2 View Controllers in my App.

The first "MainViewController" displays a tableView with CKRecords fields fetched from the private CloudKit database. Inside the viewWillAppear method of this VC I fetch records from CloudKit and reload data of a tableview to show the latest fetched results that have been previously saved in the CloudKit by the user.

The second view controller "CreateRecordViewController" is made for creating CKRecords and saving them to the private database of the CloudKit.

So i create records in the CreateRecordViewController and show them in the MainViewController.

The problem is the following: when I create record at CreateRecordViewController, it is saving on the CloudKit server, but after closing this CreateRecordViewController and going to MainViewController the tableview does not always update in time.

This is the code of saving record in my CreateRecordViewController:

CloudKitManager.sharedInstance.privateDatabase.save(myRecord) { (savedRecord, error) -> Void in

if error == nil {


print("successfully saved record code: \(savedRecord)")


}
else {
// Insert error handling
print("error Saving Data to iCloud: \(error.debugDescription)")
}
}


After record saved i close CreateRecordViewController and see MainViewController.

As i've said earlier in viewWillAppear of MainViewController i check if iCloud available, and if it is available, I fetch all records with a query from CloudKit and showing them in a tableView.

override func viewWillAppear(_ animated: Bool) {

CKContainer.default().accountStatus { (accountStatus, error) in
switch accountStatus {
case .noAccount: print("CloudKitManager: no iCloud Alert")
case .available:
print("CloudKitManager: checkAccountStatus : iCloud Available")

self.loadRecordsFromiCloud()

case .restricted:
print("CloudKitManager: checkAccountStatus : iCloud restricted")
case .couldNotDetermine:
print("CloudKitManager: checkAccountStatus : Unable to determine iCloud status")
}
}


}


In loadRecordsFromiCloud() I'm also reloading tableview async when query successful to show the latest results.

My loadRecordsFromiCloud method located in my MainViewController looks like this:

func loadRecordsFromiCloud() {

// Get a private Database
let privateDatabase = CloudKitManager.sharedInstance.privateDatabase
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "MyRecords", predicate: predicate)

let operation = CKQueryOperation(query: query)


privateDatabase.perform(query, inZoneWith: nil) { (results, error) in
if ((error) != nil) {
// Error handling for failed fetch from public database
print("error loading : \(error)")

}
else {
// Display the fetched records
//print(results!)
self.tableViewDataArray = results!

DispatchQueue.main.async {
print("DispatchQueue.main.sync")
self.tableView.reloadData()
}

}
}

}


Sometimes when CloudKit servers work faster i can see the new records in a tableView, but most of the time there is a delay (i don't see the new results in a tableview at the moment when MainViewController loads) I think this is because when i fetch records it fetches the old data (not sure why), but maybe i've made another mistake .
This is a bad user experience and i would like to know how to avoid this delay. I want my tableView to show the updated results just after i close CreateRecordViewController .

My first idea was to subscribe to CloudKit Records changes, and fetch and reload data in a tableView when notification received, but i really don't need a push notification (i'd rather have just a method in code where i could fetch data from CloudKit after i know that all CloudKit records saved or after i know that there is a new record created, and after fetching and getting the data for a tableView i would call tableView.reloadData for example), but i'm not sure how to implement this right (in what method) and not sure if this is the best solution. I've also heard that in WWDC 2016 video dedicated to CloudKit, that now there are some new methods related to subscription to Record changes, maybe some of those methods can help (not sure). Looking for the best, or any good and easy solution for this problem (Delay Problem).

I'm using XCode 8, iOS 10, swift 3

Answer Source

There is no guarantee as to when the record would be available in a query but there is something you can do. You can stitch the new record back in. Because when you create and save a record you have the record id you can make a ckfetchrecordsoperation and pass the id from the new record and you are guaranteed to get it back immediately. The indexing sometimes can take a while and this is frustrating with CloudKit. So basically the best way to guarantee a speedy database is make a query and if the new record id is not in there make a fetch with the id and append it to your results. Hope this makes sense.

I had to do this before and since I have not been too keen on CK. Here is the link to the operation to stitch the record back in. https://developer.apple.com/reference/cloudkit/ckfetchrecordsoperation also if you are using images check out this library I made that allows you to exclude the image data keys and download and cache on demand that could speed up your queries. https://github.com/agibson73/AGCKImage

Edit after comment:

I think the part you are not getting is the record may or may not come down with the query in viewcontroller 1 because of the way the indexing works. You even mention in your question it fetches old data. This is due to the server indexing. The same would happen if you deleted a record. It could still show up for some time in the query. In that case you keep track of the recently deleted record ids and remove them after the query. Again this manually adding and removing I am talking about is the only way to guarantee what the users see and the results from the query stay in sync with what the user would expect.

Here is some code although completely untested that I hope will help you visualize what I am saying above.

   func loadRecordsFromiCloud() {

    // Get a private Database
    let privateDatabase = CKContainer.default().privateCloudDatabase
    let predicate = NSPredicate(value: true)
    let query = CKQuery(recordType: "MyRecords", predicate: predicate)

    privateDatabase.perform(query, inZoneWith: nil) { (results, error) in
        if ((error) != nil) {
            // Error handling for failed fetch from public database
            print("error loading : \(error)")

        }
        else {
            //check for a newRecord ID that might be missing from viewcontroller 2 that was passed back
            if self.passedBackNewRecordID != nil{
                let newResults = results?.filter({$0.recordID == self.passedBackNewRecordID})
                //only excute if there is a new record that is missing from the query
                if newResults?.count == 0{
                    //houston there is a problem
                    let additionalOperation = CKFetchRecordsOperation(recordIDs: [self.passedBackNewRecordID!])
                    additionalOperation.fetchRecordsCompletionBlock = { recordsDict,fetchError in
                        if let newRecords = recordsDict?.values as? [CKRecord]{
                            //stitch the missing record back in
                            let final = newRecords.flatMap({$0}) + results!.flatMap({$0})
                            self.reloadWithResults(results: final)
                            self.passedBackNewRecordID = nil

                        }else{
                            self.reloadWithResults(results: results)
                            self.passedBackNewRecordID = nil
                        }

                    }
                    privateDatabase.add(additionalOperation)
                 }else{
                    //the new record is already in the query result
                    self.reloadWithResults(results: results)
                    self.passedBackNewRecordID = nil
                }
            }else{
                //no new records missing to do additional check on
                self.reloadWithResults(results: results)
            }

        }
    }
}


func reloadWithResults(results:[CKRecord]?){
       self.tableViewDataArray = results!
        DispatchQueue.main.async {
            print("DispatchQueue.main.sync")
             self.tableView.reloadData()
        }

    }
}

It's a bit of a mess but you can see that I am stitching the missing recordID if not nil back into the query that you are doing because that query is not guaranteed in real time to give you your expected new records. In this case self.passedBackNewRecordID is set based on the new recordID from Viewcontroller 2. How you set this or track this variable is up to you but you probably need an entire queue system because what I am telling you applies for changes to the record as well as deletes. So in a production app I had to track the records that had changes, deletes and additions and get the fresh version of each of those so you can imagine the complexity of a list of objects. Since I stopped using CloudKit because the tombstoning or indexing takes too long to show changes in queries.

To test your saved code could look like this.

 CloudKitManager.sharedInstance.privateDatabase.save(myRecord) { (savedRecord, error) -> Void in

            if error == nil {


        print("successfully saved record code: \(savedRecord)")
        //save temporarily to defaults
        let recordID = "someID"
        UserDefaults.standard.set(recordID, forKey: "recentlySaved")
        UserDefaults.standard.synchronize()
        //now we can dismiss


            }
            else {
                // Insert error handling
                print("error Saving Data to iCloud: \(error.debugDescription)")
            }
        }

And in the code where you call the query in view controller 1 possibly viewWillAppear you could call this

func startQuery(){
    UserDefaults.standard.synchronize()
    if let savedID = UserDefaults.standard.value(forKey: "recentlySaved") as? String{
        passedBackNewRecordID = CKRecordID(recordName: savedID)
        //now we can remove from Userdefualts
        UserDefaults.standard.removeObject(forKey: "recentlySaved")
        UserDefaults.standard.synchronize()
    }

    self.loadRecordsFromiCloud()
}

This should fit your example pretty closely and allow you to test what I am saying with only minor changes possibly.