第58天 项目12 第二部分
今天,我们将深入探讨更高级的 SwiftData 技术——这些技术能让应用在功能和实用性方面脱颖而出。其中一些技术可能需要花点时间学习,尤其是当我们更深入 SwiftData 时,你会发现它在很大程度上依赖宏。
坚持下去!正如玛雅·安吉洛所说:“所有伟大的成就都需要时间”——要理解 SwiftData 在这里为我们所做的一切,确实需要付出一些努力,但这些努力终会有回报,而且我相信你会喜欢在应用中同时使用 SwiftData 和 SwiftUI。
今天你需要学习三个主题,在这些主题中,你将了解 NSPredicate、动态更改获取请求、创建关系等内容。
在某个节点,你会看到我说你已经达到了一个不错的阶段,可以继续学习下一个教程,但如果你选择继续深入,我们还会探索更多高级主题。需要明确的是,额外的内容是可选的:如果你时间紧张,或者只想掌握基础知识,完全可以不用学习。
- 使用 SwiftUI 动态排序和筛选 @Query
- SwiftData、SwiftUI 和 @Query 中的关系
- 将 SwiftData 与 CloudKit 同步
一定要和其他人分享你的学习进度——这有助于你保持专注,而且你还能认识其他同样在学习的人!
使用 SwiftUI 动态排序和筛选 @Query
作者:Paul Hudson 2024年5月13日
既然你已经对 SwiftData 的 #Predicate 有了一些了解,接下来你可能会问:“如何让它支持用户输入?”答案是……这有点复杂。我会向你展示具体的实现方法,以及如何使用相同的技术动态调整排序方式,但你可能需要花点时间才能记住这种实现方式——希望苹果未来能对这部分进行优化!
如果我们基于之前看过的 SwiftData 代码进行扩展,每个用户对象都有一个不同的 joinDate 属性,有些在过去,有些在未来。我们还通过一个 List 展示查询结果:
List(users) { user in
Text(user.name)
}我们要做的是将这个列表移到一个单独的视图中——这个视图专门用于执行 SwiftData 查询并展示结果,然后让它可以选择显示所有用户,或者只显示未来加入的用户。
因此,创建一个新的 SwiftUI 视图,命名为 UsersView,导入 SwiftData 框架,然后将上述 List 代码移到这个视图中,无需移动任何修饰符——只移动上面显示的代码即可。
现在我们要在 UsersView 中展示 SwiftData 的查询结果,需要在该视图中添加一个 @Query 属性。目前暂时不要为这个属性设置排序规则或谓词。所以,添加以下属性:
@Query var users: [User]在预览视图中添加 modelContainer() 修饰符后,你的 UsersView.swift 代码应如下所示:
import SwiftData
import SwiftUI
struct UsersView: View {
@Query var users: [User]
var body: some View {
List(users) { user in
Text(user.name)
}
}
}
#Preview {
UsersView()
.modelContainer(for: User.self)
}在完成这个视图之前,我们需要一种方式来自定义要执行的查询。目前,仅使用 @Query var users: [User] 意味着 SwiftData 会加载所有用户,不进行筛选或排序,但实际上我们希望从 ContentView 中自定义筛选条件或排序规则(或两者都自定义)——也就是说,我们需要向这个视图传递一些数据。
最好的方式是通过初始化器向视图传递一个值,然后利用这个值创建查询。正如我之前所说,我们的目标是要么显示所有用户,要么只显示未来加入的用户。为了实现这一点,我们会传递一个最小加入日期,确保所有显示的用户的加入日期都不早于这个日期。
现在为 UsersView 添加以下初始化器:
init(minimumJoinDate: Date) {
_users = Query(filter: #Predicate<User> { user in
user.joinDate >= minimumJoinDate
}, sort: \User.name)
}这段代码大部分你应该都很熟悉,但请注意 users 前面有一个下划线。这是有意为之的:我们并不是要修改 User 数组,而是要修改生成该数组的 SwiftData 查询。下划线是 Swift 中获取该查询的方式,这意味着我们会根据传入的日期创建查询。
到这里,UsersView 的工作就完成了。接下来回到 ContentView,我们需要删除现有的 @Query 属性,并用代码实现一个布尔值的切换功能,然后将其当前值传递给 UsersView。
首先,在 ContentView 中添加以下新的 @State 属性:
@State private var showingUpcomingOnly = false然后,将 ContentView 中的 List 代码(同样不包含修饰符)替换为以下内容:
UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast)这会向 UsersView 传递两个日期中的一个:当布尔属性为 true 时,我们传递 .now,这样就只会显示在当前时间之后加入的用户;否则,我们传递 .distantPast(这个日期至少在 2000 年前)——除非我们的用户中包含一些罗马皇帝,否则所有用户的加入日期都会远晚于这个时间,因此会显示所有用户。
最后,我们需要在 ContentView 中添加一种方式来切换这个布尔值——在 ContentView 的工具栏中添加以下代码:
Button(showingUpcomingOnly ? "显示所有人" : "显示未来加入的人") {
showingUpcomingOnly.toggle()
}这样按钮的标签会始终反映下次点击时的操作效果。
所有工作都已完成,现在运行应用,你会发现可以动态更改显示的用户列表。
是的,这个过程确实需要不少步骤,但正如你所见,它的效果非常好,而且这种技术也可以应用到其他类型的筛选中。
这种方法同样适用于数据排序:我们可以在 ContentView 中控制一个排序描述符数组,然后将其传递给 UsersView 的初始化器,从而调整查询结果的排序方式。
首先,我们需要升级 UsersView 的初始化器,让它能够接收用于 User 类的排序描述符。这里再次用到了 Swift 的泛型:SortDescriptor 类型需要知道它要排序的对象类型,因此我们需要在尖括号中指定 User。
将 UsersView 的初始化器修改为以下内容:
init(minimumJoinDate: Date, sortOrder: [SortDescriptor<User>]) {
_users = Query(filter: #Predicate<User> { user in
user.joinDate >= minimumJoinDate
}, sort: sortOrder)
}你还需要更新预览代码,传递一个示例排序规则,以确保代码能够正确编译:
UsersView(minimumJoinDate: .now, sortOrder: [SortDescriptor(\User.name)])
.modelContainer(for: User.self)回到 ContentView,我们需要添加另一个新属性来存储当前的排序规则。我们将默认设置为先按名称排序,再按加入日期排序,这看起来是一个合理的默认选择:
@State private var sortOrder = [
SortDescriptor(\User.name),
SortDescriptor(\User.joinDate),
]然后,像传递加入日期一样,将这个排序规则数组传递给 UsersView:
UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast, sortOrder: sortOrder)最后,我们需要一种方式来动态调整这个数组。一种方法是使用一个 Picker,提供两个选项:“按名称排序”和“按加入日期排序”。这本身并不复杂,但如何将排序描述符数组与每个选项关联起来呢?
答案在于一个实用的修饰符 tag(),它允许我们为每个选择器选项附加自定义的值。在这里,这意味着我们可以直接将每个选项的标签设置为其对应的 SortDescriptor 数组,SwiftUI 会自动将该标签赋值给 sortOrder 属性。
尝试在工具栏中添加以下代码:
Picker("排序", selection: $sortOrder) {
Text("按名称排序")
.tag([
SortDescriptor(\User.name),
SortDescriptor(\User.joinDate),
])
Text("按加入日期排序")
.tag([
SortDescriptor(\User.joinDate),
SortDescriptor(\User.name)
])
}现在运行应用,你看到的结果可能和预期不符。根据你使用的设备不同,导航栏中可能不会显示带有选项的“排序”菜单,而是会出现以下两种情况之一:
- 显示一个圆形的三点图标,点击该图标才会显示选项。
- 直接在导航栏中显示“按名称排序”,点击后可以切换到“按加入日期排序”。
这两种情况的体验都不太好,但借此机会,我想介绍另一个实用的 SwiftUI 视图 Menu。它可以在导航栏中创建菜单,你可以在其中放置按钮、选择器等内容。
在这种情况下,如果我们用 Menu 包裹当前的 Picker 代码,结果会好很多。尝试以下代码:
Menu("排序", systemImage: "arrow.up.arrow.down") {
// 当前的选择器代码
}再次尝试,你会发现体验有了很大改善,更重要的是,我们的动态筛选和排序功能现在都能正常工作了!
SwiftData、SwiftUI 和 @Query 中的关系
作者:Paul Hudson 2024年4月10日
SwiftData 允许我们创建相互引用的模型,例如,School 模型可以包含一个 Student 对象数组,或者 Employee 模型可以存储一个 Manager 对象。
这些被称为关系,关系有多种形式。只要你告诉 SwiftData 你想要的关系类型,它就能很好地自动构建这些关系,不过仍可能会有一些意想不到的情况!
现在让我们尝试创建关系。我们已经有了以下 User 模型:
@Model
class User {
var name: String
var city: String
var joinDate: Date
init(name: String, city: String, joinDate: Date) {
self.name = name
self.city = city
self.joinDate = joinDate
}
}我们可以对其进行扩展,让每个 User 可以附加一个任务数组——这些是他们工作中需要完成的任务。要实现这一点,首先需要创建一个新的 Job 模型,代码如下:
@Model
class Job {
var name: String
var priority: Int
var owner: User?
init(name: String, priority: Int, owner: User? = nil) {
self.name = name
self.priority = priority
self.owner = owner
}
}注意,我让 owner 属性直接引用 User 模型——这明确告诉 SwiftData 这两个模型是关联在一起的。
现在,我们可以调整 User 模型,添加任务数组:
var jobs = [Job]()这样一来,任务有一个所有者,用户有一个任务数组——关系是双向的,这通常是个好主意,因为它能让数据操作更便捷。
这个数组会立即生效:SwiftData 会在首次请求时加载某个用户的所有任务,如果这些任务从未被使用过,它就会跳过加载操作。
更棒的是,下次我们的应用启动时,SwiftData 会自动为所有现有用户添加 jobs 属性,并默认赋予一个空数组。这被称为迁移:当我们的需求随时间变化,需要在模型中添加或删除属性时,就会用到迁移。对于这类简单的迁移,SwiftData 可以自动完成,但随着你进一步学习,你会了解如何创建自定义迁移来处理更复杂的模型变更。
提示: 之前在 App 结构体中使用 modelContainer() 修饰符时,我们传入了 User.self,这样 SwiftData 就知道要为该模型设置存储。我们不需要在这里添加 Job.self,因为 SwiftData 能检测到这两个模型之间的关系,从而自动处理 Job 模型的存储设置。
你无需修改用于加载数据的 @Query,直接像使用普通数组一样使用这个任务数组即可。例如,我们可以像这样显示用户列表及其任务数量:
List(users) { user in
HStack {
Text(user.name)
Spacer()
Text(String(user.jobs.count))
.fontWeight(.black)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.blue)
.foregroundStyle(.white)
.clipShape(.capsule)
}
}如果你想使用实际数据来测试,可以创建一个 SwiftUI 视图,为选中的用户创建新的 Job 实例。但为了测试方便,我们可以走个捷径,添加一些示例数据。
首先,添加一个属性来访问当前的 SwiftData 模型上下文:
@Environment(\.modelContext) var modelContext然后添加一个方法来创建示例数据,如下所示:
func addSample() {
let user1 = User(name: "派珀·查普曼", city: "纽约", joinDate: .now)
let job1 = Job(name: "整理袜子抽屉", priority: 3)
let job2 = Job(name: "和亚历克斯制定计划", priority: 4)
modelContext.insert(user1)
user1.jobs.append(job1)
user1.jobs.append(job2)
}同样,注意这段代码中几乎所有内容都是普通的 Swift 代码——只有一行代码与 SwiftData 相关。
我建议你在这里多做一些尝试。你应该始终假设操作这些数据就像操作普通的 @Observable 类一样——除非有特殊原因需要干预,否则就让 SwiftData 自动处理。
不过,有一个小问题值得我们在继续之前说明:我们已经将 User 和 Job 关联起来,让一个用户可以拥有多个任务,但如果我们删除一个用户,会发生什么呢?
答案是,该用户的所有任务都会保留下来——不会被删除。这是 SwiftData 的明智设计,避免了意外的数据丢失。
如果你明确希望删除一个用户时,其所有任务对象也随之删除,就需要告诉 SwiftData。这可以通过 @Relationship 宏来实现,我们需要为其提供一个删除规则,用于指定当所属的 User 被删除时,Job 对象应如何处理。
默认的删除规则是 .nullify,这意味着每个 Job 对象的 owner 属性会被设置为 nil,表示它们没有所有者。我们要将其改为 .cascade,这样删除一个 User 时,会自动删除其所有 Job 对象。之所以称为“级联”(cascade),是因为删除操作会对所有相关对象持续生效——例如,如果 Job 对象有一个 locations 关系,那么这些 locations 对象也会被删除,以此类推。
因此,将 User 中的 jobs 属性修改为以下内容:
@Relationship(deleteRule: .cascade) var jobs = [Job]()现在我们的设置就很明确了,删除用户时不会留下任何隐藏的 Job 对象——这样要好得多!
将 SwiftData 与 CloudKit 同步
作者:Paul Hudson 2023年11月27日
SwiftData 可以将用户的所有数据与 iCloud 同步,而且最棒的是,通常不需要编写任何代码。
开始之前,有一个重要提示:将数据同步到 iCloud 需要一个有效的 Apple 开发者账号。如果没有,以下内容将无法正常工作。
仍然准备继续?好的,要将本地 SwiftData 存储的数据同步到 iCloud,需要为应用启用 iCloud 功能。我们之前没有自定义过应用功能,所以这是一个新的步骤。
首先,点击项目导航器顶部的“SwiftDataTest”应用图标。它应该就在“SwiftDataTest”分组的正上方。
其次,在“TARGETS”列表下选择“SwiftDataTest”。此时会出现一系列标签页:General(通用)、Signing & Capabilities(签名与功能)、Resource Tags(资源标签)、Info(信息)等。我们需要的是“Signing & Capabilities”,请选择这个标签页。
第三,点击“+ CAPABILITY”(添加功能),然后选择“iCloud”,这样“iCloud”就会出现在已启用功能的列表中——你会看到有三个可选服务、一个“CloudKit Console”(CloudKit 控制台)按钮等内容。
第四,勾选“CloudKit”选项,它能让我们的应用将 SwiftData 数据存储到 iCloud 中。你还需要点击“+”按钮添加一个新的 CloudKit 容器,用于配置数据在 iCloud 中的实际存储位置。容器名称应使用应用的 Bundle ID 前缀,并加上“iCloud.”,例如“iCloud.com.hackingwithswift.swiftdatatest”。
第五,再次点击“+ CAPABILITY”,然后添加“Background Modes”(后台模式)功能。这个功能有很多配置选项,但你只需要勾选“Remote Notifications”(远程通知)选项——它能让应用在 iCloud 中的数据发生变化时收到通知,从而实现本地同步。
这样就完成了所有设置——你的应用现在可以使用 iCloud 同步 SwiftData 数据了。
或许吧。
要知道,支持 iCloud 的 SwiftData 有一个本地 SwiftData 没有的要求:所有属性必须是可选的,或者有默认值,而且所有关系也必须是可选的。前者只是一个小麻烦,但后者的影响要大得多——它可能会对你的代码造成不小的干扰。
然而,这些都是必须满足的要求,而非建议。因此,以 Job 模型为例,我们需要将其属性调整为以下内容:
var name: String = "无"
var priority: Int = 1
var owner: User?对于 User 模型,我们需要使用以下代码:
var name: String = "匿名"
var city: String = "未知"
var joinDate: Date = Date.now
@Relationship(deleteRule: .cascade) var jobs: [Job]? = [Job]()重要提示: 如果不进行这些修改,iCloud 将无法正常工作。如果你查看 Xcode 的日志(CloudKit 非常喜欢向 Xcode 日志中写入内容),并滚动到日志顶部附近,当有任何属性导致 iCloud 同步无法正常工作时,SwiftData 应该会尝试给出警告。
调整完模型后,你需要修改所有相关代码,以正确处理可选类型。例如,向用户添加任务时,可能需要使用可选链:
user1.jobs?.append(job1)
user1.jobs?.append(job2)读取用户的任务数量时,可能需要同时使用可选链和空合运算符:
Text(String(user.jobs?.count ?? 0))我不太喜欢在项目中到处散布这类代码,所以如果经常使用任务数组,我更愿意创建一个只读的计算属性,命名为 unwrappedJobs 之类的——该属性会在 jobs 有值时返回它,否则返回一个空数组,代码如下:
var unwrappedJobs: [Job] {
jobs ?? []
}这只是一个小技巧,但确实能让代码的其他部分更简洁,而且将其设为只读可以防止你意外修改一个不存在的数组。
重要提示: 虽然模拟器可以用于测试本地 SwiftData 应用,但它在测试 iCloud 同步方面表现很差——你可能会发现数据无法正确同步、同步速度慢,甚至完全无法同步。请使用真实设备进行测试,以避免出现问题!