Skip to content

第57天 项目12 第一部分

在这个技术项目中,我们将探索 SwiftData 和 SwiftUI 如何协同工作,帮助我们构建出色的应用程序。我已经在项目 11 中向你介绍过这个主题,但在本项目中,我们将深入探讨更多细节:自定义托管对象子类、确保数据唯一性等等。

美国企业家吉姆·罗恩曾说过:“成功既非魔法也非奥秘——成功是持续运用基本原理的自然结果。” SwiftData 无疑就是这些基本原理之一——你不一定会在每个项目中都用到它,但理解它的工作原理以及如何充分利用它,将让你成为一名更优秀的应用开发者。

今天你需要学习三个主题,通过这些内容,你将掌握如何使用 SwiftUI 编辑 SwiftData 对象,以及如何使用 #Predicate 过滤数据等知识。

  • SwiftData:简介
  • 编辑 SwiftData 模型对象
  • 使用 Predicate 过滤 @Query 结果

SwiftData:简介

作者:Paul Hudson 2023 年 11 月 23 日

本技术项目将深入探索 SwiftData,首先总结一些基本技巧,然后逐步解决更复杂的问题。

正如你将看到的,SwiftData 充分利用了 Swift 和 SwiftUI 的高级特性,旨在帮助我们高效地存储数据。不过,它并非始终“易于使用”,在某些场景下,要正确使用还需要深入思考。

我们有很多内容要探索,所以请创建一个新的项目来尝试。将项目命名为“SwiftDataProject”,而不是简单的“SwiftData”,因为后者会导致 Xcode 产生混淆。

请确保不要为存储启用 SwiftData。 同样,我们将从零开始构建,以便你清晰了解整个过程。

准备就绪了吗?让我们开始吧!

编辑 SwiftData 模型对象

作者:Paul Hudson 2023 年 11 月 23 日

SwiftData 的模型对象由支持 @Observable 类的观察系统提供动力,这意味着对模型对象的更改会被 SwiftUI 自动捕获,从而确保数据与用户界面保持同步。

这种支持还扩展到了我们之前学过的 @Bindable 属性包装器,这使得对象编辑变得异常简单。

为了演示这一点,我们可以创建一个简单的 User 类,其中包含几个属性。新建一个名为 User.swift 的文件,在顶部导入 SwiftData,然后添加以下代码:

swift
@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
    }
}

接下来,我们可以为其创建模型容器和模型上下文。在 App 结构体文件中再导入一次 SwiftData,然后使用 modelContainer() 方法,代码如下:

swift
WindowGroup {
    ContentView()
}
.modelContainer(for: User.self)

在编辑 User 对象时,我们可以创建一个名为 EditUserView 的新视图,然后使用 @Bindable 属性包装器为其创建绑定。代码如下:

swift
struct EditUserView: View {
    @Bindable var user: User

    var body: some View {
        Form {
            TextField("姓名", text: $user.name)
            TextField("城市", text: $user.city)
            DatePicker("加入日期", selection: $user.joinDate)
        }
        .navigationTitle("编辑用户")
        .navigationBarTitleDisplayMode(.inline)
    }
}

这与我们使用普通 @Observable 类的方式完全相同,但 SwiftData 仍会自动将所有更改写入持久化存储——整个过程对我们来说是完全透明的。

重要提示: 如果想在 Xcode 预览中使用该视图,需要传入一个示例对象,这就需要创建自定义配置和容器。首先导入 SwiftData,然后将预览代码修改为以下内容:

swift
#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: User.self, configurations: config)
        let user = User(name: "泰勒·斯威夫特", city: "纳什维尔", joinDate: .now)
        return EditUserView(user: user)
            .modelContainer(container)
    } catch {
        return Text("创建容器失败:\(error.localizedDescription)")
    }
}

我们可以基于此构建一个非常简单的用户编辑应用:点击按钮添加新用户,然后通过编程式导航直接跳转到新用户的编辑页面。

让我们逐步构建。首先,打开 ContentView.swift 并导入 SwiftData,然后添加属性以获取模型上下文、加载所有 User 对象,并存储一个可绑定到 NavigationStack 的路径:

