学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

Swift花园笔记 - CoreData

结合 Core Data 和 SwiftUI

SwiftUI 和 Core Data 几乎是在十年前后被分别引入 —— SwiftUI 是伴随 iOS 13, 而 Core Data 是伴随 iPhoneOS 3 发布;

Core Data 是一个“对象图谱和持久化框架”,这是对于定义对象和对象属性,然后从永久存储中读写它们的高级叫法。表面上看,使用它意味着使用CodableUserDefaults这些东西,但它远不止这些:Core Data 能够排序和过滤我们的数据,对于更大的数据也成立 —— 基本上对于数据量没有限制。更棒的是,Core Data 还实现了各种高级的功能,你会依赖它们:包括数据可视化,数据懒加载,撤销和重做,等等。

当你创建工程时,我让你把 Use Core Data 的 checkbox 勾选起来,它会给你的项目带来几项改变:

  • 你会拥有一个叫 Bookworm.xcdatamodeld 的文件,它描述了你的数据模型,实际上是一张类和它们属性的清单。
  • AppDelegate.swiftSceneDelegate.swift 有配置 Core Data 的额外代码。

设置 Core Data 需要两步:创建一个“persistent container”,用于从设备存储中加载和保存实际的数据,并且把它注入 SwiftUI 环境,以便所有的视图都能访问它。

以上两步 Xcode 模板都已经为你完成。

从 Core Data 中查询数据是通过一个fetch 请求来完成的 —— 我们描述我们需要的东西,它们应当如何排序,以及是否使用某些过滤器,而 Core Data 会返回所有匹配的数据。我们需要确保这个 fetch 请求随着时间的推移是足够新的,这样我们的界面上的学生信息才能保持同步。

SwiftUI 对此有一个解决方案 —— 你可以猜猜它是什么 —— 它是另一个属性包装器。这个属性包装器称为@FetchRequest,它接收两个参数:我们想要查询的实体,以及我们希望结果执行的排序方式。有一个特定的格式 —— 我们在 students 之前添加 fetch request 注解 —— 请将下面这个属性添加到ContentView:

Core Data 将极大地困扰你的地方:它有可选数据的概念,但这种可选与 Swift 的可选型是完全不同的概念。如果我们在 Core Data 里说 “这个东西不能是可选的” (在模型编辑里操作),它仍然会生成可选的 Swift 属性,因为 Core Data 关注属性在保存时是否有值 —— 它们也可以没有值。

当我们定义 “Student” 实体时,实际发生在 Core Data 里的事情是,它为我们创建了一个继承自NSManagedObject的类。我们在代码中看不到这个类,因为它是在我们编译项目时自动生成的,就像 Core ML 的模型。这些对象之所以被称为managed,是因为 Core Data 负责管理它们:它从持久化容器中加载它们,也把它们写回持久化容器。

我们所有的 managed 对象都住在一个managed object context里,它负责实际获取 managed 对象,以及为我们保存变动等等。如果你愿意,可以拥有许多个 managed object contexts ,但我们一般不会这么做 —— 实际上多数情况下一个就够了。

我们并不需要自行创建这个 managed object context,因为 Xcode 已经为我们创建好它了。更好的是,它已经被添加到 SwiftUI 的环境,这也是@FetchRequest属性包装器能工作的原因 —— 它使用环境中可用的任何 managed object context

尽管如此,当涉及到添加和保持对象时,我们还是需要访问 SwiftUI 环境中的 managed object context。这里是使用@Environment属性包装器的又一个例子 —— 我们可以请求当前的 managed object context,然后赋给一个属性为我们所用。

把这个属性添加到ContentView:

@Environment(\.managedObjectContext) var moc

下一步是添加一个生成随机学生并且把它保存到 managed object context 的按钮。为了凸显学生,我们用randomElement()从数组中随机选择姓和名。

把下面这个按钮添加到List:

1
2
3
4
5
6
7
8
9
Button("Add") {
let firstNames = ["Ginny", "Harry", "Hermione", "Luna", "Ron"]
let lastNames = ["Granger", "Lovegood", "Potter", "Weasley"]

let chosenFirstName = firstNames.randomElement()!
let chosenLastName = lastNames.randomElement()!

// 更多代码
}

接下来是有趣的部分:我们将创建一个Student对象 —— 用 Core Data 为我们生成的类,它需要被附着到一个 managed object context,以便对象知道自己应当被存储在哪里。我们可以像操作常规结构体一样给它的属性赋值。

把下面三行代码添加到按钮的 action 闭包:

1
2
3
let student = Student(context: self.moc)
student.id = UUID()
student.name = "\(chosenFirstName) \(chosenLastName)"

