本文为翻译文章,原文地址:

本文指导您如何使用SwiftUI和SwiftData构建一个完整的iOS应用程序。我们将创建一个名为“FaceFacts”的应用,帮助您记住在工作场所、学校、活动中遇到的人的姓名、面孔和个人详细信息。请注意,这个项目需要一些Swift和SwiftUI知识,但我会尽量解释所有SwiftData相关的知识。我们将针对iOS 17,因此您需要Xcode 15或更高版本。

首先,开始一个名为FaceFacts的新iOS项目,选择SwiftUI作为界面。虽然我们将使用SwiftData,但请保留存储选项为无,以免Xcode引入我们不需要的额外代码。

引入SwiftData到项目中需要三个小步骤:定义您要处理的数据,为这些数据创建一些存储,以及在需要的地方读取数据。我们首先设计我们的数据。一开始会很简单,但随着时间的推移我们会添加更多内容。现在,我们将仅存储三个信息:他们的姓名、电子邮件地址,以及您可以添加任何想要的额外信息的自由文本字段。

首先,创建一个名为Person.swift的新Swift文件,我们将用它来存储描述我们应用中一个人的SwiftData类。这意味着需要为SwiftData添加导入,然后添加这个类:

1
2
3
4
5
class Person {
var name: String
var emailAddress: String
var details: String
}

由于这是一个类,您需要为其创建一个初始化器,但在类中输入“in”应该会提示Xcode为您自动创建一个:

1
2
3
4
5
6
7
8
9
10
11
class Person {
var name: String
var emailAddress: String
var details: String

init(name: String, emailAddress: String, details: String) {
self.name = name
self.emailAddress = emailAddress
self.details = details
}
}

目前,这只是一个普通的Swift类,但我们可以通过在开始时添加@Model宏,让SwiftData加载和保存其实例:

1
2
3
4
@Model
class Person {

}

宏允许Swift在编译时重写我们的代码,添加额外的功能。在@Model的情况下,Swift重写了类,使所有属性自动由SwiftData支持 - 这些不再是简单的字符串,而是从SwiftData的存储中读写。

提示:如果您想了解宏对代码的作用,可以右键单击它并选择展开宏。探索完成后,再次右键单击宏并选择隐藏宏展开。

第二步是告诉SwiftData我们想在应用中使用Person类。这是通过为该类创建一个模型容器来完成的,这是SwiftData加载和保存iPhone SSD中数据的方式。

为此,打开FaceFactsApp.swift,给它另一个SwiftData导入,然后向WindowGroup添加modelContainer(for:)修饰符,如下所示:

1
2
3
4
WindowGroup {
ContentView()
}
.modelContainer(for: Person.self)

该代码首次运行时,SwiftData将为我们随时间创建的所有Person对象创建底层存储,但在所有后续运行中,SwiftData将加载所有现有对象并继续操作。幕后,这是一个数据库,但SwiftData在其顶部添加了各种额外的功能,如iCloud同步。

现在第三步是在我们想要使用数据的地方读取一些数据。对我们来说,暂时将是ContentView,所以在那里再添加一个SwiftData导入。

通过SwiftData读取信息是通过@Query宏完成的,在最简单的形式下,它只需要被告知将加载什么类型的数据。对我们来说,这将是我们的Person对象的数组,所以我们可以将此属性添加到ContentView

1
@Query var people: [Person]

这个@Query宏告诉SwiftData加载所有的Person对象到一个数组中,而且这就是全部所需的工作。更好的是,当数据在未来发生变化时,这个数组会自动保持最新状态。

我们稍后会研究排序和过滤,但现在我们已经完成了SwiftData设置代码,可以编写一些SwiftUI代码来在列表中展示这些人。

将默认视图体替换为以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
NavigationStack {
List {
ForEach(people) { person in
NavigationLink(value: person) {
Text(person.name)
}
}
}
.navigationTitle("FaceFacts")
.navigationDestination(for: Person.self) { person in
Text(person.name)
}
}

是的,这会导航到一个简单的Text视图;这只是我们稍后填充内容的占位符。

如果您愿意,现在可以运行应用程序,但我担心它会相当乏味。是的,我们的SwiftData代码已经就绪,我们有一些UI来展示我们所遇到的所有人,但现在实际上没有办法添加人员!

接下来让我们解决这个问题……

添加和编辑

在处理用户数据时,不仅要给他们添加自定义数据的能力,还要能够_编辑_那些数据 - 在不删除并重新添加的情况下更改现有值。

如果您看看苹果的Notes应用是如何解决这个问题的,您会看到它做了一件相当聪明的事情:当您添加一个新笔记时,它会立即创建一个空笔记,然后直接导航到编辑界面 - 它将添加和编辑合并到一个视图中,从而消除了额外的工作。

我们可以在这里采取完全相同的方法:我们可以创建一个方法来创建一个新的、空白的Person对象,然后立即导航到那里进行编辑。

完成这个目标需要几个步骤:

  1. 创建一个用于编辑Person数据的视图。再次强调,我们的Person类现在非常简单,但我们稍后会添加更多内容。
  2. 更改我们现有的NavigationStack,以便我们可以以编程方式控制其路径。
  3. ContentView中编写一个方法,创建该人员然后立即导航到它。
  4. 从工具栏按钮调用该方法。

一旦我们完成这些步骤,我们就可以为我们的数据制作添加和编辑工作 - 我们实际上将有一个可用的应用程序。

