跟随编程:使用 SwiftData 添加持久化功能
Code-along: Add persistence with SwiftData
2026年6月10日
一句话判断
这场 Session 最值得关注的一件事是:SwiftData 迁移绝不是无脑加 @Model 宏,它强迫你重构底层数据结构(比如把 Enum 拍平成 Class 继承),并且苹果终于用全新的 withContinuousObservation API 解决了 @Model 里 didSet 失效的痛点。
这场 Session 讲了什么
这次苹果拿 Wishlist 这个纯内存的 SwiftUI 示例 App 开刀,演示怎么把它改造成带持久化的现代应用。以前这 App 靠一个 DataSource 类在内存里硬扛所有数据,一杀进程就灰飞烟灭。现在要把这些内存状态塞进 SwiftData 的 ModelContext(模型上下文)里。
过程其实挺折腾的。你不能只是给现有的 struct 或 enum 贴上 @Model 标签就下班。苹果在演示里硬生生把 Goal 这个枚举拆解成了 TripGoal 和 ActivityGoal 两个子类,搞起了类继承。视图层也全盘推翻,把环境里传的 DataSource 全换成了 @Query 宏和 FetchDescriptor(获取描述符),让视图直接跟数据库对话。
这对那些正打算把 @Observable 内存模型升级为本地持久化的团队影响最大。它直接打破了“SwiftData 就是 CoreData 的简单语法糖”的幻想,告诉你底层数据范式的转换是躲不掉的。
值得深挖的点
把 Enum 拍平成 Class 继承,SwiftData 的“对象图”执念
在 Swift 里,我们太喜欢用 Enum 带关联值来表示状态了,比如 Goal 原本是个枚举,用来区分是旅行目标还是活动目标。但在 SwiftData 的世界里,这套行不通。关系型数据库和对象图不认 Swift 的 Enum 关联值。
苹果给出的解法是使用模型继承(Model Inheritance)。把 Goal 变成基类,抽出 TripGoal 和 ActivityGoal 作为子类。
这个设计的 Trade-off 很明显:你失去了 Swift Enum 极其优雅的模式匹配(switch)和值语义,代码写起来会变得“Java 味”很重。但换来的好处是实打实的:数据库层面支持了多态,你可以直接用 #Predicate 去独立查询某种特定的 Goal,而不需要把整个大 Enum 反序列化到内存里再过滤。如果你以后的 App 需要重度依赖本地数据库,尽早放弃在持久化模型里用复杂 Enum 的念头,拥抱 Class 继承。
withContinuousObservation 终于救活了属性观察者
用过 @Model 宏的人都知道,它和 Swift 原生的 didSet 属性观察者(Property Observers)八字不合。以前你想在某个属性改变时自动更新 dateEdited,用 didSet 经常会遇到触发时机不对或者和计算属性冲突的坑。
这次 Session 提到了全新的 withContinuousObservation API。虽然演示代码没完全展开,但这个 API 的意图很明确:让 SwiftData 的模型变更追踪和 SwiftUI 的视图刷新机制在属性级别完美对齐。它允许你在模型内部安全地监听属性变化并做出副作用操作(比如更新时间戳),而不会打断 SwiftData 的自动保存(Autosave)机制。这是一个巨大的体验提升,以前为了绕开这个坑,大家不得不在 ViewModel 层写一堆胶水代码,现在终于可以收拢回 Model 层了。
代码片段
1. 在 init 中动态构建 @Query
场景:列表页需要根据外部传入的参数(如搜索词或分类)动态过滤数据,而不是写死查询条件。
import SwiftUI
import SwiftData
struct TripCollectionView: View {
// 使用 @Query 宏声明数据源
@Query private var trips: [Trip]
let tripCollection: TripCollection
// 在初始化器中动态修改 _trips 的查询条件
init(tripCollection: TripCollection) {
self.tripCollection = tripCollection
// 动态构建带 #Predicate 的 Query
_trips = Query(
filter: #Predicate<Trip> { $0.collection == tripCollection },
sort: \Trip.name
)
}
var body: some View {
List(trips) { trip in
Text(trip.name)
}
}
}
坑:在 init 里赋值 _trips 时,千万别漏了下划线,否则你只是在给一个普通的计算属性赋值,SwiftUI 根本不会触发数据库查询。
2. 使用 @Relationship 处理级联删除与反向关联
场景:一个 Trip 包含多个 Activity,当删除 Trip 时,需要自动清理关联的 Activity,避免数据库里产生孤儿数据。
import Foundation
import SwiftData
@Model
class Trip {
var name: String
// 声明一对多关系,设置级联删除规则,并指定反向关联属性
@Relationship(deleteRule: .cascade, inverse: \Activity.trip)
var activities: [Activity] = []
// 内联缩略图数据,避免只存文件路径导致文件移动后失效
var thumbnailData: Data?
init(name: String) {
self.name = name
}
}
@Model
class Activity {
var name: String
// 反向关联属性,SwiftData 会自动维护两端的同步
var trip: Trip?
}
坑:如果只写了 @Relationship 没写 inverse,SwiftData 会尝试自己推断,但在复杂模型下极易推断失败导致编译报错或运行时崩溃,永远显式写出 inverse。
最佳实践
- 已有项目的迁移策略:别想着一步到位。先剥离内存
DataSource,把纯数据模型改成@Model。遇到 Enum 状态机,果断重构为 Class 继承体系。把环境对象(EnvironmentObject)替换为@Query时,按视图层级逐个替换,确保每次替换后 UI 渲染正常。 - 新项目的采用建议:直接在 Model 层设计好继承体系,别用 Enum 凑合。对于需要频繁查询的字段,确保它们是可被
#Predicate解析的基础类型。 - 实战中容易踩的坑:
@Model里的属性如果是自定义类型(比如示例中的TripCollection枚举),必须显式实现Codable协议,否则 SwiftData 无法将其序列化到数据库列中,会直接抛出编译错误。
还有什么值得关注
- 内联缩略图数据:示例中将
thumbnailData: Data?直接存入数据库,而不是只存文件路径。这避免了文件被系统清理或移动导致路径失效的问题,对于小体积图片来说,读取效率反而更高。 FetchDescriptor的fetchLimit:在首页展示“最近旅行”时,使用了fetchLimit: 5。这是一个极其好用的内存保护机制,直接限制底层 SQL 拉取的条数,避免一次性把几千条数据全加载到内存里。- 运行时错误暴露:Session 提到了使用 SwiftUI view modifiers 来捕获和展示 SwiftData 的运行时错误(如数据库损坏或 Schema 迁移失败),这在以前只能靠
do-catch盲猜,现在有了更优雅的 UI 降级方案。