最后,我们需要让 managed object context 保存这个对象。这是一个可能抛出错误的函数调用,因为理论上它可能失败。实践上,我们做的事情几乎没有机会失败,所以我们可以用try?来调用 —— 我们不关心错误捕获。

把最后一行代码添加到按钮的 action:

try? self.moc.save()

删除 Core Data 对象

从一个 Core Data fetech request 中删除对象

像常规的数据数组一样,大部分的工作是通过附加一个onDelete(perform:)modifierForEach来实现的,不过相对于从数组中移除项,我们需要从 fetch 请求中找到请求的对象,然后对其调用我们的 managed object contextdelete()方法。一旦目标对象都删除完成,我们需要触发 context 的保存。如果不这么做,变更不会被实际写入磁盘。把下面这个方法添加到ContentView:

1
2
3
4
5
6
7
8
9
10
11
12
func deleteBooks(at offsets: IndexSet) {
for offset in offsets {
// find this book in our fetch request
let book = books[offset]

// delete it from the context
moc.delete(book)
}

// save the context
try? moc.save()
}

注意,onDelete(perform:)modifier 是附着在ForEach而不是List。

Core Data ForEach .self 的工作机制

为什么 ForEach 可以使用 .self

之前我们了解了使用ForEach来创建动态视图的不同方式,它们都有一个共同点:SwiftUI 需要知道如何唯一识别动态视图的每一项,以便正确地动画化改变。

如果一个对象遵循Identifiable协议,那么 SwiftUI 会自动使用它的id属性作为唯一标识。如果我们不使用Identifiable,那就需要指定一个我们知道是唯一的属性的 key path,比如图书的 ISBN 号。但假如我们不遵循Identifiable也没有唯一的 key path,我们通常会使用\.self

我们对原始数据类型,例如Int和String使用过\.self,就像下面这样:

1
2
3
4
5
List {
ForEach([2, 4, 6, 8, 10], id: \.self) {
Text("\($0) is even")
}
}

对于 Core Data 为我们生成托管类,我们也使用\.self,当时我没有解释这是如何工作的,或者说它究竟是如何与我们的ForEach关联的。不过今天我要再来讨论这个问题,因为我觉得这会给你提供一些有助益的洞察。

当我们使用\.self作为标识符时,我们指的是“整个对象”,但实践上这个指代并没有包含太多的含义 —— 一个结构体就是结构体,除了内容之外,它并没有包含任何特定的标识信息。因此,实际发生的事情是,Swift 计算了结构体的哈希值 —— 一种以固定长度的值表示复杂数据的方法 —— 然后以哈希值作为标识符。

哈希值可以以很多种方法生成,但所有方法的背后的概念是一致的:

  1. 无论输入的数据多大,输出总是固定大小。
  2. 对同一个对象计算两次哈希值,应该返回相同的值。

哈希常见于数据校验。Xcode 为我们生成托管对象的类时,它会让这些类遵循Hashable,这是一个表示 Swift 可以从中生成哈希值的协议,也就是说,我们可以用它的\.self作为标识符。这也是为什么StringInt可以用\.self的原因:它们也遵循Hashable

你可能想,这样会导致问题吧:如果我们用相同的值创建了两个 Core Data 对象,它们会生成相同的哈希值,这样我们就遇到问题了。不过,其实 Core Data 是一种很聪明的方式来工作:它为我们创建的对象实际上有一组我们定义数据模型时定义的属性之外的其他属性,包括一个叫 ID 的对象 —— 这是一个可以唯一标识对象的标识符,不管对象包含的属性是什么。这些 ID 类似于 UUID,在我们创建对象时,Core Data 顺序产生它们。

因此,\.self适用于所有遵循Hashable的类,因为 Swift 会为其生成哈希值并用该值作为对象的唯一标识。这对于 Core Data 的对象同样适用,因为它们已经遵循了Hashable

创建 NSManagedObject 子类

Xcode 会为我们生成两个文件,但我们只关心其中的一个:Movie+CoreDataProperties.swift。在那个文件里,你会看到下面三行代码:

1
2
3
@NSManaged public var title: String?
@NSManaged public var director: String?
@NSManaged public var year: Int16

@NSManaged 并不是一个属性包装器 —— 它比 SwiftUI 的属性包装器要古老的多。实际上,它其实稍稍揭示了 Core Data 内部的工作机制:相对于直接存在于类中作为属性,它们实际上是由 Core Data 存储在一个字典中,由 Core Data 读取和写入。当我们读取或者写入标记了@NSManaged的属性时,Core Data 缓存这些操作,然后在内部处理 —— 远非一个简单的 Swift 字符串。

现在,当你凝视着代码,然后在想“我可不想要这些可选型”,然后把代码改成下面这样:

1
2
3
@NSManaged public var title: String
@NSManaged public var director: String
@NSManaged public var year: Int16

