Skip to content

0x07a-译-cloudkit-swift-data

https://alexanderlogan.co.uk/blog/wwdc23/08-cloudkit-swift-data

SwiftData具有与NSPersistentCloudKitContainer类似的超能力,实际上我很确定它只是对其的封装,所以让我们来了解一下它们。

将您的数据放到云端

CloudKit已经很容易使用了一段时间,现在变得更加容易。只需几个步骤就可以开始同步到云端。
确保您已启用iCloud权限,并仔细检查CloudKit是否已勾选。您还需要设置一个容器。

在这里顺便打开后台模式和远程通知,否则您的容器将无法加载。

一旦启用所有这些,CloudKit同步就会正常工作。它会从您的权限中检测到它,并使用它。

为了获得更明确的控制,您可以显式地为您的ModelConfiguration提供容器标识符。

我们之前见过这段代码,但为了以防万一,这里再次展示。

swift
@main
struct Brew_BookApp: App {
    let container: ModelContainer = {
        // Don't force unwrap for real 👀
        try! ModelContainer(
            for: [Brewer.self, Brew.self],
            .init(
                cloudKitContainerIdentifier: "icloud.uk.co.alexanderlogan.samples.Brew-Book"
            )
        )
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}
@main
struct Brew_BookApp: App {
    let container: ModelContainer = {
        // Don't force unwrap for real 👀
        try! ModelContainer(
            for: [Brewer.self, Brew.self],
            .init(
                cloudKitContainerIdentifier: "icloud.uk.co.alexanderlogan.samples.Brew-Book"
            )
        )
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

修复我们的模型

如果您启用了这个功能,您的应用很可能会突然崩溃。您的容器可能无法加载,控制台会充满错误。

如果您还没有亲自运行过,我在这里添加了一个错误示例 - 它们非常有用!

error: Store failed to load.  <NSPersistentStoreDescription: 0x600000c5d6b0> (type: SQLite, url: file:///Users/alex/Library/Developer/CoreSimulator/Devices/95D42E8D-CA19-481B-9150-ACD1642F8194/data/Containers/Data/Application/6564C0E2-2907-490F-A5E0-E8ED505C705B/Library/Application%20Support/default.store) with error = Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:
Brew: brewDate
Brew: brewIdentifier
Brew: rating
Brew: type
CloudKit integration requires that all relationships be optional, the following are not:
Brewer: brews
CloudKit integration does not support unique constraints. The following entities are constrained:
Brew: brewIdentifier} with userInfo {
    NSLocalizedFailureReason = "CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:\nBrew: brewDate\nBrew: brewIdentifier\nBrew: rating\nBrew: type\nCloudKit integration requires that all relationships be optional, the following are not:\nBrewer: brews\nCloudKit integration does not support unique constraints. The following entities are constrained:\nBrew: brewIdentifier";
}
error: Store failed to load.  <NSPersistentStoreDescription: 0x600000c5d6b0> (type: SQLite, url: file:///Users/alex/Library/Developer/CoreSimulator/Devices/95D42E8D-CA19-481B-9150-ACD1642F8194/data/Containers/Data/Application/6564C0E2-2907-490F-A5E0-E8ED505C705B/Library/Application%20Support/default.store) with error = Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:
Brew: brewDate
Brew: brewIdentifier
Brew: rating
Brew: type
CloudKit integration requires that all relationships be optional, the following are not:
Brewer: brews
CloudKit integration does not support unique constraints. The following entities are constrained:
Brew: brewIdentifier} with userInfo {
    NSLocalizedFailureReason = "CloudKit integration requires that all attributes be optional, or have a default value set. The following attributes are marked non-optional but do not have a default value:\nBrew: brewDate\nBrew: brewIdentifier\nBrew: rating\nBrew: type\nCloudKit integration requires that all relationships be optional, the following are not:\nBrewer: brews\nCloudKit integration does not support unique constraints. The following entities are constrained:\nBrew: brewIdentifier";
}

就像使用NSPersistentCloudkitContainer一样,我们必须对数据模型进行一些更改,以便在云端正常工作。

需要注意的主要事项是不支持唯一约束,关系必须是可选的(即使您将它们默认为空数组),并且所有值都必须有默认值。

让我们通过一个已存在的模型示例,将其修改为CloudKit友好的模型。
我们的第一个模型Brewer,只需要设置默认值并将关系设置为可选。

swift
// After
@Model final class Brewer {
    var name: String = ""

    @Relationship(.cascade, inverse: \Brew.brewer)
    var brews: [Brew]? = []

    init(name: String) {
        self.name = name
    }
}
// After
@Model final class Brewer {
    var name: String = ""

    @Relationship(.cascade, inverse: \Brew.brewer)
    var brews: [Brew]? = []

    init(name: String) {
        self.name = name
    }
}

接下来,让我们修复Brew模型。
这个稍微复杂一些,因为我们有一个唯一参数,它必须被移除。

swift
// Before
@Model
final class Brew {
    @Attribute(.unique) var brewIdentifier: UUID
    var type: BrewType.RawValue
    var rating: Int
    var brewDate: Date

    var brewer: Brewer?

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}
// After
@Model
final class Brew {
    var brewIdentifier: UUID = .init()
    var type: BrewType.RawValue = BrewType.espresso.rawValue
    var rating: Int = 5
    var brewDate: Date = Date()

    var brewer: Brewer? = nil

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}
// Before
@Model
final class Brew {
    @Attribute(.unique) var brewIdentifier: UUID
    var type: BrewType.RawValue
    var rating: Int
    var brewDate: Date

    var brewer: Brewer?

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}
// After
@Model
final class Brew {
    var brewIdentifier: UUID = .init()
    var type: BrewType.RawValue = BrewType.espresso.rawValue
    var rating: Int = 5
    var brewDate: Date = Date()

    var brewer: Brewer? = nil

    init(
        brewIdentifier: UUID = .init(),
        type: BrewType,
        rating: Int,
        brewDate: Date

    ) {
        self.brewIdentifier = brewIdentifier
        self.type = type.rawValue
        self.rating = rating
        self.brewDate = brewDate
    }
}

如果您现在在已登录的设备上运行应用,保存一些内容,删除应用,重新安装,您的内容将会回来!

实时更新

您会注意到当您最初打开应用时,即使云端有内容,也不会显示任何内容。这是因为@Query在安装到视图后不会在从Web获得推送时自动更新 - 但有一个巧妙的技巧可以解决这个问题。

我们实际上可以使用底层的NSPersistentCloudKitContainer.eventChangedNotification来检测由于CloudKit而更新的内容,并强制我们的查询更新。

让我们将此功能添加到我们的酿酒师列表中。

swift
.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        // TODO
    }
}
.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        // TODO
    }
}