swift
@Environment(\.modelContext) var modelContext
@Query(sort: \User.name) var users: [User]
@State private var path = [User]()

将默认的 body 属性替换为以下内容:

swift
NavigationStack(path: $path) {
    List(users) { user in
        NavigationLink(value: user) {
            Text(user.name)
        }
    }
    .navigationTitle("用户")
    .navigationDestination(for: User.self) { user in
        EditUserView(user: user)
    }
}

现在,我们只需要一种添加用户的方式。仔细想想,添加和编辑其实非常相似,所以最简单的方法是创建一个属性为空的新 User 对象,将其插入模型上下文,然后通过调整 path 属性立即导航到该对象的编辑页面。

在两个导航修饰符下方添加以下额外修饰符:

swift
.toolbar {
    Button("添加用户", systemImage: "plus") {
        let user = User(name: "", city: "", joinDate: .now)
        modelContext.insert(user)
        path = [user]
    }
}

这样就可以正常工作了!实际上,这与苹果官方的备忘录应用所采用的方法非常相似,只不过备忘录应用多了一个额外步骤:如果用户未添加任何文本就退出编辑视图,会自动删除该备忘录。

由此可见,编辑 SwiftData 对象与编辑普通 @Observable 类并无区别——只是额外增加了数据加载和保存的便捷功能!

使用 Predicate 过滤 @Query 结果

作者:Paul Hudson 2024 年 5 月 29 日

你已经了解到如何使用 @Query 按特定顺序对 SwiftData 对象进行排序,但它也可以使用“谓词”(predicate)来过滤数据——谓词是一系列应用于数据的测试条件,用于确定返回哪些数据。

起初,谓词的语法可能有些奇怪,这主要是因为它背后实际上也是一个“宏”——Swift 会将我们的谓词代码转换为一系列规则,应用于存储 SwiftData 所有对象的底层数据库。

让我们从简单的例子开始,使用之前的 User 模型:

swift
@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
    }
}

现在,我们可以在 ContentView 中添加几个属性,用于显示所有用户:

swift
@Environment(\.modelContext) var modelContext
@Query(sort: \User.name) var users: [User]

最后,我们可以在列表中显示所有用户,并添加一个按钮以便轻松添加示例数据:

swift
NavigationStack {
    List(users) { user in
        Text(user.name)
    }
    .navigationTitle("用户")
    .toolbar {
        Button("添加示例数据", systemImage: "plus") {
            let first = User(name: "艾德·希兰", city: "伦敦", joinDate: .now.addingTimeInterval(86400 * -10))
            let second = User(name: "罗莎·迪亚兹", city: "纽约", joinDate: .now.addingTimeInterval(86400 * -5))
            let third = User(name: "罗伊·肯特", city: "伦敦", joinDate: .now.addingTimeInterval(86400 * 5))
            let fourth = User(name: "约翰尼·英格力", city: "伦敦", joinDate: .now.addingTimeInterval(86400 * 10))

            modelContext.insert(first)
            modelContext.insert(second)
            modelContext.insert(third)
            modelContext.insert(fourth)
        }
    }
}

提示: 这些加入日期代表过去或未来的若干天,为我们提供了一些可用于测试的数据。

在使用此类示例数据时,最好能在添加示例数据之前删除现有数据。要实现这一点,在 let first = 这行代码之前添加以下代码:

swift
try? modelContext.delete(model: User.self)

这行代码会告诉 SwiftData 删除所有 User 类型的现有模型对象,确保在添加示例用户之前数据库是空的。

要完成这个小示例应用,我们只需确保 App 结构体使用 modelContainer() 修饰符正确配置 SwiftData:

swift
WindowGroup {
    ContentView()
}
.modelContainer(for: User.self)

现在运行应用,然后点击“+”按钮插入四个用户。

你会看到用户按字母顺序排列,这是因为我们在 @Query 属性中指定了按名称排序。

现在,让我们尝试过滤这些数据,只显示姓名中包含大写字母“R”的用户。为此,我们向 @Query 中传入 filter 参数,代码如下:

swift
@Query(filter: #Predicate<User> { user in
    user.name.contains("R")
}, sort: \User.name) var users: [User]