首先,按Cmd+N创建一个新的SwiftUI视图,命名为EditPersonView。这需要知道我们正在编辑哪个人,因此我们将添加一个属性来存储该信息:

1
var person: Person

这将立即在视图的预览中导致错误,但我希望您现在只是暂时注释掉预览 - 我们稍后会回来修复这个问题,但现在我想先完成主要应用程序。

因此,只需将整个#Preview宏注释掉:

我们_将_稍后回来修复这个问题,但现在让我们继续添加和编辑。

我们当前的Person类有三个属性我们希望能够编辑:他们的名字、他们的电子邮件地址以及我们要存储的关于他们的一些额外细节。在SwiftUI中,这三者都可以通过TextField处理,但正如您将看到的那样,这里有一个小速陷。

为了看到问题,请开始用以下内容填充body属性:

1
2
3
4
5
6
7
8
Form {
Section {
TextField("Name", text: $person.name)
.textContentType(.name)
}
}
.navigationTitle("Edit Person")
.navigationBarTitleDisplayMode(.inline)

当我们使用本地属性并将其与@State或类似的属性结合使用时,SwiftUI会自动为属性的访问创建三种方式。例如,如果我们有一个名为age的整数,那么:

  1. 直接读取age,我们可以获取或设置整数。
  2. 使用$age,我们访问数据的_binding_,这是我们可以附加到SwiftUI视图的数据的双向连接。例如,如果这个绑定到Stepper上,改变stepper将改变age的值,但改变age的值也会更新stepper。
  3. 如果使用_age我们可以直接访问State属性包装器,这在我们需要以自定义方式初始化它时很有帮助。

在我们当前的代码中,我们没有使用@State,这意味着person属性只是持有一个简单的值 - 它没有为我们使用TextField或其他SwiftUI视图提供绑定的方式。

幸运的是,SwiftUI有一个属性包装器可以自动为对象创建绑定。实际上,它只需要将该属性包装器添加到我们的属性中,问题就可以解决:

1
@Bindable var person: Person

所以,我们的视图仍然期望获得一个Person对象来编辑,但当它被传递时,SwiftUI将自动为我们创建绑定 - 我们现在可以像之前一样使用$person.name了。

有了这个,我们可以继续填写表单的其余部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Form {
Section {
TextField("Name", text: $person.name)
.textContentType(.name)

TextField("Email address", text: $person.emailAddress)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
}

Section("Notes") {
TextField("Details about this person", text: $person.details, axis: .vertical)
}
}

提示: 使用axis: .vertical使得详情文本字段可以在用户输入多于一行时垂直增长。

我们稍后会在这里添加更多内容,但现在足够了。

第二步是更改ContentView中的NavigationStack,以便我们可以以编程方式控制其路径。这意味着创建一些本地状态来存储其路径,然后将该路径绑定到NavigationStack

虽然导航路径可以存储各种不同的对象,但在这里我们只需要一种数据类型,因为我们只是试图展示我们正在编辑的人。所以,我们的路径可以是Person的空数组,像这样:

1
@State private var path = [Person]()

现在我们可以像这样将其绑定到我们的NavigationStack

