NSPersistentCloudKitContainer: How to check if data is synced to CloudKit

末鹿安然 提交于 2020-12-27 08:39:05

问题


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.


回答1:


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.

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)
    }
}


来源:https://stackoverflow.com/questions/59138880/nspersistentcloudkitcontainer-how-to-check-if-data-is-synced-to-cloudkit

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!