在这里,我们监听eventChangedNotification,检查事件类型是否为import(数据下载),并且我们有一个结束日期表示它已完成。

现在,来看技巧。
当此通知触发时,我们的数据已下载并准备就绪,但我们的查询不会更新。
我们可以通过触发手动获取来欺骗它更新。

swift
.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        Task { @MainActor in
            let brewersFetchDescriptor = FetchDescriptor<Brewer>(
                predicate: nil,
                sortBy: [.init(\.name)]
            )
            _ = try? context.fetch(brewersFetchDescriptor)
        }
    }
}
.onReceive(NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)) { notification in
    guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
        return
    }
    if event.endDate != nil && event.type == .import {
        Task { @MainActor in
            let brewersFetchDescriptor = FetchDescriptor<Brewer>(
                predicate: nil,
                sortBy: [.init(\.name)]
            )
            _ = try? context.fetch(brewersFetchDescriptor)
        }
    }
}

当触发此获取时,它会欺骗查询也进行更新。
现在,当您从全新安装运行应用时,您的内容将从Web获取并按预期更新,后续更新也是如此。


数据存储的这个版本和仅本地版本的示例都可以在Github上找到。
如果您想阅读更多内容或分享您的经验,我是Twitter上的@SwiftyAlex。

参考链接

原文链接:https://alexanderlogan.co.uk/blog/wwdc23/08-cloudkit-swift-data