SwiftData 与 SwiftUI 查询:筛选、排序和可测试边界
SwiftData 与 SwiftUI 的结合非常直接:视图声明需要哪些模型,持久化上下文变化后列表自动更新。便利之处也容易让查询、编辑和业务规则全部堆进一个视图。更稳妥的结构是让 @Query 负责“读取哪些数据”,让模型上下文负责持久化操作,把可以脱离数据库表达的领域规则放进普通 Swift 类型。
从最小模型开始
import SwiftData
@Model
final class Book {
var title: String
var author: String
var isFinished: Bool
var updatedAt: Date
init(title: String, author: String) {
self.title = title
self.author = author
self.isFinished = false
self.updatedAt = .now
}
}
持久化模型只保存需要查询和恢复的数据。类似“标题去除空白后不能为空”的规则可以放在创建命令或工厂中,避免每个页面各写一次略有不同的验证。
静态查询保持声明式
固定列表最适合直接声明排序和过滤:
struct FinishedBooksView: View {
@Query(
filter: #Predicate<Book> { $0.isFinished },
sort: \Book.updatedAt,
order: .reverse
)
private var books: [Book]
var body: some View {
List(books) { book in
Text(book.title)
}
}
}
过滤在持久化层完成,比先获取所有模型再在 body 中调用 filter 更符合意图。后者可能让数据量、更新频率和界面计算意外耦合。
动态条件在初始化时组成
搜索文本来自父视图时,可以在视图初始化器里构造 Query。不要为了改变条件而创建第二份内存数组作为“缓存真相”。
struct BooksView: View {
@Query private var books: [Book]
init(searchText: String) {
let term = searchText.trimmingCharacters(in: .whitespaces)
_books = Query(
filter: #Predicate<Book> { book in
term.isEmpty || book.title.contains(term)
},
sort: \Book.updatedAt,
order: .reverse
)
}
var body: some View {
List(books) { book in
Text(book.title)
}
}
}
真实项目要根据数据规模决定是否对每次按键都重建查询。可以在输入层做节流,但最终筛选条件仍应是一个明确值,而不是多个异步任务竞争修改列表。
写入集中在明确动作
通过环境取得 modelContext 后,在按钮、滑动操作或命令处理器中执行写入。创建前先在普通函数中验证输入;删除前记录稳定标识符,而不是让视图持有已删除模型的引用。复杂批量修改最好进入独立服务,并让调用者决定错误如何呈现。
分页与大数据量也不能只依赖列表的懒加载外观。LazyVStack 只控制视图创建,不代表持久化层没有取回全部匹配模型。需要限制结果时,应使用明确的 fetch descriptor、批次边界或按业务游标加载,并在真机数据规模上测量。搜索、排序和分页条件应共同形成稳定顺序,否则新增记录后可能出现重复或跳项。
关系模型要避免在 body 中触发难以预测的大量遍历。页面若只需要作者名称和图书数量,可以建立针对该界面的轻量映射,或在查询边界取得需要的关系,再把不可变展示值传给子视图。这样滚动期间不会因为子视图随意访问整个对象图而放大工作量。
测试分为两层。纯规则测试不启动 SwiftData,例如验证书名规范化和状态转换。持久化集成测试创建内存中的 ModelContainer,插入少量模型,再验证查询排序和删除行为。这样既不会把所有测试都变成慢速数据库测试,也不会假设宏生成的持久化行为一定正确。
还要把 schema 迁移当作单独的发布工作。给模型新增必填属性、改变关系或唯一性约束,都可能影响已有存储。查询代码看似仍能编译,不代表旧数据库一定能打开。
SwiftData 最适合承担持久化与查询职责,SwiftUI 最适合描述当前查询结果的界面。两者之间保留一层普通、可测试的领域规则,自动更新就会成为优势,而不是隐藏数据流的魔法。