1

My app uses CloudKit and I am trying to implement background fetch.

The method in App Delegate calls a method in my main view controller which checks for changes in the CloudKit database.

However, I realise that I am not calling the completion handler correctly, as the closures for the CloudKit will return asynchronously. I am really unsure how best to call the completion handler in the app delegate method once the operation is complete. Can I pass the completion handler through to the view controller method?

App Delegate

func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    DispatchQueue.global(qos: .userInitiated).async {
        // Code to get a reference to main view controller
        destinationViewController.getZoneChanges()
        completionHandler(.newData)
    }
}

Main view controller method to get CloudKit changes

// Fetch zone changes (a method in main table view controller)
func getZoneChanges() {

  DispatchQueue.global(qos: .userInitiated).async {
        let customZone = CKRecordZone(zoneName: "Drugs")
        let zoneID = customZone.zoneID
        let zoneIDs = [zoneID]

        let changeToken = UserDefaults.standard.serverChangeToken // Custom way of accessing User Defaults using an extension

        // Look up the previous change token for each zone
        var optionsByRecordZoneID = [CKRecordZone.ID: CKFetchRecordZoneChangesOperation.ZoneOptions]()
        // Some other functioning code to process options

        // CK Zone Changes Operation
        let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIDs, optionsByRecordZoneID: optionsByRecordZoneID)

        // Closures for records changed, deleted etc. 
        // Closure details omitted for brevity as fully functional as expected.
        // These closures change data model, Spotlight indexing, notifications and trigger UI refresh etc.

        operation.recordChangedBlock = { (record) in
            // Code...
        }
        operation.recordWithIDWasDeletedBlock = { (recordId, string) in
            // Code...
        }

        operation.recordZoneChangeTokensUpdatedBlock = { (zoneId, token, data) in
            UserDefaults.standard.serverChangeToken = changeToken
            UserDefaults.standard.synchronize()
        }
        operation.recordZoneFetchCompletionBlock = { (zoneId, changeToken, _, _, error) in
            if let error = error {
                print("Error fetching zone changes: \(error.localizedDescription)")
            }
            UserDefaults.standard.serverChangeToken = changeToken
            UserDefaults.standard.synchronize()
        }
        operation.fetchRecordZoneChangesCompletionBlock = { (error) in
            if let error = error {
                print("Error fetching zone changes: \(error.localizedDescription)")
            } else {
                print("Changes fetched successfully!")

                // Save local items
                self.saveData() // Uses NSCoding
            }
        }
        CKContainer.default().privateCloudDatabase.add(operation)
    }
}
Chris
  • 4,009
  • 3
  • 21
  • 52
  • Have your `getZoneChanges` method take a completion handler. – rmaddy Sep 03 '19 at 20:12
  • 1
    Unrelated to your question but you have a serious flaw in your CloudKit code. You should not save the zone's change token until after you have successfully processed the associated changes. Image what happens if your app is killed or crashes between the call to `UserDefaults.standard.serverChangeToken = changeToken` and `self.saveData()`. Those queued up (and unsaved) changes are lost for good. – rmaddy Sep 03 '19 at 20:18
  • @rmaddy Thanks for your advice. So can I call the completion handler from the final CK operation closure? I assume the completion handler would then call the background fetch completion handler in turn? – Chris Sep 03 '19 at 20:39
  • @rmaddy Regarding your other point - good point! I hadn’t considered this. Is there just one place (after `saveData()`) where I should store the token? How then do I use the `recordZoneChangeTokensUpdatedBlock` and the other ones that return a token? – Chris Sep 03 '19 at 20:42
  • 1
    Your code for saving the token is fine. But you should first call `saveData` and make sure it succeeds before you actually save the token. Do this in both places you get a token. – rmaddy Sep 03 '19 at 21:13
  • 1
    And yes, your thoughts on the completion handler sound correct. – rmaddy Sep 03 '19 at 21:13
  • @rmaddy Many thanks. I’ll give this a shot. Happy to accept an answer if you want to add one. If you have time, I’d also appreciate your opinion on a related question I have about data loss during a background fetch: https://stackoverflow.com/q/57758330/8289095 – Chris Sep 03 '19 at 21:46

1 Answers1

2

Update your getZoneChanges to have a completion parameter.

func getZoneChanges(completion: @escaping (Bool) -> Void) {
    // the rest of your code

    operation.fetchRecordZoneChangesCompletionBlock = { (error) in
        if let error = error {
            print("Error fetching zone changes: \(error.localizedDescription)")

            completion(false)
        } else {
            print("Changes fetched successfully!")

            completion(true)
        }
    }
}

Then you can update the app delegate method to use it:

func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    DispatchQueue.global(qos: .userInitiated).async {
        // Code to get a reference to main view controller
        destinationViewController.getZoneChanges { (success) in
            completionHandler(success ? .newData : .noData)
        }
    }
}
rmaddy
  • 314,917
  • 42
  • 532
  • 579