1
NavigationStack(path: $path) {

这是一个双向绑定,这意味着当用户在视图间导航时,我们的数组会自动更新,如果我们手动更改数组,导航栈也会更新以显示我们请求的数据。

第三步是在ContentView中编写一个方法来创建人员,然后立即导航到它。我们实际上可以将这一步分解为四个小步骤:

  1. 获取访问SwiftData存储信息的位置。
  2. 创建我们的数据。
  3. 告诉SwiftData存储它。
  4. 导航到编辑屏幕。

第一个小步骤直接带我们进入一个重要的SwiftData概念,称为_模型上下文_。

您已经见过_模型容器_了,因为我们在FaceFactsApp.swift中创建了一个 - 它负责从iPhone的永久存储中加载和保存我们的数据。模型上下文有点像数据缓存:从存储中读取和写入所有内容会相当低效,所以SwiftData为我们提供了一个_模型上下文_

模型上下文实际上存储着我们此刻正在处理的所有对象。因此,当我们使用@Query加载对象时,SwiftData会从底层数据库中获取它们,并存储在其模型上下文中。然后我们可以对这些对象进行所有想要的更改,未来某个时刻SwiftData将把这些更改保存回容器。

这种模型上下文的方法让SwiftData可以有效地批量处理工作,同时也意味着当我们创建一个新的Person对象时,我们不会立即将其写入永久存储。相反,我们将其插入到模型上下文中,然后让SwiftData从那里接管。

SwiftData在这方面为我们自动做了几件聪明的事情:

  1. 当我们之前使用modelContainer(for:)修饰符时,SwiftData悄无声息地为我们创建了一个名为_主要_上下文的模型上下文。这始终在Swift的主执行者上运行,因此我们可以安全地从我们的SwiftUI代码中使用它。
  2. 它自动将该模型上下文放入SwiftUI的环境中,以便我们在将来将对象插入其中时可以读取和使用它。
  3. 我们之前使用的@Query宏会自动在SwiftUI的环境中找到模型上下文,并使用它来读取数据。这就是@Query如何能够定位我们所有数据而无需额外工作的原因。

所以,我们的第一个小步骤是获取访问SwiftData存储信息的位置,这意味着我们需要从SwiftUI的环境中读取该模型上下文。这意味着在ContentView中添加一个新属性:

1
@Environment(\.modelContext) var modelContext

我知道,这是一小段代码背后的大量解释,但希望现在您明白这个模型上下文是什么,以及它来自哪里了!

第二个小步骤是创建我们的数据,因此在ContentView中添加以下新方法:

1
2
3
func addPerson() {
let person = Person(name: "", emailAddress: "", details: "")
}

Person对象的所有三个属性提供空文本意味着当我们显示它进行编辑时,用户会看到我们的占位符提示,而不是一些虚拟文本。

我们第三个小步骤是告诉SwiftData存储这个新人物 - 只是创建它是不够的。这意味着将这个人物插入到我们的模型上下文中,这只需要一行代码。在addPerson()方法的末尾添加以下内容:

1
modelContext.insert(person)

现在是最后一个小步骤:既然我们已经创建了一个新人物并将其插入到SwiftData中,我们需要导航到编辑屏幕。这意味着调整我们之前制作的path属性,在addPerson()中之前的两行后添加以下内容:

1
path.append(person)

但是我们还需要调整之前使用的navigationDestination()修饰符,这样我们才能导航到EditPersonView,而不是Text视图:

1
2
3
.navigationDestination(for: Person.self) { person in
EditPersonView(person: person)
}

现在剩下的只是第四个也是最后一个步骤:从工具栏按钮调用addPerson()方法。

navigationDestination()修饰符之后添加以下内容:

1
2
3
.toolbar {
Button("Add Person", systemImage: "plus", action: addPerson)
}

现在去运行应用程序吧,因为它已经运行得相当不错了!您可以:

  • 点击+按钮创建一个新人物。
  • 填写所有他们的详细信息。
  • 返回到原始列表并看到那个人。
  • 点击他们并编辑他们的详细信息。
  • 返回并看到这些编辑反映在列表中。
  • 退出应用程序并重新启动,看到数据正确恢复。

考虑到我们写的实际SwiftData代码很少,它已经为我们做了很多工作。不仅正确地读写了数据,还刷新了SwiftUI的视图,以便它们保持同步。很棒!

删除人物

大多数基于数据库的工具都希望实现四个基本任务:创建、读取、更新和删除,通常简称为CRUD。我们已经完成了其中的前三个,所以我猜我们已经有了CRU。

为了增加额外的D,我们需要写一个方法,根据SwiftUI传入的数据从模型上下文中删除人物,然后将其附加到onDelete()修饰符上,以启用滑动删除等功能。

就像插入对象一样简单地调用modelContext.insert(),_删除_对象只需要调用modelContext.delete(),告诉它确切需要删除的内容。如果您以前用过SwiftUI的onDelete()修饰符,您会知道它传递了一个IndexSet,指示要删除的对象,所以我们可以遍历它并对每个对象调用modelContext.delete() - 现在将其添加到ContentView

1
2
3
4
5
6
func deletePeople(at offsets: IndexSet) {
for offset in offsets {
let person = people[offset]
modelContext.delete(person)
}
}

这可以直接附加到ForEach上的onDelete()修饰符:

1
.onDelete(perform: deletePeople)

这就完成了删除功能!

寻找特别的某人

到目前为止,SwiftData可能看起来非常容易,因为它代表我们处理了很多事情,并且与SwiftUI紧密结合。

好吧,接下来的任务更具挑战性:我们将让用户根据搜索字符串过滤人物列表。这很棘手,因为SwiftData不允许我们动态更改其查询的过滤器;我们需要每次更改搜索文本时构造一个新的查询。

这个问题单独来说不会太难,除了我们无法原地更改@Query属性 - 我们无法从ContentView中调整查询,因为虽然数据可能随时间变化,但查询本身是只读的。

所以,我们将对代码进行一些调整:我们会让ContentView负责处理导航堆栈,包括其标题、导航目的地和工具栏,但然后我们会制作一个专门负责运行SwiftData查询的子视图。这意味着ContentView还可以处理搜索,并且每次我们更改搜索文本时都会重新创建带有其SwiftData查询的子视图。

首先,在ContentView中添加这个新属性,以存储用户想要搜索的文本:

1
@State private var searchText = ""

然后,将其绑定到一个搜索栏,通过在之前添加的工具栏下面添加此修饰符:

1
.searchable(text: $searchText)

现在是棘手的部分:我们需要将ContentView分成两部分,这样查询、ListdeletePeople()部分都进入一个子视图。

首先创建一个名为PeopleView的新SwiftUI视图。一旦完成:

  1. 在PeopleView.swift中添加对SwiftData的导入。
  2. people属性移到那里。
  3. 复制modelContext属性到那里 - 我们需要在ContentView中插入一个新人物,但我们也需要在PeopleView中删除人物。
  4. 将整个deletePeople()方法移动到PeopleView
  5. 将除其修饰符外的整个List代码移动到PeopleViewbody属性中,替换掉其默认代码。
  6. 现在将PeopleView()放在ContentView中原来List代码的位置。

如果您愿意,可以再次运行应用程序,但没有太大必要 - 如果一切按计划进行,它看起来与我们之前的版本相同,因为我们只是稍微移动了一下代码。

然而,这次重组为一个重要目的服务:我们可以为PeopleView创建一个自定义初始化器,接受一个要搜索的字符串,并用它来重新创建其查询。

在SwiftData中过滤查询需要一些非常精确的代码,所以现在我们只放一些占位符代码 - 我想先填写完其余的代码再详细探讨这一点。

所以,现在请为PeopleView添加这个自定义初始化器:

1
2
3
4
5
init(searchString: String = "") {
_people = Query(filter: #Predicate { person in
true
})
}

