如何使用SwiftData?如何与SwiftUI配合使用
本文为翻译文章,原文地址:
本文指导您如何使用SwiftUI和SwiftData构建一个完整的iOS应用程序。我们将创建一个名为“FaceFacts”的应用,帮助您记住在工作场所、学校、活动中遇到的人的姓名、面孔和个人详细信息。请注意,这个项目需要一些Swift和SwiftUI知识,但我会尽量解释所有SwiftData相关的知识。我们将针对iOS 17,因此您需要Xcode 15或更高版本。
首先,开始一个名为FaceFacts的新iOS项目,选择SwiftUI作为界面。虽然我们将使用SwiftData,但请保留存储选项为无,以免Xcode引入我们不需要的额外代码。
引入SwiftData到项目中需要三个小步骤:定义您要处理的数据,为这些数据创建一些存储,以及在需要的地方读取数据。我们首先设计我们的数据。一开始会很简单,但随着时间的推移我们会添加更多内容。现在,我们将仅存储三个信息:他们的姓名、电子邮件地址,以及您可以添加任何想要的额外信息的自由文本字段。
首先,创建一个名为Person.swift的新Swift文件,我们将用它来存储描述我们应用中一个人的SwiftData类。这意味着需要为SwiftData添加导入,然后添加这个类:
1 | class Person { |
由于这是一个类,您需要为其创建一个初始化器,但在类中输入“in”应该会提示Xcode为您自动创建一个:
1 | class Person { |
目前,这只是一个普通的Swift类,但我们可以通过在开始时添加@Model
宏,让SwiftData加载和保存其实例:
1 |
|
宏允许Swift在编译时重写我们的代码,添加额外的功能。在@Model
的情况下,Swift重写了类,使所有属性自动由SwiftData支持 - 这些不再是简单的字符串,而是从SwiftData的存储中读写。
提示:如果您想了解宏对代码的作用,可以右键单击它并选择展开宏。探索完成后,再次右键单击宏并选择隐藏宏展开。
第二步是告诉SwiftData我们想在应用中使用Person
类。这是通过为该类创建一个模型容器来完成的,这是SwiftData加载和保存iPhone SSD中数据的方式。
为此,打开FaceFactsApp.swift,给它另一个SwiftData导入,然后向WindowGroup
添加modelContainer(for:)
修饰符,如下所示:
1 | WindowGroup { |
该代码首次运行时,SwiftData将为我们随时间创建的所有Person
对象创建底层存储,但在所有后续运行中,SwiftData将加载所有现有对象并继续操作。幕后,这是一个数据库,但SwiftData在其顶部添加了各种额外的功能,如iCloud同步。
现在第三步是在我们想要使用数据的地方读取一些数据。对我们来说,暂时将是ContentView
,所以在那里再添加一个SwiftData导入。
通过SwiftData读取信息是通过@Query
宏完成的,在最简单的形式下,它只需要被告知将加载什么类型的数据。对我们来说,这将是我们的Person
对象的数组,所以我们可以将此属性添加到ContentView
:
1 | var people: [Person] |
这个@Query
宏告诉SwiftData加载所有的Person
对象到一个数组中,而且这就是全部所需的工作。更好的是,当数据在未来发生变化时,这个数组会自动保持最新状态。
我们稍后会研究排序和过滤,但现在我们已经完成了SwiftData设置代码,可以编写一些SwiftUI代码来在列表中展示这些人。
将默认视图体替换为以下内容:
1 | NavigationStack { |
是的,这会导航到一个简单的Text
视图;这只是我们稍后填充内容的占位符。
如果您愿意,现在可以运行应用程序,但我担心它会相当乏味。是的,我们的SwiftData代码已经就绪,我们有一些UI来展示我们所遇到的所有人,但现在实际上没有办法添加人员!
接下来让我们解决这个问题……
添加和编辑
在处理用户数据时,不仅要给他们添加自定义数据的能力,还要能够_编辑_那些数据 - 在不删除并重新添加的情况下更改现有值。
如果您看看苹果的Notes应用是如何解决这个问题的,您会看到它做了一件相当聪明的事情:当您添加一个新笔记时,它会立即创建一个空笔记,然后直接导航到编辑界面 - 它将添加和编辑合并到一个视图中,从而消除了额外的工作。
我们可以在这里采取完全相同的方法:我们可以创建一个方法来创建一个新的、空白的Person
对象,然后立即导航到那里进行编辑。
完成这个目标需要几个步骤:
- 创建一个用于编辑
Person
数据的视图。再次强调,我们的Person
类现在非常简单,但我们稍后会添加更多内容。 - 更改我们现有的
NavigationStack
,以便我们可以以编程方式控制其路径。 - 在
ContentView
中编写一个方法,创建该人员然后立即导航到它。 - 从工具栏按钮调用该方法。
一旦我们完成这些步骤,我们就可以为我们的数据制作添加和编辑工作 - 我们实际上将有一个可用的应用程序。
首先,按Cmd+N创建一个新的SwiftUI视图,命名为EditPersonView
。这需要知道我们正在编辑哪个人,因此我们将添加一个属性来存储该信息:
1 | var person: Person |
这将立即在视图的预览中导致错误,但我希望您现在只是暂时注释掉预览 - 我们稍后会回来修复这个问题,但现在我想先完成主要应用程序。
因此,只需将整个#Preview
宏注释掉:
我们_将_稍后回来修复这个问题,但现在让我们继续添加和编辑。
我们当前的Person
类有三个属性我们希望能够编辑:他们的名字、他们的电子邮件地址以及我们要存储的关于他们的一些额外细节。在SwiftUI中,这三者都可以通过TextField
处理,但正如您将看到的那样,这里有一个小速陷。
为了看到问题,请开始用以下内容填充body
属性:
1 | Form { |
当我们使用本地属性并将其与@State
或类似的属性结合使用时,SwiftUI会自动为属性的访问创建三种方式。例如,如果我们有一个名为age
的整数,那么:
- 直接读取
age
,我们可以获取或设置整数。 - 使用
$age
,我们访问数据的_binding_,这是我们可以附加到SwiftUI视图的数据的双向连接。例如,如果这个绑定到Stepper
上,改变stepper将改变age
的值,但改变age
的值也会更新stepper。 - 如果使用
_age
我们可以直接访问State
属性包装器,这在我们需要以自定义方式初始化它时很有帮助。
在我们当前的代码中,我们没有使用@State
,这意味着person
属性只是持有一个简单的值 - 它没有为我们使用TextField
或其他SwiftUI视图提供绑定的方式。
幸运的是,SwiftUI有一个属性包装器可以自动为对象创建绑定。实际上,它只需要将该属性包装器添加到我们的属性中,问题就可以解决:
1 | var person: Person |
所以,我们的视图仍然期望获得一个Person
对象来编辑,但当它被传递时,SwiftUI将自动为我们创建绑定 - 我们现在可以像之前一样使用$person.name
了。
有了这个,我们可以继续填写表单的其余部分:
1 | Form { |
提示: 使用axis: .vertical
使得详情文本字段可以在用户输入多于一行时垂直增长。
我们稍后会在这里添加更多内容,但现在足够了。
第二步是更改ContentView
中的NavigationStack
,以便我们可以以编程方式控制其路径。这意味着创建一些本地状态来存储其路径,然后将该路径绑定到NavigationStack
。
虽然导航路径可以存储各种不同的对象,但在这里我们只需要一种数据类型,因为我们只是试图展示我们正在编辑的人。所以,我们的路径可以是Person
的空数组,像这样:
1 | private var path = [Person]() |
现在我们可以像这样将其绑定到我们的NavigationStack
:
1 | NavigationStack(path: $path) { |
这是一个双向绑定,这意味着当用户在视图间导航时,我们的数组会自动更新,如果我们手动更改数组,导航栈也会更新以显示我们请求的数据。
第三步是在ContentView
中编写一个方法来创建人员,然后立即导航到它。我们实际上可以将这一步分解为四个小步骤:
- 获取访问SwiftData存储信息的位置。
- 创建我们的数据。
- 告诉SwiftData存储它。
- 导航到编辑屏幕。
第一个小步骤直接带我们进入一个重要的SwiftData概念,称为_模型上下文_。
您已经见过_模型容器_了,因为我们在FaceFactsApp.swift中创建了一个 - 它负责从iPhone的永久存储中加载和保存我们的数据。模型上下文有点像数据缓存:从存储中读取和写入所有内容会相当低效,所以SwiftData为我们提供了一个_模型上下文_
模型上下文实际上存储着我们此刻正在处理的所有对象。因此,当我们使用@Query
加载对象时,SwiftData会从底层数据库中获取它们,并存储在其模型上下文中。然后我们可以对这些对象进行所有想要的更改,未来某个时刻SwiftData将把这些更改保存回容器。
这种模型上下文的方法让SwiftData可以有效地批量处理工作,同时也意味着当我们创建一个新的Person
对象时,我们不会立即将其写入永久存储。相反,我们将其插入到模型上下文中,然后让SwiftData从那里接管。
SwiftData在这方面为我们自动做了几件聪明的事情:
- 当我们之前使用
modelContainer(for:)
修饰符时,SwiftData悄无声息地为我们创建了一个名为_主要_上下文的模型上下文。这始终在Swift的主执行者上运行,因此我们可以安全地从我们的SwiftUI代码中使用它。 - 它自动将该模型上下文放入SwiftUI的环境中,以便我们在将来将对象插入其中时可以读取和使用它。
- 我们之前使用的
@Query
宏会自动在SwiftUI的环境中找到模型上下文,并使用它来读取数据。这就是@Query
如何能够定位我们所有数据而无需额外工作的原因。
所以,我们的第一个小步骤是获取访问SwiftData存储信息的位置,这意味着我们需要从SwiftUI的环境中读取该模型上下文。这意味着在ContentView
中添加一个新属性:
1 | var modelContext (\.modelContext) |
我知道,这是一小段代码背后的大量解释,但希望现在您明白这个模型上下文是什么,以及它来自哪里了!
第二个小步骤是创建我们的数据,因此在ContentView
中添加以下新方法:
1 | func addPerson() { |
为Person
对象的所有三个属性提供空文本意味着当我们显示它进行编辑时,用户会看到我们的占位符提示,而不是一些虚拟文本。
我们第三个小步骤是告诉SwiftData存储这个新人物 - 只是创建它是不够的。这意味着将这个人物插入到我们的模型上下文中,这只需要一行代码。在addPerson()
方法的末尾添加以下内容:
1 | modelContext.insert(person) |
现在是最后一个小步骤:既然我们已经创建了一个新人物并将其插入到SwiftData中,我们需要导航到编辑屏幕。这意味着调整我们之前制作的path
属性,在addPerson()
中之前的两行后添加以下内容:
1 | path.append(person) |
但是我们还需要调整之前使用的navigationDestination()
修饰符,这样我们才能导航到EditPersonView
,而不是Text
视图:
1 | .navigationDestination(for: Person.self) { person in |
现在剩下的只是第四个也是最后一个步骤:从工具栏按钮调用addPerson()
方法。
在navigationDestination()
修饰符之后添加以下内容:
1 | .toolbar { |
现在去运行应用程序吧,因为它已经运行得相当不错了!您可以:
- 点击+按钮创建一个新人物。
- 填写所有他们的详细信息。
- 返回到原始列表并看到那个人。
- 点击他们并编辑他们的详细信息。
- 返回并看到这些编辑反映在列表中。
- 退出应用程序并重新启动,看到数据正确恢复。
考虑到我们写的实际SwiftData代码很少,它已经为我们做了很多工作。不仅正确地读写了数据,还刷新了SwiftUI的视图,以便它们保持同步。很棒!
删除人物
大多数基于数据库的工具都希望实现四个基本任务:创建、读取、更新和删除,通常简称为CRUD。我们已经完成了其中的前三个,所以我猜我们已经有了CRU。
为了增加额外的D,我们需要写一个方法,根据SwiftUI传入的数据从模型上下文中删除人物,然后将其附加到onDelete()
修饰符上,以启用滑动删除等功能。
就像插入对象一样简单地调用modelContext.insert()
,_删除_对象只需要调用modelContext.delete()
,告诉它确切需要删除的内容。如果您以前用过SwiftUI的onDelete()
修饰符,您会知道它传递了一个IndexSet
,指示要删除的对象,所以我们可以遍历它并对每个对象调用modelContext.delete()
- 现在将其添加到ContentView
:
1 | func deletePeople(at offsets: IndexSet) { |
这可以直接附加到ForEach
上的onDelete()
修饰符:
1 | .onDelete(perform: deletePeople) |
这就完成了删除功能!
寻找特别的某人
到目前为止,SwiftData可能看起来非常容易,因为它代表我们处理了很多事情,并且与SwiftUI紧密结合。
好吧,接下来的任务更具挑战性:我们将让用户根据搜索字符串过滤人物列表。这很棘手,因为SwiftData不允许我们动态更改其查询的过滤器;我们需要每次更改搜索文本时构造一个新的查询。
这个问题单独来说不会太难,除了我们无法原地更改@Query
属性 - 我们无法从ContentView
中调整查询,因为虽然数据可能随时间变化,但查询本身是只读的。
所以,我们将对代码进行一些调整:我们会让ContentView
负责处理导航堆栈,包括其标题、导航目的地和工具栏,但然后我们会制作一个专门负责运行SwiftData查询的子视图。这意味着ContentView
还可以处理搜索,并且每次我们更改搜索文本时都会重新创建带有其SwiftData查询的子视图。
首先,在ContentView
中添加这个新属性,以存储用户想要搜索的文本:
1 | private var searchText = "" |
然后,将其绑定到一个搜索栏,通过在之前添加的工具栏下面添加此修饰符:
1 | .searchable(text: $searchText) |
现在是棘手的部分:我们需要将ContentView
分成两部分,这样查询、List
和deletePeople()
部分都进入一个子视图。
首先创建一个名为PeopleView
的新SwiftUI视图。一旦完成:
- 在PeopleView.swift中添加对SwiftData的导入。
- 将
people
属性移到那里。 - 复制
modelContext
属性到那里 - 我们需要在ContentView
中插入一个新人物,但我们也需要在PeopleView
中删除人物。 - 将整个
deletePeople()
方法移动到PeopleView
。 - 将除其修饰符外的整个
List
代码移动到PeopleView
的body
属性中,替换掉其默认代码。 - 现在将
PeopleView()
放在ContentView
中原来List
代码的位置。
如果您愿意,可以再次运行应用程序,但没有太大必要 - 如果一切按计划进行,它看起来与我们之前的版本相同,因为我们只是稍微移动了一下代码。
然而,这次重组为一个重要目的服务:我们可以为PeopleView
创建一个自定义初始化器,接受一个要搜索的字符串,并用它来重新创建其查询。
在SwiftData中过滤查询需要一些非常精确的代码,所以现在我们只放一些占位符代码 - 我想先填写完其余的代码再详细探讨这一点。
所以,现在请为PeopleView
添加这个自定义初始化器:
1 | init(searchString: String = "") { |
给搜索字符串一个空字符串的默认值意味着我们不需要更改预览。然而,我们_确实_想从ContentView
中传递searchString
属性,这样当用户在搜索栏中键入时,它会自动发送到PeopleView
。
调整ContentView
中的代码如下:
1 | PeopleView(searchString: searchText) |
现在让我们回到那个初始化器。这一步同时做了三件事情,幕后它利用了我见过的一些最先进的Swift代码。
在SwiftData中过滤查询是通过应用一系列_谓词_完成的,这些是我们可以应用于数据中单个对象的测试。SwiftData会向我们提供一个人的数据,我们的任务是返回true,如果这个人应该在最终数组中,或者false,如果不是。
现在,请记住,SwiftData在幕后将我们的所有信息存储在数据库中。那个数据库不知道如何执行Swift代码,所以Swift做了一些相当神奇的事情:它能够将Swift代码转换为结构化查询语言,或者简称_SQL_,这是与数据库通信的语言。
它不能转换_所有_Swift代码,当然,实际上只支持相当有限的Swift子集。然而,一旦你掌握了它的工作原理,你会发现这些谓词工作得非常好。
考虑到这一点,让我们来看看我们之前写的占位符代码:
1 | _people = Query(filter: #Predicate { person in |
那个#Predicate
部分是另一个宏,这是它能够重写我们代码的原因。像我说的,幕后这会将所有我们的Swift代码转换为SQL。然而,它不是_直接_做这个转换的 - 如果你右键点击#Predicate
并选择展开宏,你会看到它被转换为一个包含PredicateExpressions
的Predicate
对象。在运行时,它们_然后_被转换为SQL并执行,但最好的部分是所有这些对我们来说都是完全透明的;我们大部分时间都不关心。
第二个有趣的事情是,这段代码接收一个单独的Person
对象来检查,然后直接返回true
。这意味着我们的过滤器什么都不做,只是允许所有人都显示。显然,我们希望有些更有趣的东西在这里:我们希望只显示那些姓名包含我们正在寻找的字符串的人。
现在,我们_可以_这样写谓词:
1 | _people = Query(filter: #Predicate { person in |
但这并不理想,因为contains()
是区分大小写的。您可能会想到将名字和搜索字符串都转换为小写,像这样:
1 | _people = Query(filter: #Predicate { person in |
但这甚至无法编译。请记住,当我说SwiftData只支持有限的Swift子集时?这是一个很好的例子:我们不能在谓词内使用lowercased()
,因为它不被支持。
幸运的是,Swift提供了一个很好的替代方法,叫做localizedStandardContains()
,它与contains()
相同,但默认情况下忽略大小写,并且还忽略_变音符号_,这意味着它忽略了像急音符和长音符这样的东西。
因此,更好地编写谓词如下:
1 | _people = Query(filter: #Predicate { person in |
这是一个很大的改进,并且_几乎_可以工作。但这里有一个问题:当搜索字符串为空时,我们希望返回所有人,而不是检查某人的名字是否包含一个空字符串。
因此,这个谓词的最终版本将在搜索字符串为空时返回true,否则调用localizedStandardContains()
,如下所示:
1 | _people = Query(filter: #Predicate { person in |
最后,这个新查询被存储在_people
中,这让我们能够访问底层查询本身。如果我们在这里使用没有下划线的people
,这意味着我们尝试改变查询产生的数组,而不是查询本身。
现在我们的搜索功能运行良好 - 您可以运行应用程序,添加几个用户,然后使用搜索栏正确地过滤它们。
我们可以做得更好:如果用户键入某人的电子邮件地址或详情的一部分,也应该在过滤中使用。因此,我们真正想要表达的是:“如果名字匹配或者电子邮件地址匹配或者详情匹配,那么返回true。”在Swift中,这意味着使用二进制或运算符||
,如下所示:
1 | _people = Query(filter: #Predicate { person in |
这是一个小但受欢迎的改进!
提示: SwiftData按照我们编写的顺序评估这些谓词,所以通常最好以高效的顺序安排它们。这可能意味着在较慢的检查之前放置更快的检查,或者将更有效地淘汰对象的检查放在开头 - 这确实取决于您正在构建的应用程序。
对数据排序
现在我们已经实现了搜索,排序部分就容易多了,因为它基于相同的原理 - 就像我们无法动态更改查询的过滤器一样,我们也无法动态更改其排序顺序,因此我们必须将_这个_注入到PeopleView
的初始化器中,连同用户的搜索文本一起。
SwiftData中的排序可以以两种不同的方式完成,但我们要使用的方式既简单又强大:我们将向查询传递一个新类型SortDescriptor
的数组,其中列出了我们用于排序的属性,以及它们应该按升序还是降序排序。
首先,在ContentView
中添加这个属性:
1 | private var sortOrder = [SortDescriptor(\Person.name)] |
这是一个包含单个SortDescriptor
的数组,该SortDescriptor
包含指向Person.name
的键路径 - 我们说我们想按他们的姓名进行排序。我用@State
标记了它,这样我们就可以随时间改变它,这正是我们接下来要做的。
为了让用户更改排序顺序,我们将创建一个绑定到sortOrder
属性的选择器。在选择器中,我们可以添加各种Text
视图,包含我们想要提供的排序选项,但重要的是:每个视图都需要有一个标签,其中包含其匹配的SortDescriptor
数组,这将被分配给sortOrder
,当该选项被选中时。
我在这里只添加两个选项:按名字字母顺序排序,或按名字反字母顺序排序。在ContentView
的工具栏中放入以下代码:
1 | Menu("Sort", systemImage: "arrow.up.arrow.down") { |
提示: 将Picker
包装在Menu
中意味着我们在导航栏中获得了一个漂亮的排序图标,而不是看到“Name (A-Z)”在那里。
这为我们提供了控制排序的所有UI,但实际上并没有执行排序。为此,我们需要调整PeopleView
的初始化器,使其接受排序描述符数组:
1 | init(searchString: String = "", sortOrder: [SortDescriptor<Person>] = []) { |
如您所见,SortDescriptor
使用了Swift的泛型系统 - 这个数组不只是任何排序描述符,它包含我们的Person
类的排序描述符。
为了将该数组应用于我们的查询,我们需要向Query
传递第二个参数,这是在谓词之后,像这样:
1 | _people = Query(filter: #Predicate { person in |
在ContentView
中更改初始化器不会破坏我们的代码,因为我们有一个空数组的默认值,但这需要改变 - 我们需要传递我们之前制作的sortOrder
属性,如下所示:
1 | PeopleView(searchString: searchText, sortOrder: sortOrder) |
这样就完成了排序!
时间关系
到目前为止,我们已经制作了一个相当简单的SwiftData应用程序,但现在我想进一步发展,并跟踪用户最初在哪里遇到不同的人。
这意味着添加第二个SwiftData模型称为Event
,然后将其链接回我们原来的Person
模型。
首先,创建一个名为Event.swift的新Swift文件,在那里添加SwiftData的导入,然后给它以下代码:
1 |
|
这是一个不错的开始,但这次我想添加一些额外的内容:我希望每个活动都存储我们在那里遇到的确切人物。这意味着给模型添加一个额外的属性来存储我们在该活动中第一次遇到的所有人:
1 | var people = [Person]() |
我们还要做相反的事情:我们将让每个Person
记住我们第一次遇到他们的活动,如下所示:
1 | var metAt: Event? |
提示: 我将metAt
设为可选,因为最初它不会有值;用户需要在编辑时选择一个活动。
我们还可以扩展初始化器以包含这个额外的值:
1 | init(name: String, emailAddress: String, details: String, metAt: Event? = nil) { |
这个更改意味着我们的连接两端都在起作用:每个人都知道我们第一次在哪里遇见他们,而每个活动都知道我们在那里遇见的所有人。
此时,SwiftData为我们自动完成了三件非常聪明的事情:
- 它看到这两个模型类相互引用,所以它在两者之间创建了一个_关系_ - 如果我们设置了
Person
的metAt
属性,那个人将自动被添加或从Event
模型中适当的people
数组中删除。 - 由于该关系,它会自动创建所有数据库存储以处理
Event
对象 - 我们不需要调整modelContainer(for:)
修饰符,因为SwiftData可以看到Person
和Event
是链接的。 - 它将自动为
Person
类的数据库存储进行升级,以包括我们首次在所有现有人物中添加的空值。
这些都会自动发生 - 我们甚至不需要考虑它们。
添加和编辑活动部分是您之前看到的代码,部分是新代码。首先,简单的部分:创建一个名为EditEventView
的新SwiftUI视图,然后给它以下属性,这样它就知道它正在编辑哪个事件:
1 | var event: Event |
这将再次破坏预览代码,而且我希望您只是将其注释掉。别担心,我们稍后会回来修正这个问题,使预览正确工作!
现在您可以用两个文本字段填充视图的主体,如下所示:
1 | Form { |
现在是新工作的时候了:我们需要一种方法将某人与我们第一次遇见他们的活动连接起来,这意味着在EditPersonView
中添加一些额外的代码。
首先在文件顶部添加import SwiftData
,然后添加以下查询,以读取SwiftData正在管理的所有活动:
1 | (sort: [ |
那个排序顺序不会动态改变,所以我们可以直接在属性定义中固定它。
当涉及到添加一个活动时,我们将调用一个新的addEvent()
方法来处理创建一个新活动、将其插入到SwiftData的模型上下文中,然后立即导航到它进行编辑。该方法的代码马上就会添加,但我们现在至少可以先放一个方法存根 - 现在将其添加到EditPersonView
:
1 | func addEvent() { |
对于所有这些的UI,我们将在表单中添加一个新部分,用于使用Picker
从现有活动中选择一个,或者选择一个“未知活动”作为新人物的默认选项。我们还将在这个部分中添加一个按钮来调用addEvent()
,方便用户轻松访问该屏幕。
在“Notes”部分之前放入以下内容:
1 | Section("Where did you meet them?") { |
现在我们可以回到addEvent()
方法:这需要创建一个新事件,将其插入到SwiftData的模型上下文中,然后进行编辑。
这意味着向EditPersonView
添加一个新属性,以便我们可以访问主模型上下文:
1 | var modelContext (\.modelContext) |
现在我们可以填写addEvent()
方法如下:
1 | func addEvent() { |
然而,有一个问题:我们如何触发导航到该事件,以便用户可以编辑它?即使我们能够从ContentView
访问path
属性,我们仍然无法将我们的新事件放入其中,因为它是Person
数组 - 它不会接受事件。
SwiftUI为此提供了一个解决方案,而且非常易于使用,我们只需修改一行代码。这个解决方案叫做NavigationPath
,它是一种存储多个导航目的地的单一值的方法。如果你想从技术上讲,它是一个类型抹除包装器,围绕我们的导航目的地,意味着它可以持有符合Hashable
协议的人物、活动或任何其他东西。
因此,将ContentView
中的path
属性更改为:
1 | private var path = NavigationPath() |
我们不需要更改其使用方式,因为NavigationPath
也有一个append()
方法。
这解决了一个问题:我们现在可以将Person
和Event
对象都推送到NavigationPath
中。现在的第二个问题是:我们如何从EditPersonView
内部操作它?
一个简单的选择是将导航路径作为绑定传递给EditPersonView
,这样我们就可以直接更改它。这意味着为EditPersonView
添加以下新属性:
1 | var navigationPath: NavigationPath |
然后更改ContentView
中的导航目的地,以便传递路径:
1 | .navigationDestination(for: Person.self) { person in |
现在我们可以返回到addEvent()
并通过在末尾添加额外的一行来正确导航到新事件:
1 | navigationPath.append(event) |
最后但同样重要的是,我们可以添加另一个navigationDestination()
修饰符,这次是在导航到Event
时显示EditEventView
。我更愿意将其添加到EditPersonView
中的表单末尾,因为那里发生了导航,但如果你愿意,也可以放在其他地方:
1 | .navigationDestination(for: Event.self) { event in |
这样,我们就几乎完成了所有代码,但在我们看看还缺少什么之前,我希望你现在就去运行应用程序。
你会注意到两件事:
- 当你到达
EditPersonView
时,Xcode的调试日志会显示“Picker: selection ‘nil’ is invalid and does not have an associated tag, this will give undefined results”并且背景是红色的,这是 SwiftUI 告诉我们我们犯了一个错误。 - 如果你添加一个新活动,它会正确地出现在活动选择器中,但选择它实际上不会起作用。
这两个问题都与同一个基本问题相关,并且都有相同的解决方案:我们需要为选择器值附加标签,以便它了解每个选项所指的内容。
因此,将“未知活动”文本更改为:
1 | Text("Unknown event") |
然后将ForEach
更改为:
1 | ForEach(events) { event in |
这将消除 Xcode 调试控制台中的错误消息,但仍然不会使选择起作用。这里的问题是一个微妙的问题,当使用 SwiftUI 时经常会困扰开发者:我们的metAt
属性是一个可选的Event
,而不是一个具体的Event
,这使它成为不同的类型。
在底层,Swift 的可选项作为一个名为Optional
的泛型枚举实现,它被设计为在其中包装某种值。你可以在“未知活动”文本的标签中看到这一点 - 它不仅仅是nil
,因为nil
在孤立时没有意义,而是Optional<Event>.none
,意味着“一个可以包装事件的可选项,但目前什么都没有。”
在我们当前的ForEach
中,我们提供的是非可选事件作为标签,但SwiftData期望存储一个_可选_事件。是的,我们知道这些可选项确实都有值,但类型必须匹配。
因此,为了使选择起作用,我们需要像这样更改标签:
1 | ForEach(events) { event in |
现在我们已经让这种关系完全起作用了 — 太棒了!
让预览工作
尽管我们在这个应用程序上已经完成了很多工作,但现在我想暂停一下来解决一个悬而未决的问题:我们如何让 Xcode 的预览正常工作?
嗯,你_可能_会想我们可以只是创建一个示例Person
或Event
对象然后传递进去,连同一个常量导航路径。对于EditPersonView
,这种解决方案看起来像这样:
1 | #Preview { |
然而,这段代码不起作用,因为 SwiftData 很狡猾:只要你调用Person
初始化器,它就会悄悄寻找当前活跃的模型容器以确保一切配置正确。
在我们的预览代码中,我们在创建示例人物之后才创建模型容器,这意味着我们的预览不会起作用 - 实际上它只会崩溃。
修复这个问题意味着在创建示例数据之前创建一个模型容器,但在这里,我们还想启用一个自定义配置选项,告诉 SwiftData 将其数据仅存储在内存中。这意味着我们插入到模型容器中的任何内容都只是暂时的,这非常适合预览目的。
为此需要很多行代码,因为我们在多个地方需要它,我们将这个功能隔离到一个名为Previewer
的新结构中。这将负责设置一个示例容器并创建一些可预览的数据,这样我们就可以在需要预览的地方共享这段代码了。
所以,创建一个名为Previewer.swift
的新Swift文件,添加一个对SwiftData的导入,然后给它这样的代码:
1 |
|
这里有几个重要的细节我想指出:
- 因为SwiftData的主上下文总是在主执行者上运行,我们需要用
@MainActor
注释整个结构体,以确保它也在那里运行。 - 使SwiftData将其数据仅存储在内存中意味着使用
ModelConfiguration
。这里有各种有用的选项,但目前我们只关心它不会永久存储数据。 - 我们自己创建模型容器是一个抛出异常的操作,所以我直接将整个初始化器设置为可抛出异常,而不是在这里处理错误。
- 创建了两个示例数据,但只有一个被插入。这没问题 - 再次,SwiftData知道这个关系在那里,所以它会同时插入两个。
- 容器、人物和活动都存储在属性中,方便外部访问。我将它们全部设为常量,因为一旦创建后就没有意义去改变它们。
有了这个设置,我们现在可以回到EditPersonView
并正确地填写它的预览:
1 | #Preview { |
注意我们如何通过发送一些文本来处理错误,但还要注意我们使用的是modelContainer()
变体的不同形式 - 我们传入的是从我们的预览器创建的现有容器,而不是在这里创建一个新的。
我们还可以前往EditEventView
,它看起来非常相似:
1 | #Preview { |
如果您想让所有地方都有良好的预览,那么还应该将ContentView
的预览调整为以下内容:
1 | #Preview { |
以及PeopleView
的预览:
1 | #Preview { |
这应该意味着所有您的预览现在应该展示一些有意义的示例数据。
导入照片
我们已经在这个应用程序中做了很多工作,但还有一个重要的功能我想添加:我想让用户能够导入他们所遇到的人的照片,以便更容易记住他们。
SwiftData实际上处理这个问题非常优雅:而不是将大型图像blob直接存储在我们的数据库中,我们可以建议SwiftData将这些属性作为单独的文件存储,然后只在数据库中引用它们的文件名。
我们不需要处理所有这些命名和引用;SwiftData会为我们处理。相反,我们只需要告诉SwiftData,一个特定属性最好作为_外部存储_。这是通过另一个宏完成的,这次是@Attribute
,直接附加到我们想要自定义的属性上。
为了将图片写入磁盘,我们需要将它们存储为可选的Data
实例。所以,现在在Person
中添加这个属性:
1 | var photo: Data? (.externalStorage) |
如您所见,这特别告诉SwiftData这个属性最好存储在外部。请注意,这是一个_建议_ — SwiftData可以根据自己的判断做出最佳决定,但实际上这对我们来说并不重要,因为整个存储系统对我们来说是完全不透明的。
现在我们有了一个地方来存储一个人的照片,我们可以在EditUserView
中构建一些UI来选择和显示这张照片。
如果您之前用过SwiftUI的PhotosPicker
视图,那么您将知道这是如何完成的,但如果没有,让我们一起走过这些步骤。
首先,我们需要为EditPersonView
添加另一个导入,这次是PhotosUI框架:
1 | import PhotosUI |
我们接着可以添加一个属性到EditPersonView
来存储用户的选择:
1 | private var selectedItem: PhotosPickerItem? |
现在我们可以将其绑定到一个PhotosPicker
视图上,它将为我们处理所有照片选择UI。将以下部分放在表单的开始处,即在询问个人的姓名或电邮地址之前:
1 | Section { |
处理照片选择时,我们可以直接将loadTransferable(type:)
的结果分配给我们正在编辑的人的photo
属性,但非常重要的是,这必须在主执行者上安全地进行,以避免线程问题。
因此,将以下方法添加到EditPersonView
中,以安全地加载照片数据:
1 | func loadPhoto() { |
这需要在selectedItem
属性更改时调用,这意味着在之前添加的navigationDestination()
修饰符下方附加一个onChange()
修饰符:
1 | .onChange(of: selectedItem, loadPhoto) |
此时,我们已经完成了选择和加载用户照片所需的所有代码,现在我们只需要将它放在屏幕上的某个地方。SwiftUI的Image
视图没有原生的加载图像数据的方法,所以我们需要通过UIImage
来转换它。
将以下代码添加到第一Section
中,即在PhotosPicker
之前:
1 | if let imageData = person.photo, let uiImage = UIImage(data: imageData) { |
这样就完成了!用户现在可以为人物导入照片,它会自动由SwiftData保存为外部文件。请注意,我们没有为外部存储进行任何特殊处理 - 它都是自动为我们处理的。
最后一件事:将数据存储在iCloud中
在结束这个项目之前,我想添加一个最后的功能:我想让用户将他们的数据上传到iCloud,这样他们就可以在所有设备上拥有所有人和活动的信息。
这实际上相当简单,因为SwiftData几乎为我们处理了所有事情。不过,有一个陷阱:iCloud 有特定的数据要求,SwiftData 没有,我们需要遵守这些要求,才能将我们的数据同步到 iCloud。
首先,选中你的应用的目标,然后转到签名和功能标签。在这里你需要:
- 点击 + 功能,选择 iCloud。
- 加载后,勾选旁边的 CloudKit 复选框。
- 勾选现有 CloudKit 容器旁的框,或按 + 创建一个新容器。容器应该以“iCloud.”开头,后跟你的包标识符,对我来说就是“iCloud.com.hackingwithswift.FaceFacts”。
- 再次点击 + 功能,这次选择背景模式。
- 加载后,勾选远程通知旁的框,这样我们的应用可以在云中有新更新时收到通知。
这就完成了配置更改,所以现在我希望你再次运行应用。这次你会看到 Xcode 日志中充满了调试信息,因为 CloudKit 真的很喜欢在那里输出文本。
不过,如果你向上滚动到接近顶部,你会看到一些黄色的警告 - 这些是告诉我们 CloudKit 无法使用,因为我们的模型类不符合它的规则。具体来说,我们需要确保每个属性都有默认值,所有关系都被标记为可选。
在这个应用中,进行所有这些更改实际上非常容易。对于Person
,我们可以为所有字符串提供一个空字符串作为默认值:
1 | var name: String = "" |
对于我们的Event
类,我们可以为那里的字符串做同样的事情,然后使people
数组变为可选的,如下所示:
1 | var name: String = "" |
有了这些更改,iCloud 错误将消失,因为我们的项目现在准备好与云同步了。实际上,如果你马上尝试使用它,你会看到它工作得很好,包括同步我们附加到人物上的图片。
现在,这里有一个重要的提示,无论我说多少次,我仍然有人完全忽略它:在模拟器中使用 iCloud 经常出现问题或完全失败,测试同步的唯一可靠方式是使用实际设备。
这并不是说设备上的情况总是完美的 - 开发者生活的灾难之一是可怕的 CloudKit 错误 500,这是苹果的一种方式,表明 SwiftData 同步完全失败了。如果在开发过程中发生这种情况,最好的解决办法是登录 Apple 的 CloudKit 仪表板,选择你正在使用的容器,然后点击重置环境。
下一步呢?
到目前为止,我们已经覆盖了大量内容,并构建了一个使用 SwiftUI、SwiftData、PhotosUI、关系、外部存储、排序、过滤、预览等等的应用程序。
我们仍然可以为这个应用程序添加更多内容。例如,一个起点可能是添加一个TabView
,让用户在当前人物列表和替代所有活动的列表之间切换 - 这将允许他们看到在特定活动中遇到的每个人,但也可以编辑和删除现有活动。
您还可以让用户跟踪他们遇见某人的日期,或将不同的人联系在一起,或添加更多的排序选项,或添加一个在 iPad 上更好工作的 UI,或者使用 SwiftUI 的 ContentUnavailableView
在应用程序启动时没有添加任何人时显示一些有意义的内容,等等 - 还有很多潜在的发展方向可以探索,我希望您能够继续进行!
例如,您可以添加功能来允许用户分享或导出他们的数据,可能是以 JSON 或 CSV 格式。这对于数据备份和跨平台使用可能非常有用。您还可以考虑加入更多的个性化元素,比如让用户自定义应用的主题或外观。
此外,增强应用的交互性也是一个不错的方向。例如,您可以添加更多的动画和过渡效果,或者实现更复杂的用户界面元素,如可拖动的列表项或者自定义的滑动操作。
最后,随着技术的发展,总会有新的 Swift 或 SwiftUI 特性出现,这些新特性可能会为您的应用带来更多的可能性。例如,Apple 可能会发布新的界面组件或者改进现有的数据绑定机制,您可以利用这些新特性来提升用户体验或简化代码。
总之,虽然我们已经完成了一个功能丰富的应用,但总有更多的机会可以探索和实现。无论您选择哪条路,重要的是不断学习和实验,不断改进和扩展您的应用功能。在这个过程中,不仅您的应用会变得更加强大,您作为一个开发者的技能和经验也将得到提升。祝您编程愉快!
- 感谢你赐予我前进的力量