Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

NSPersistentCloudKitContainer: How to check if data is synced to CloudKit

I have implemented NSPersistentCloudKitContainer to get my data synced to CloudKit, I would like to know that the sync is finished and there is no other change pending to be synced.

When I tried reinstalling the app, I start getting my data back from CloudKit and it started printing certain logs in the console. It takes around 30 seconds to get all my data back from the CloudKit. Some of the logs mention about NSCloudKitMirroringDelegate. It looks like NSCloudKitMirroringDelegate knows about the remaining sync requests but I couldn't find any information about being sure that the sync is complete.

here are few logs which does show that NSCloudKitMirroringDelegate knows when sync is finished.

CoreData: CloudKit: CoreData+CloudKit: -NSCloudKitMirroringDelegate checkAndExecuteNextRequest: : Checking for pending requests.

CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate _enqueueRequest:]_block_invoke(714): : enqueuing request: A2BB21B3-BD1B-4500-865C-6C848D67081D

CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate checkAndExecuteNextRequest]_block_invoke(2085): : Deferring additional work. There is still an active request: A3E1D4A4-2BDE-4E6A-8DB4-54C96BA0579E

CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate checkAndExecuteNextRequest]_block_invoke(2092): : No more requests to execute.

Is there any way to know that the data is synced completely? It is required for me to show certain UI to the user.

like image 695
FE_Tech Avatar asked Dec 02 '19 12:12

FE_Tech


2 Answers

To quote "Framework Engineer" from a similar question in the Apple Developer forums: "This is a fallacy". In a distributed system, you can't truly know if "sync is complete", as another device, which could be online or offline at the moment, could have unsynced changes.

That said, here are some techniques you can use to implement the use cases that tend to drive the desire to know the state of sync.

Adding default/sample data

Give them a button to add specific default/sample data rather than automatically adding it to the app. This both works better in a distributed environment, and makes the distinction between your app's functionality and the sample data clearer.

For example, in one of my apps, the user can create a list of "contexts" (e.g. "Home", "Work") into which they can add actions to do. If the user is using the app for the first time, the list of "Contexts" would be empty. This is fine, as they could add contexts, but it would be nice to provide some defaults.

Rather than detect first launch and add default contexts, I added a button that appears only if there are no contexts in the database. That is, if the user navigates to the "Next Actions" screen, and there are no contexts (i.e. contexts.isEmpty), then the screen also contains a "Add Default GTD Contexts" button. The moment a context is added (either by the user or via sync), the button disappears.

Screenshot of Next Actions screen

enter image description here

Here's the SwiftUI code for the screen:

import SwiftUI

/// The user's list of contexts, plus an add button
struct NextActionsLists: View {

    /// The Core Data enviroment in which we should perform operations
    @Environment(\.managedObjectContext) var managedObjectContext

    /// The available list of GTD contexts to which an action can be assigned, sorted alphabetically
    @FetchRequest(sortDescriptors: [
        NSSortDescriptor(key: "name", ascending: true)]) var contexts: FetchedResults<ContextMO>

    var body: some View {
        Group {
            // User-created lists
            ForEach(contexts) { context in
                NavigationLink(
                    destination: ContextListActionListView(context: context),
                    label: { ContextListCellView(context: context) }
                ).isDetailLink(false)
                    .accessibility(identifier: "\(context.name)") // So we can find it without the count
            }
            .onDelete(perform: delete)

            ContextAddButtonView(displayComplicationWarning: contexts.count > 8)

            if contexts.isEmpty {
                Button("Add Default GTD Contexts") {
                    self.addDefaultContexts()
                }.foregroundColor(.accentColor)
                    .accessibility(identifier: "addDefaultContexts")
            }
        }
    }

    /// Deletes the contexts at the specified index locations in `contexts`.
    func delete(at offsets: IndexSet) {
        for index in offsets {
            let context = contexts[index]
            context.delete()
        }
        DataManager.shared.saveAndSync()
    }

    /// Adds the contexts from "Getting Things Done"
    func addDefaultContexts() {
        for name in ["Calls", "At Computer", "Errands", "At Office", "At Home", "Anywhere", "Agendas", "Read/Review"] {
            let context = ContextMO(context: managedObjectContext)
            context.name = name
        }
        DataManager.shared.saveAndSync()
    }
}

Preventing changes/conflicts