给搜索字符串一个空字符串的默认值意味着我们不需要更改预览。然而,我们_确实_想从ContentView中传递searchString属性,这样当用户在搜索栏中键入时,它会自动发送到PeopleView

调整ContentView中的代码如下:

1
PeopleView(searchString: searchText)

现在让我们回到那个初始化器。这一步同时做了三件事情,幕后它利用了我见过的一些最先进的Swift代码。

在SwiftData中过滤查询是通过应用一系列_谓词_完成的,这些是我们可以应用于数据中单个对象的测试。SwiftData会向我们提供一个人的数据,我们的任务是返回true,如果这个人应该在最终数组中,或者false,如果不是。

现在,请记住,SwiftData在幕后将我们的所有信息存储在数据库中。那个数据库不知道如何执行Swift代码,所以Swift做了一些相当神奇的事情:它能够将Swift代码转换为结构化查询语言,或者简称_SQL_,这是与数据库通信的语言。

它不能转换_所有_Swift代码,当然,实际上只支持相当有限的Swift子集。然而,一旦你掌握了它的工作原理,你会发现这些谓词工作得非常好。

考虑到这一点,让我们来看看我们之前写的占位符代码:

1
2
3
_people = Query(filter: #Predicate { person in
true
})

那个#Predicate部分是另一个宏,这是它能够重写我们代码的原因。像我说的,幕后这会将所有我们的Swift代码转换为SQL。然而,它不是_直接_做这个转换的 - 如果你右键点击#Predicate并选择展开宏,你会看到它被转换为一个包含PredicateExpressionsPredicate对象。在运行时,它们_然后_被转换为SQL并执行,但最好的部分是所有这些对我们来说都是完全透明的;我们大部分时间都不关心。

第二个有趣的事情是,这段代码接收一个单独的Person对象来检查,然后直接返回true。这意味着我们的过滤器什么都不做,只是允许所有人都显示。显然,我们希望有些更有趣的东西在这里:我们希望只显示那些姓名包含我们正在寻找的字符串的人。

现在,我们_可以_这样写谓词:

1
2
3
_people = Query(filter: #Predicate { person in
person.name.contains(searchString)
})

但这并不理想,因为contains()是区分大小写的。您可能会想到将名字和搜索字符串都转换为小写,像这样:

1
2
3
_people = Query(filter: #Predicate { person in
person.name.lowercased().contains(searchString.lowercased())
})

但这甚至无法编译。请记住,当我说SwiftData只支持有限的Swift子集时?这是一个很好的例子:我们不能在谓词内使用lowercased(),因为它不被支持。

幸运的是,Swift提供了一个很好的替代方法,叫做localizedStandardContains(),它与contains()相同,但默认情况下忽略大小写,并且还忽略_变音符号_,这意味着它忽略了像急音符和长音符这样的东西。

因此,更好地编写谓词如下:

1
2
3
_people = Query(filter: #Predicate { person in
person.name.localizedStandardContains(searchString)
})

这是一个很大的改进,并且_几乎_可以工作。但这里有一个问题:当搜索字符串为空时,我们希望返回所有人,而不是检查某人的名字是否包含一个空字符串。

因此,这个谓词的最终版本将在搜索字符串为空时返回true,否则调用localizedStandardContains(),如下所示:

1
2
3
4
5
6
7
_people = Query(filter: #Predicate { person in
if searchString.isEmpty {
true
} else {
person.name.localizedStandardContains(searchString)
}
})

最后,这个新查询被存储在_people中,这让我们能够访问底层查询本身。如果我们在这里使用没有下划线的people,这意味着我们尝试改变查询产生的数组,而不是查询本身。

现在我们的搜索功能运行良好 - 您可以运行应用程序,添加几个用户,然后使用搜索栏正确地过滤它们。

我们可以做得更好:如果用户键入某人的电子邮件地址或详情的一部分,也应该在过滤中使用。因此,我们真正想要表达的是:“如果名字匹配或者电子邮件地址匹配或者详情匹配,那么返回true。”在Swift中,这意味着使用二进制或运算符||,如下所示:

1
2
3
4
5
6
7
8
9
_people = Query(filter: #Predicate { person in
if searchString.isEmpty {
true
} else {
person.name.localizedStandardContains(searchString)
|| person.emailAddress.localizedStandardContains(searchString)
|| person.details.localizedStandardContains(searchString)
}
})

这是一个小但受欢迎的改进!

提示: SwiftData按照我们编写的顺序评估这些谓词,所以通常最好以高效的顺序安排它们。这可能意味着在较慢的检查之前放置更快的检查,或者将更有效地淘汰对象的检查放在开头 - 这确实取决于您正在构建的应用程序。

对数据排序

现在我们已经实现了搜索,排序部分就容易多了,因为它基于相同的原理 - 就像我们无法动态更改查询的过滤器一样,我们也无法动态更改其排序顺序,因此我们必须将_这个_注入到PeopleView的初始化器中,连同用户的搜索文本一起。

SwiftData中的排序可以以两种不同的方式完成,但我们要使用的方式既简单又强大:我们将向查询传递一个新类型SortDescriptor的数组,其中列出了我们用于排序的属性,以及它们应该按升序还是降序排序。

首先,在ContentView中添加这个属性:

1
@State private var sortOrder = [SortDescriptor(\Person.name)]

这是一个包含单个SortDescriptor的数组,该SortDescriptor包含指向Person.name的键路径 - 我们说我们想按他们的姓名进行排序。我用@State标记了它,这样我们就可以随时间改变它,这正是我们接下来要做的。