你可能会注意有些事情变得有点奇怪:尽管我们的属性都已经不是可选型了,我们仍然可以不提供任何值而创建出一个Movie类的实例。这本来应该是不可能的:这些属性并不是可选型,意味着它们必须一直有值,而我们却可以不给值创建它们。

发生的事情可以透露出@NSManaged的魔法,这些并非实际的属性,结果@NSManaged让我们做到了本不能做到的事情。事实上,这么做是优雅的,尤其对于小型的 Core Data 项目或者对于学习者来说。但是,有一个更深层次的问题:Core Data 是懒加载的

还记得 Swift 的lazy关键字吧,它可以让我们延迟实际的初始化,直到我们实际需要用到的时刻。Core Data 的做法差不多,只是针对数据:有的时候,有些数据看似已经加载了,但实际上没有。因此 Core Data 会尝试最小化它的内存占用。Core Data 把这值称为堕值,取义 “断层” —— 一条介于某样东西实际存在和某样东西只是占位符的断层。

对于这些堕值,我们并不需要做什么实际的工作,因为只要我们尝试读取这些值 Core Data 会透明地为我们获取实际的数据并且返回给我们 —— 这是@NSManaged的又一好处。不过,当我们开始在这些 Core Data 的属性上游走的时候,我们要承担暴露其软肋的风险。这些东西特别不符合 Swift 预期的工作方式,如果我们尝试回避这个事实,那我们很可能会引入问题 —— 某些我们认为绝对不会是 nil 的值可能在某个时刻突然变成 nil

因此,为了帮助我们更安全地访问这些可选值,你可能会考虑添加一些计算属性,这也能帮助我们把所有的空合操作符放在一个地方处理。例如,给Movie添加下面这个属性,以确保我们总是有一个合法的标题字符串可以使用:

1
2
3
public var wrappedTitle: String {
title ?? "Unknown Title"
}

这样一来,你的代码的其余部分,就不用担心 Core Data 的可选性,如果你想要改变默认值,可以在单个文件里就解决掉。

条件化保存 NSManagedObjectContext

条件化保存 NSManagedObjectContext

我们之前使用NSManagedObjectContextsave()方法把所有未保存的更改 flush 到永久存储中,但我们没有实现的是检查更改是否真的需要被保存。一般来说这不会有问题,因为通常我们是在做出诸如插入或者删除的操作之后,才调用save()方法。

Apple 特别指出,我们应该总是在调用save()之前检查更改,以避免 Core Data 做不必要的工作。

我们可以用两种方式来检查更改。首先,每个托管对象都有一个hasChanges属性,当对象有未保存的改动时,这个属性的值为true,并且整个 context 也有一个hasChanges属性,用于检查 context 所有拥有的对象中,是否有对象包含改动。因此,与其直接调用save(),你可以总是先做一个检查,像这样:

1
2
3
if self.moc.hasChanges {
try? self.moc.save()
}

通过约束确保 Core Data 对象是唯一的

Core Data 提供了 constraints:我们可以指定某个属性被约束,以便它总是唯一的。然后我们就可以构建任意多的对象,在我们请求 Core Data 保存那些对象时,它会解决重复,确保只有一份数据被写入。更好的是,如果已经存储某些数据和约束冲突,我们可以选择如何处理数据合并。

现在,回到 ContentView.swift,提供下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ContentView: View {
@Environment(\.managedObjectContext) var moc

@FetchRequest(entity: Wizard.entity(), sortDescriptors: []) var wizards: FetchedResults<Wizard>

var body: some View {
VStack {
List(wizards, id: \.self) { wizard in
Text(wizard.name ?? "Unknown")
}

Button("Add") {
let wizard = Wizard(context: self.moc)
wizard.name = "Harry Potter"
}

Button("Save") {
do {
try self.moc.save()
} catch {
print(error.localizedDescription)
}
}
}
}
}

你会看到一张显示巫师的清单,以及一个添加巫师的按钮,一个保存的按钮。但你运行应用,你会发现你可以多次点击按钮,多个 “Harry Potter” 滑入表格,但是当你点击 “Save” 时,我们会遭遇一个错误 —— Core Data 检测到冲突,拒绝保存变动。如果你的确希望 Core Data 写入这些变动,你需要打开 SceneDelegate.swift 添加下面这行导入:

import CoreData

然后在willConnectTo方法中添加下面这样,在let context那行后面:

1
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

这会要求 Core Data 基于对象的属性来合并重复的对象 —— 它会用新版本对象的属性智能地覆盖旧版本对象的属性。再次运行应用,现在你可以添加任意多次,不过点下保存按钮时,所有的行会坍缩成一行,因为 Core Data 剔除了所有的重复。