This should be done via your data model. To use the example from WWDC2019, say you're writing a blogging app, and you have a "posts" entity:

Post
----
content: String

If the user modifies "content" on two devices at the same time, one will overwrite the other.

Instead, make content a "contribution":

Content
-------
post: Post
contribution: String

Your app would then read the contributions and merge them using a strategy appropriate for your app. The easiest/laziest approach would be to use a modifiedAt date and choose the last one.

For the app I mentioned above, I chose a couple of strategies:

  • For simple fields, I just included them in the entity. Last writer wins.
  • For notes (i.e. big strings - lots of data to lose), I created a a relationship (multiple notes per item), and allowed the user to add multiple notes to an item (which are automatically timestamped for the user). This both solves the data model issue and adds a Jira-comment-like feature for the user. Now, the user could edit an existing note, in which case the last device to write a change "wins".

Displaying "first-run" (e.g. onboarding) screens

I'll give a couple of approaches to this:

  • Store a first-run flag in UserDefaults. If the flag isn't there, display your first-run screens. This approach makes your first-run a per-device thing. Give the user a "skip" button too. (Example code from Detect first launch of iOS app)

      let launchedBefore = UserDefaults.standard.bool(forKey: "launchedBefore")
      if launchedBefore  {
          print("Not first launch.")
      } else {
          print("First launch, setting UserDefault.")
          UserDefaults.standard.set(true, forKey: "launchedBefore")
      }
    
  • Set up a FetchRequestController on a table that will definitely have data in it if the user's used your app before. Display your first-run screens if the results of your fetch are empty, and remove them if your FetchRequestController fires and has data.

I recommend the UserDefaults approach. It's easier, it's expected if the user just installed your app on a device, and it's a nice reminder if they installed your app months ago, played with it for a bit, forgot, got a new phone, installed your app on it (or found it auto-installed), and ran it.

Misc

For completeness, I'll add that iOS 14 and macOS 11 add some notifications/publishers to NSPersistentCloudKitContainer that let your app be notified when sync events happen. Although you can (and probably should) use these to detect sync errors, be careful about using them to detect "sync is complete".

Here's an example class using the new notifications.

import Combine
import CoreData

@available(iOS 14.0, *)
class SyncMonitor {
    /// Where we store Combine cancellables for publishers we're listening to, e.g. NSPersistentCloudKitContainer's notifications.
    fileprivate var disposables = Set<AnyCancellable>()

    init() {
        NotificationCenter.default.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)
            .sink(receiveValue: { notification in
                if let cloudEvent = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
                    as? NSPersistentCloudKitContainer.Event {
                    // NSPersistentCloudKitContainer sends a notification when an event starts, and another when it
                    // ends. If it has an endDate, it means the event finished.
                    if cloudEvent.endDate == nil {
                        print("Starting an event...") // You could check the type, but I'm trying to keep this brief.
                    } else {
                        switch cloudEvent.type {
                        case .setup:
                            print("Setup finished!")
                        case .import:
                            print("An import finished!")
                        case .export:
                            print("An export finished!")
                        @unknown default:
                            assertionFailure("NSPersistentCloudKitContainer added a new event type.")
                        }

                        if cloudEvent.succeeded {
                            print("And it succeeded!")
                        } else {
                            print("But it failed!")
                        }

                        if let error = cloudEvent.error {
                            print("Error: \(error.localizedDescription)")
                        }
                    }
                }
            })
            .store(in: &disposables)
    }
}
like image 114
ggruen Avatar answered Sep 22 '22 13:09

ggruen


If the meaning of "to display certain UI to the user" to present onboarding screens to help the user to prepopulate the data store, an approach is to use NSUbiquitousKeyValueStore, which can be simply described as "UserDefaults on iCloud".

There are two advantages over a UserDefaults approach:

  • the data store is synchronized on iCloud, so it will work consistently if the user has already launched your app on another device
  • you can force a synchronization (NSUbiquitousKeyValueStore.default.synchronize(), to make sure that the data is updated.

You can use it to store 'key information' so that you know what to expect, and choose the UI to present the UI accordingly. For example, in an app with accounts, you can store the account identifiers hashes as an array. If the store returns an empty array, you request a prompt to create a new account. Otherwise, you display a placeholder to indicate that the data is synchronizing. And then you let the FetchRequestControllers working in the background.

like image 20
Renaud Avatar answered Sep 24 '22 13:09

Renaud