为了让用户更改排序顺序,我们将创建一个绑定到sortOrder属性的选择器。在选择器中,我们可以添加各种Text视图,包含我们想要提供的排序选项,但重要的是:每个视图都需要有一个标签,其中包含其匹配的SortDescriptor数组,这将被分配给sortOrder,当该选项被选中时。

我在这里只添加两个选项:按名字字母顺序排序,或按名字反字母顺序排序。在ContentView的工具栏中放入以下代码:

1
2
3
4
5
6
7
8
9
Menu("Sort", systemImage: "arrow.up.arrow.down") {
Picker("Sort", selection: $sortOrder) {
Text("Name (A-Z)")
.tag([SortDescriptor(\Person.name)])

Text("Name (Z-A)")
.tag([SortDescriptor(\Person.name, order: .reverse)])
}
}

提示:Picker包装在Menu中意味着我们在导航栏中获得了一个漂亮的排序图标,而不是看到“Name (A-Z)”在那里。

这为我们提供了控制排序的所有UI,但实际上并没有执行排序。为此,我们需要调整PeopleView的初始化器,使其接受排序描述符数组:

1
init(searchString: String = "", sortOrder: [SortDescriptor<Person>] = []) {

如您所见,SortDescriptor使用了Swift的泛型系统 - 这个数组不只是任何排序描述符,它包含我们的Person类的排序描述符。

为了将该数组应用于我们的查询,我们需要向Query传递第二个参数,这是在谓词之后,像这样:

1
2
3
_people = Query(filter: #Predicate { person in

}, sort: sortOrder)

ContentView中更改初始化器不会破坏我们的代码,因为我们有一个空数组的默认值,但这需要改变 - 我们需要传递我们之前制作的sortOrder属性,如下所示:

1
PeopleView(searchString: searchText, sortOrder: sortOrder)

这样就完成了排序!

时间关系

到目前为止,我们已经制作了一个相当简单的SwiftData应用程序,但现在我想进一步发展,并跟踪用户最初在哪里遇到不同的人。

这意味着添加第二个SwiftData模型称为Event,然后将其链接回我们原来的Person模型。

首先,创建一个名为Event.swift的新Swift文件,在那里添加SwiftData的导入,然后给它以下代码:

1
2
3
4
5
6
7
8
9
10
@Model
class Event {
var name: String
var location: String

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

这是一个不错的开始,但这次我想添加一些额外的内容:我希望每个活动都存储我们在那里遇到的确切人物。这意味着给模型添加一个额外的属性来存储我们在该活动中第一次遇到的所有人:

1
var people = [Person]()

我们还要做相反的事情:我们将让每个Person记住我们第一次遇到他们的活动,如下所示:

1
var metAt: Event?

提示: 我将metAt设为可选,因为最初它不会有值;用户需要在编辑时选择一个活动。

我们还可以扩展初始化器以包含这个额外的值:

1
2
3
4
5
6
init(name: String, emailAddress: String, details: String, metAt: Event? = nil) {
self.name = name
self.emailAddress = emailAddress
self.details = details
self.metAt = metAt
}

这个更改意味着我们的连接两端都在起作用:每个人都知道我们第一次在哪里遇见他们,而每个活动都知道我们在那里遇见的所有人。

此时,SwiftData为我们自动完成了三件非常聪明的事情:

