引用 Apple Developer 论坛中类似问题的“框架工程师”:“这是一个谬论”。在分布式系统中,您无法真正知道“同步是否完成”,因为此时可能在线或离线的另一台设备可能有未同步的更改。
也就是说,您可以使用以下一些技术来实现倾向于驱动了解同步状态的愿望的用例。
添加默认/示例数据
给他们一个按钮来添加特定的默认/示例数据,而不是自动将其添加到应用程序中。这在分布式环境中工作得更好,并且使您的应用程序的功能和示例数据之间的区别更加清晰。
例如,在我的一个应用程序中,用户可以创建一个“上下文”列表(例如“家庭”、“工作”),他们可以在其中添加要执行的操作。如果用户是第一次使用该应用程序,“上下文”列表将为空。这很好,因为他们可以添加上下文,但提供一些默认值会很好。
我没有检测首次启动并添加默认上下文,而是添加了一个仅在数据库中没有上下文时才出现的按钮。也就是说,如果用户导航到“Next Actions”屏幕,并且没有上下文(即contexts.isEmpty
),则该屏幕还包含一个“Add Default GTD Contexts”按钮。添加上下文的那一刻(由用户或通过同步),按钮消失。
这是屏幕的 SwiftUI 代码:
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()
}
}
防止更改/冲突
这应该通过您的数据模型来完成。使用 WWDC2019 中的示例,假设您正在编写一个博客应用程序,并且您有一个“帖子”实体:
Post
----
content: String
如果用户同时在两台设备上修改“内容”,其中一台将覆盖另一台。
相反,让内容成为“贡献”:
Content
-------
post: Post
contribution: String
然后,您的应用程序将读取贡献并使用适合您的应用程序的策略将它们合并。最简单/最懒惰的方法是使用 modifiedAt 日期并选择最后一个。
对于我上面提到的应用程序,我选择了几个策略:
- 对于简单的字段,我只是将它们包含在实体中。最后一位作家获胜。
- 对于注释(即大字符串 - 大量数据丢失),我创建了一个关系(每个项目多个注释),并允许用户向一个项目添加多个注释(自动为用户添加时间戳)。这既解决了数据模型问题,又为用户添加了类似 Jira 评论的功能。现在,用户可以编辑现有注释,在这种情况下,最后一个写入更改的设备“获胜”。
显示“首次运行”(例如入职)屏幕
我将为此提供几种方法:
在 UserDefaults 中存储首次运行标志。如果标志不存在,请显示您的首次运行屏幕。这种方法使您的首次运行成为每个设备的事情。也给用户一个“跳过”按钮。(示例代码来自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")
}
如果用户以前使用过您的应用程序,则在肯定会有数据的表上设置 FetchRequestController。如果您的 fetch 结果为空,则显示您的首次运行屏幕,如果您的 FetchRequestController 触发并有数据,则将其删除。
我推荐 UserDefaults 方法。这更容易,如果用户刚刚在设备上安装了你的应用程序,这是一个很好的提醒,如果他们几个月前安装了你的应用程序,玩了一会儿,忘记了,买了一部新手机,在上面安装了你的应用程序,这是一个很好的提醒(或发现它自动安装),然后运行它。
杂项
为了完整起见,我将添加 iOS 14 和 macOS 11 向 NSPersistentCloudKitContainer 添加一些通知/发布者,以便在同步事件发生时通知您的应用。尽管您可以(并且可能应该)使用它们来检测同步错误,但在使用它们来检测“同步完成”时要小心。
这是一个使用新通知的示例类。
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)
}
}