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.
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.
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()
}
}
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:
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.
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)
}
}
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:
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With