  1. 它看到这两个模型类相互引用,所以它在两者之间创建了一个_关系_ - 如果我们设置了PersonmetAt属性,那个人将自动被添加或从Event模型中适当的people数组中删除。
  2. 由于该关系,它会自动创建所有数据库存储以处理Event对象 - 我们不需要调整modelContainer(for:)修饰符,因为SwiftData可以看到PersonEvent是链接的。
  3. 它将自动为Person类的数据库存储进行升级,以包括我们首次在所有现有人物中添加的空值。

这些都会自动发生 - 我们甚至不需要考虑它们。

添加和编辑活动部分是您之前看到的代码,部分是新代码。首先,简单的部分:创建一个名为EditEventView的新SwiftUI视图,然后给它以下属性,这样它就知道它正在编辑哪个事件:

1
@Bindable var event: Event

这将再次破坏预览代码,而且我希望您只是将其注释掉。别担心,我们稍后会回来修正这个问题,使预览正确工作!

现在您可以用两个文本字段填充视图的主体,如下所示:

1
2
3
4
5
6
Form {
TextField("Name of event", text: $event.name)
TextField("Location", text: $event.location)
}
.navigationTitle("Edit Event")
.navigationBarTitleDisplayMode(.inline)

现在是新工作的时候了:我们需要一种方法将某人与我们第一次遇见他们的活动连接起来,这意味着在EditPersonView中添加一些额外的代码。

首先在文件顶部添加import SwiftData,然后添加以下查询,以读取SwiftData正在管理的所有活动:

1
2
3
4
@Query(sort: [
SortDescriptor(\Event.name),
SortDescriptor(\Event.location)
]) var events: [Event]

那个排序顺序不会动态改变,所以我们可以直接在属性定义中固定它。

当涉及到添加一个活动时,我们将调用一个新的addEvent()方法来处理创建一个新活动、将其插入到SwiftData的模型上下文中,然后立即导航到它进行编辑。该方法的代码马上就会添加,但我们现在至少可以先放一个方法存根 - 现在将其添加到EditPersonView

1
2
3
func addEvent() {

}

对于所有这些的UI,我们将在表单中添加一个新部分,用于使用Picker从现有活动中选择一个,或者选择一个“未知活动”作为新人物的默认选项。我们还将在这个部分中添加一个按钮来调用addEvent(),方便用户轻松访问该屏幕。

在“Notes”部分之前放入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Section("Where did you meet them?") {
Picker("Met at", selection: $person.metAt) {
Text("Unknown event")

if !events.isEmpty {
Divider()

ForEach(events) { event in
Text(event.name)
}
}
}

Button("Add a new event", action: addEvent)
}

现在我们可以回到addEvent()方法:这需要创建一个新事件,将其插入到SwiftData的模型上下文中,然后进行编辑。

这意味着向EditPersonView添加一个新属性,以便我们可以访问主模型上下文:

1
@Environment(\.modelContext) var modelContext

现在我们可以填写addEvent()方法如下:

1
2
3
4
func addEvent() {
let event = Event(name: "", location: "")
modelContext.insert(event)
}

然而,有一个问题:我们如何触发导航到该事件,以便用户可以编辑它?即使我们能够从ContentView访问path属性,我们仍然无法将我们的新事件放入其中,因为它是Person数组 - 它不会接受事件。

SwiftUI为此提供了一个解决方案,而且非常易于使用,我们只需修改一行代码。这个解决方案叫做NavigationPath,它是一种存储多个导航目的地的单一值的方法。如果你想从技术上讲,它是一个类型抹除包装器,围绕我们的导航目的地,意味着它可以持有符合Hashable协议的人物、活动或任何其他东西。

因此,将ContentView中的path属性更改为:

1
@State private var path = NavigationPath()

我们不需要更改其使用方式,因为NavigationPath也有一个append()方法。

这解决了一个问题:我们现在可以将PersonEvent对象都推送到NavigationPath中。现在的第二个问题是:我们如何从EditPersonView内部操作它?

一个简单的选择是将导航路径作为绑定传递给EditPersonView,这样我们就可以直接更改它。这意味着为EditPersonView添加以下新属性:

1
@Binding var navigationPath: NavigationPath

然后更改ContentView中的导航目的地,以便传递路径:

1
2
3
.navigationDestination(for: Person.self) { person in
EditPersonView(person: person, navigationPath: $path)
}

现在我们可以返回到addEvent()并通过在末尾添加额外的一行来正确导航到新事件:

1
navigationPath.append(event)

最后但同样重要的是,我们可以添加另一个navigationDestination()修饰符,这次是在导航到Event时显示EditEventView。我更愿意将其添加到EditPersonView中的表单末尾,因为那里发生了导航,但如果你愿意,也可以放在其他地方:

1
2
3
.navigationDestination(for: Event.self) { event in
EditEventView(event: event)
}

这样,我们就几乎完成了所有代码,但在我们看看还缺少什么之前,我希望你现在就去运行应用程序。

你会注意到两件事:

  1. 当你到达EditPersonView时,Xcode的调试日志会显示“Picker: selection ‘nil’ is invalid and does not have an associated tag, this will give undefined results”并且背景是红色的,这是 SwiftUI 告诉我们我们犯了一个错误。
  2. 如果你添加一个新活动,它会正确地出现在活动选择器中,但选择它实际上不会起作用。

这两个问题都与同一个基本问题相关,并且都有相同的解决方案:我们需要为选择器值附加标签,以便它了解每个选项所指的内容。

因此,将“未知活动”文本更改为:

1
2
Text("Unknown event")
.tag(Optional<Event>.none)

然后将ForEach更改为:

1
2
3
4
ForEach(events) { event in
Text(event.name)
.tag(event)
}

这将消除 Xcode 调试控制台中的错误消息,但仍然不会使选择起作用。这里的问题是一个微妙的问题,当使用 SwiftUI 时经常会困扰开发者:我们的metAt属性是一个可选的Event,而不是一个具体的Event,这使它成为不同的类型。

在底层,Swift 的可选项作为一个名为Optional的泛型枚举实现,它被设计为在其中包装某种值。你可以在“未知活动”文本的标签中看到这一点 - 它不仅仅是nil,因为nil在孤立时没有意义,而是Optional<Event>.none,意味着“一个可以包装事件的可选项,但目前什么都没有。”

在我们当前的ForEach中,我们提供的是非可选事件作为标签,但SwiftData期望存储一个_可选_事件。是的,我们知道这些可选项确实都有值,但类型必须匹配。

因此,为了使选择起作用,我们需要像这样更改标签:

1
2
3
4
ForEach(events) { event in
Text(event.name)
.tag(Optional(event))
}

现在我们已经让这种关系完全起作用了 — 太棒了!

让预览工作

尽管我们在这个应用程序上已经完成了很多工作,但现在我想暂停一下来解决一个悬而未决的问题:我们如何让 Xcode 的预览正常工作?

嗯,你_可能_会想我们可以只是创建一个示例PersonEvent对象然后传递进去,连同一个常量导航路径。对于EditPersonView,这种解决方案看起来像这样:

1
2
3
4
5
6
#Preview {
let person = Person(name: "Dave Lister", emailAddress: "dave@reddwarf.com", details: "")

return EditPersonView(person: person, navigationPath: .constant(NavigationPath()))
.modelContainer(for: Person.self)
}

然而,这段代码不起作用,因为 SwiftData 很狡猾:只要你调用Person初始化器,它就会悄悄寻找当前活跃的模型容器以确保一切配置正确。

在我们的预览代码中,我们在创建示例人物之后才创建模型容器,这意味着我们的预览不会起作用 - 实际上它只会崩溃。