我们来拆解一下这段代码:

  1. 过滤器以 #Predicate<User> 开头,表示我们正在编写一个谓词(即要应用的测试条件)。
  2. 该谓词会给我们一个单独的用户实例进行检查。实际上,SwiftData 会为加载的每个用户调用一次这个谓词,如果该用户应包含在结果中,我们需要返回 true
  3. 我们的测试条件是检查用户姓名是否包含大写字母“R”。如果包含,该用户将被纳入结果;否则,将被排除。

因此,现在运行代码时,你会看到罗莎(Rosa)和罗伊(Roy)出现在列表中,而艾德(Ed)和约翰尼(Johnny)则不会出现,因为他们的姓名中不包含大写字母“R”。contains() 方法区分大小写:它认为大写“R”和小写“r”是不同的,这就是为什么它没有找到“Ed Sheeran”中的“r”。

对于简单的谓词测试来说,这已经很好用了,但用户通常并不关心字母的大小写——他们通常只想输入几个字母,然后在结果中查找匹配项,而不考虑大小写。

为此,iOS 为我们提供了一个专门的方法 localizedStandardContains()。该方法也接受一个要搜索的字符串,但会自动忽略字母大小写,因此在根据用户输入的文本进行过滤时,这是一个更好的选择。

使用方法如下:

swift
@Query(filter: #Predicate<User> { user in
    user.name.localizedStandardContains("R")
}, sort: \User.name) var users: [User]

在我们的测试数据中,这意味着列表中将显示四个用户中的三个,因为这三个用户的姓名中都包含字母“r”(无论大小写)。

现在,让我们进一步升级过滤器:只匹配姓名中包含“R”且居住在伦敦(London)的用户:

swift
@Query(filter: #Predicate<User> { user in
    user.name.localizedStandardContains("R") &&
    user.city == "London"
}, sort: \User.name) var users: [User]

这里使用了 Swift 的“逻辑与”运算符(&&),这意味着只有当两边的条件都为 true 时,整个条件才为 true——用户的姓名必须包含“R”,并且他们必须居住在伦敦。

如果我们只保留第一个检查姓名中是否包含“R”的条件,那么艾德、罗莎和罗伊会匹配;如果我们只保留第二个检查是否居住在伦敦的条件,那么艾德、罗伊和约翰工会匹配。将两个条件结合起来后,只有艾德和罗伊会匹配,因为他们是仅有的两个姓名中包含“R”且居住在伦敦的用户。

你可以像这样添加更多检查条件,但过多使用 && 会让代码变得难以理解。幸运的是,这些谓词支持“有限子集”的 Swift 表达式,有助于提高代码的可读性。

例如,我们可以将当前的谓词重写为:

swift
@Query(filter: #Predicate<User> { user in
    if user.name.localizedStandardContains("R") {
        if user.city == "London" {
            return true
        } else {
            return false
        }
    } else {
        return false
    }
}, sort: \User.name) var users: [User]

此时,你可能会觉得这段代码有些冗长——我们可以删除两个 else 块,最后只保留 return true,因为如果用户确实匹配谓词条件,return true 就会被执行。

修改后的代码如下:

swift
@Query(filter: #Predicate<User> { user in
    if user.name.localizedStandardContains("R") {
        if user.city == "London" {
            return true
        }
    }

    return false
}, sort: \User.name) var users: [User]

遗憾的是,这段代码实际上是无效的。尽管它看起来像是纯 Swift 代码,但请记住,事情并非如此——#Predicate 宏会将我们的代码重写为一系列可在数据库上应用的测试条件,而数据库内部并不使用 Swift。

要了解内部发生了什么,你可以按几次撤销,回到带有两个 else 块的原始版本。然后右键单击 #Predicate 并选择“展开宏”(Expand Macro),你会看到出现了大量代码。请记住,这才是实际被编译和运行的代码——是我们的 #Predicate 被转换后的结果。

以上就是 #Predicate 工作原理的简要介绍,也解释了为什么有些你尝试编写的谓词可能无法按照预期工作——这些内容看起来简单,但背后的逻辑其实非常复杂!

本站使用 VitePress 制作