修复这个问题意味着在创建示例数据之前创建一个模型容器,但在这里,我们还想启用一个自定义配置选项,告诉 SwiftData 将其数据仅存储在内存中。这意味着我们插入到模型容器中的任何内容都只是暂时的,这非常适合预览目的。

为此需要很多行代码,因为我们在多个地方需要它,我们将这个功能隔离到一个名为Previewer的新结构中。这将负责设置一个示例容器并创建一些可预览的数据,这样我们就可以在需要预览的地方共享这段代码了。

所以,创建一个名为Previewer.swift的新Swift文件,添加一个对SwiftData的导入,然后给它这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@MainActor
struct Previewer {
let container: ModelContainer
let event: Event
let person: Person

init() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(for: Person.self, configurations: config)

event = Event(name: "Dimension Jump", location: "Nottingham")
person = Person(name: "Dave Lister", emailAddress: "dave@reddwarf.com", details: "", metAt: event)

container.mainContext.insert(person)
}
}

这里有几个重要的细节我想指出:

  1. 因为SwiftData的主上下文总是在主执行者上运行,我们需要用@MainActor注释整个结构体,以确保它也在那里运行。
  2. 使SwiftData将其数据仅存储在内存中意味着使用ModelConfiguration。这里有各种有用的选项,但目前我们只关心它不会永久存储数据。
  3. 我们自己创建模型容器是一个抛出异常的操作,所以我直接将整个初始化器设置为可抛出异常,而不是在这里处理错误。
  4. 创建了两个示例数据,但只有一个被插入。这没问题 - 再次,SwiftData知道这个关系在那里,所以它会同时插入两个。
  5. 容器、人物和活动都存储在属性中,方便外部访问。我将它们全部设为常量,因为一旦创建后就没有意义去改变它们。

有了这个设置,我们现在可以回到EditPersonView并正确地填写它的预览:

1
2
3
4
5
6
7
8
9
10
#Preview {
do {
let previewer = try Previewer()

return EditPersonView(person: previewer.person, navigationPath: .constant(NavigationPath()))
.modelContainer(previewer.container)
} catch {
return Text("Failed to create preview: \(error.localizedDescription)")
}
}

注意我们如何通过发送一些文本来处理错误,但还要注意我们使用的是modelContainer()变体的不同形式 - 我们传入的是从我们的预览器创建的现有容器,而不是在这里创建一个新的。

我们还可以前往EditEventView,它看起来非常相似:

1
2
3
4
5
6
7
8
9
10
#Preview {
do {
let previewer = try Previewer()

return EditEventView(event: previewer.event)
.modelContainer(previewer.container)
} catch {
return Text("Failed to create preview: \(error.localizedDescription)")
}
}

如果您想让所有地方都有良好的预览,那么还应该将ContentView的预览调整为以下内容:

1
2
3
4
5
6
7
8
9
#Preview {
do {
let previewer = try Previewer()
return ContentView()
.modelContainer(previewer.container)
} catch {
return Text("Failed to create preview: \(error.localizedDescription)")
}
}

以及PeopleView的预览:

1
2
3
4
5
6
7
8
9
#Preview {
do {
let previewer = try Previewer()
return PeopleView()
.modelContainer(previewer.container)
} catch {
return Text("Failed to create preview: \(error.localizedDescription)")
}
}

这应该意味着所有您的预览现在应该展示一些有意义的示例数据。

导入照片

我们已经在这个应用程序中做了很多工作,但还有一个重要的功能我想添加:我想让用户能够导入他们所遇到的人的照片,以便更容易记住他们。

SwiftData实际上处理这个问题非常优雅:而不是将大型图像blob直接存储在我们的数据库中,我们可以建议SwiftData将这些属性作为单独的文件存储,然后只在数据库中引用它们的文件名。

我们不需要处理所有这些命名和引用;SwiftData会为我们处理。相反,我们只需要告诉SwiftData,一个特定属性最好作为_外部存储_。这是通过另一个宏完成的,这次是@Attribute,直接附加到我们想要自定义的属性上。

为了将图片写入磁盘,我们需要将它们存储为可选的Data实例。所以,现在在Person中添加这个属性:

1
@Attribute(.externalStorage) var photo: Data?

如您所见,这特别告诉SwiftData这个属性最好存储在外部。请注意,这是一个_建议_ — SwiftData可以根据自己的判断做出最佳决定,但实际上这对我们来说并不重要,因为整个存储系统对我们来说是完全不透明的。

现在我们有了一个地方来存储一个人的照片,我们可以在EditUserView中构建一些UI来选择和显示这张照片。

如果您之前用过SwiftUI的PhotosPicker视图,那么您将知道这是如何完成的,但如果没有,让我们一起走过这些步骤。

首先,我们需要为EditPersonView添加另一个导入,这次是PhotosUI框架:

1
import PhotosUI

我们接着可以添加一个属性到EditPersonView来存储用户的选择:

1
@State private var selectedItem: PhotosPickerItem?

现在我们可以将其绑定到一个PhotosPicker视图上,它将为我们处理所有照片选择UI。将以下部分放在表单的开始处,即在询问个人的姓名或电邮地址之前:

1
2
3
4
5
Section {
PhotosPicker(selection: $selectedItem, matching: .images) {
Label("Select a photo", systemImage: "person")
}
}

处理照片选择时,我们可以直接将loadTransferable(type:)的结果分配给我们正在编辑的人的photo属性,但非常重要的是,这必须在主执行者上安全地进行,以避免线程问题。

因此,将以下方法添加到EditPersonView中,以安全地加载照片数据:

1
2
3
4
5
6
func loadPhoto() {
Task { @MainActor in
person.photo = try await selectedItem?.loadTransferable(type: Data.self)
}
}

这需要在selectedItem属性更改时调用,这意味着在之前添加的navigationDestination()修饰符下方附加一个onChange()修饰符:

1
.onChange(of: selectedItem, loadPhoto)

此时,我们已经完成了选择和加载用户照片所需的所有代码,现在我们只需要将它放在屏幕上的某个地方。SwiftUI的Image视图没有原生的加载图像数据的方法,所以我们需要通过UIImage来转换它。

将以下代码添加到第一Section中,即在PhotosPicker之前:

1
2
3
4
5
if let imageData = person.photo, let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
}

这样就完成了!用户现在可以为人物导入照片,它会自动由SwiftData保存为外部文件。请注意,我们没有为外部存储进行任何特殊处理 - 它都是自动为我们处理的。

最后一件事:将数据存储在iCloud中

在结束这个项目之前,我想添加一个最后的功能:我想让用户将他们的数据上传到iCloud,这样他们就可以在所有设备上拥有所有人和活动的信息。

这实际上相当简单,因为SwiftData几乎为我们处理了所有事情。不过,有一个陷阱:iCloud 有特定的数据要求,SwiftData 没有,我们需要遵守这些要求,才能将我们的数据同步到 iCloud。

首先,选中你的应用的目标,然后转到签名和功能标签。在这里你需要:

  • 点击 + 功能,选择 iCloud。
  • 加载后,勾选旁边的 CloudKit 复选框。
  • 勾选现有 CloudKit 容器旁的框,或按 + 创建一个新容器。容器应该以“iCloud.”开头,后跟你的包标识符,对我来说就是“iCloud.com.hackingwithswift.FaceFacts”。
  • 再次点击 + 功能,这次选择背景模式。
  • 加载后,勾选远程通知旁的框,这样我们的应用可以在云中有新更新时收到通知。

这就完成了配置更改,所以现在我希望你再次运行应用。这次你会看到 Xcode 日志中充满了调试信息,因为 CloudKit 真的很喜欢在那里输出文本。

不过,如果你向上滚动到接近顶部,你会看到一些黄色的警告 - 这些是告诉我们 CloudKit 无法使用,因为我们的模型类不符合它的规则。具体来说,我们需要确保每个属性都有默认值,所有关系都被标记为可选。

在这个应用中,进行所有这些更改实际上非常容易。对于Person,我们可以为所有字符串提供一个空字符串作为默认值:

1
2
3
var name: String = ""
var emailAddress: String = ""
var details: String = ""

对于我们的Event类,我们可以为那里的字符串做同样的事情,然后使people数组变为可选的,如下所示:

1
2
3
var name: String = ""
var location: String = ""
var people: [Person]? = [Person]()

有了这些更改,iCloud 错误将消失,因为我们的项目现在准备好与云同步了。实际上,如果你马上尝试使用它,你会看到它工作得很好,包括同步我们附加到人物上的图片。

现在,这里有一个重要的提示,无论我说多少次,我仍然有人完全忽略它:在模拟器中使用 iCloud 经常出现问题或完全失败,测试同步的唯一可靠方式是使用实际设备。

这并不是说设备上的情况总是完美的 - 开发者生活的灾难之一是可怕的 CloudKit 错误 500,这是苹果的一种方式,表明 SwiftData 同步完全失败了。如果在开发过程中发生这种情况,最好的解决办法是登录 Apple 的 CloudKit 仪表板,选择你正在使用的容器,然后点击重置环境。

下一步呢?

到目前为止,我们已经覆盖了大量内容,并构建了一个使用 SwiftUI、SwiftData、PhotosUI、关系、外部存储、排序、过滤、预览等等的应用程序。

我们仍然可以为这个应用程序添加更多内容。例如,一个起点可能是添加一个TabView,让用户在当前人物列表和替代所有活动的列表之间切换 - 这将允许他们看到在特定活动中遇到的每个人,但也可以编辑和删除现有活动。

您还可以让用户跟踪他们遇见某人的日期,或将不同的人联系在一起,或添加更多的排序选项,或添加一个在 iPad 上更好工作的 UI,或者使用 SwiftUI 的 ContentUnavailableView 在应用程序启动时没有添加任何人时显示一些有意义的内容,等等 - 还有很多潜在的发展方向可以探索,我希望您能够继续进行!

例如,您可以添加功能来允许用户分享或导出他们的数据,可能是以 JSON 或 CSV 格式。这对于数据备份和跨平台使用可能非常有用。您还可以考虑加入更多的个性化元素,比如让用户自定义应用的主题或外观。

此外,增强应用的交互性也是一个不错的方向。例如,您可以添加更多的动画和过渡效果,或者实现更复杂的用户界面元素,如可拖动的列表项或者自定义的滑动操作。

最后,随着技术的发展,总会有新的 Swift 或 SwiftUI 特性出现,这些新特性可能会为您的应用带来更多的可能性。例如,Apple 可能会发布新的界面组件或者改进现有的数据绑定机制,您可以利用这些新特性来提升用户体验或简化代码。

总之,虽然我们已经完成了一个功能丰富的应用,但总有更多的机会可以探索和实现。无论您选择哪条路,重要的是不断学习和实验,不断改进和扩展您的应用功能。在这个过程中,不仅您的应用会变得更加强大,您作为一个开发者的技能和经验也将得到提升。祝您编程愉快!