Highlight
这场 Code-along 演示了如何将一个纯内存的 SwiftUI 旅行清单 App 迁移到 SwiftData,通过
@Model宏、@Query谓词查询和模型继承,用不到 30 分钟的时间替换掉了数百行手动状态管理代码,同时获得了跨平台持久化存储能力。
核心内容
从内存到持久化:一个真实痛点
Wishlist 是一个旅行规划 App。用户添加旅行目的地、记录待办活动、按季节分组浏览。在改造之前,所有数据都存放在一个 DataSource 类里,用全局变量和字典管理。过滤、排序、搜索全部在内存中完成。
对于演示项目来说,这够用。但关掉 App 再打开,刚才添加的 “Northern Lights” 旅行就消失了。所有修改重置回预置数据。数据量一大,内存占用也会持续攀升。
SwiftData 要解决的正是这类问题:把动态状态接入持久化存储层,让数据在 App 生命周期之外存活。
改造路径:从模型定义到视图查询
改造分三步走。
第一步,把数据结构标记为 SwiftData 模型。给 Activity、Trip 等类型加上 @Model 宏,替换原来的 @Observable。SwiftData 会自动为这些类型生成 Observable 协议实现(03:39)。
第二步,定义模型之间的关系。用 @Relationship 宏声明 Trip 和 Activity 之间的一对多关系,并设置级联删除规则。删除一个 Trip 时,其下的所有 Activity 自动清理(10:32)。
第三步,在 SwiftUI 视图中用 @Query 替换手动数据获取。每个子视图只拉取自己需要的数据,通过谓词在数据库层面过滤,而不是把全部数据读到内存再筛选(16:27)。
一个意外收获:删掉了几百行代码
改造完成后,DataSource 类、TripEditModel 等辅助类型不再需要了。SwiftData 的 ModelContext 自动接管了状态管理、存储逻辑、过滤排序和关系遍历。这些原本需要手动编写的代码被框架内建能力替代。
详细内容
用 @Model 标记持久化模型
改造从 Activity 类型开始。原代码用 @Observable 宏,SwiftUI 视图通过属性绑定观察变化。
import Foundation
import SwiftData
@Model
class Activity {
var name: String
var isComplete: Bool = false
var dateCreated = Date.now
var dateEdited = Date.now
}
关键点:
@Model宏同时生成 Observable 协议实现,视图可以继续绑定这些属性- 不需要手动写
ObservableObject或@Published - 所有存储属性默认会被持久化到数据库
原代码中 name 和 isComplete 带有 didSet 观察器,用于在属性变化时更新 dateEdited。@Model 宏不支持 didSet,所以先移除这些观察器,后面用 Observation 框架的新能力替代(03:39)。
枚举转类:Goal 的模型化改造
Goal 在原代码中是一个枚举,定义了 18 个固定的成就目标。枚举是值类型,无法作为 SwiftData 的持久化模型。
改造策略:把 Goal 转成类,保留 name、kind、target 三个属性,新增 completedCount 和 isComplete 来存储进度状态。然后用 SwiftData 的模型继承,把不同种类的 Goal 拆成子类:
// Goal 作为基类
class Goal {
var name: String
var target: Int
var completedCount: Int = 0
var isComplete: Bool = false
var dateAchieved: Date?
var sortOrder: Int = 0
}
// TripGoal 和 ActivityGoal 继承 Goal
class TripGoal: Goal { }
class ActivityGoal: Goal { }
关键点:
- 枚举的闭集特性不适合持久化场景,因为用户进度是动态数据
- 模型继承适用于有明确层级关系的类型,子类代表同一概念的更具体形态
- 子类自动继承父类的所有持久化属性
定义模型关系
Wishlist 中每个 Trip 包含多个 Activity。原代码用字典手动维护这种映射。SwiftData 中用数组和 @Relationship 宏声明:
import Foundation
import SwiftData
@Model
class Trip {
var name: String
var collection: TripCollection
var photo: TripImage
var thumbnailData: Data?
@Relationship(deleteRule: .cascade, inverse: \Activity.trip)
var activities: [Activity] = []
private(set) var creationDate = Date.now
var subtitle: String?
var isComplete: Bool = false
}
关键点:
inverse: \Activity.trip建立了双向关系,Activity 可以通过activity.trip反向引用所属 TripdeleteRule: .cascade表示删除 Trip 时,其 activities 数组中的所有 Activity 也会被删除thumbnailData用Data?类型将缩略图的原始字节直接存入数据库,而全分辨率图片通过TripImage模型以外部文件引用方式存储
配置 ModelContainer
在 App 入口设置 ModelContainer,SwiftUI 会自动将其注入到视图环境中:
import SwiftUI
import SwiftData
@main
struct WishlistApp: App {
let container: ModelContainer = {
do {
let modelContainer = try ModelContainer(
for: Trip.self, Activity.self, TripImage.self,
Goal.self, TripGoal.self, ActivityGoal.self
)
try SampleData.seedIfNeeded(in: modelContainer.mainContext)
return modelContainer
} catch {
fatalError("Could not create model container: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
}
.modelContainer(container)
}
}
关键点:
ModelContainer(for:)接收所有需要持久化的模型类型,构建数据库 SchemaSampleData.seedIfNeeded在首次启动时插入预置数据.modelContainer(container)修饰符让 SwiftUI 环境持有这个容器,@Query和ModelContext自动从中获取数据
用 @Query 做定向查询
改造前,视图从 DataSource 获取全部数据再自行过滤。改造后,每个视图用 @Query 声明自己需要的数据子集。
已达成目标和待达成目标分开查询(16:27):
@Query(
filter: #Predicate<Goal> { $0.isAchieved },
sort: \Goal.dateAchieved,
order: .reverse
)
private var achievedGoals: [Goal]
@Query(
filter: #Predicate<Goal> { !$0.isAchieved },
sort: \Goal.sortOrder
)
private var upcomingGoals: [Goal]
最近添加的旅行,限制返回 5 条(16:49):
@Query(FetchDescriptor<Trip>(
sortBy: [SortDescriptor(\Trip.creationDate, order: .reverse)],
fetchLimit: 5
))
private var trips: [Trip]
关键点:
FetchDescriptor控制排序、返回数量等查询参数fetchLimit: 5限制只取最近 5 条,避免加载过多数据到内存- 谓词在数据库层面执行过滤,比全量加载后内存过滤更高效
动态构造查询:按季节分组
TripCollectionView 在初始化时才确定要显示哪个季节,因此需要在 init 中动态构造 @Query:
init(tripCollection: TripCollection, cardSize: TripCard.Size, namespace: Namespace.ID) {
_trips = Query(
filter: #Predicate<Trip> { $0.collection == tripCollection },
sort: \Trip.name
)
self.tripCollection = tripCollection
self.cardSize = cardSize
self.namespace = namespace
}
关键点:
_trips是属性包装器的底层存储,在init中直接赋值可以传入动态参数tripCollection从初始化参数捕获后嵌入谓词中- 查询结果自动驱动视图更新
搜索功能的查询实现
搜索视图根据输入文本动态切换查询策略(18:13):
init(searchText: String, namespace: Namespace.ID) {
self.searchText = searchText
self.namespace = namespace
if searchText.isEmpty {
// 空搜索时显示最近 3 条旅行
_trips = Query(FetchDescriptor(
sortBy: [SortDescriptor(\Trip.creationDate, order: .reverse)],
fetchLimit: 3
))
_activities = Query(filter: #Predicate<Activity> { _ in false })
} else {
// 按名称匹配旅行
let tripSearchPredicate = #Predicate<Trip> {
$0.name.localizedStandardContains(searchText)
}
_trips = Query(filter: tripSearchPredicate, sort: \Trip.name)
// 按名称匹配活动,且必须属于某个旅行
let activitySearchPredicate = #Predicate<Activity> {
$0.trip != nil && $0.name.localizedStandardContains(searchText)
}
_activities = Query(filter: activitySearchPredicate, sort: \Activity.name)
}
}
关键点:
localizedStandardContains执行本地化、大小写不敏感的字符串匹配- 空搜索时返回最近 3 条旅行作为默认内容
ContentUnavailableView在结果为空时展示友好提示
错误处理
SwiftData 操作可能因磁盘空间不足等原因失败。在视图中捕获并展示错误(19:42):
.onDisappear {
do {
try updateGoalAchievements()
} catch {
updateError = error
reportError(error)
}
}
.alert(error: $updateError) {
// 自定义错误展示
}
关键点:
.alert(error:)是 SwiftUI 的内建错误提示修饰符- 生产环境中建议同时上报到遥测系统以便排查
用 Continuous Observation 替代 didSet
改造后需要恢复 dateEdited 的自动更新行为。Observation 框架在 2027 版本中新增了 withContinuousObservation:
init(activity: Activity, isLast: Bool, isEditing: Bool) {
activity.token = withContinuousObservation(options: .didSet) { event in
_ = activity.name
_ = activity.isComplete
if event.matches(\Activity.name) {
activity.dateEdited = .now
}
if event.matches(\Activity.isComplete) {
activity.dateEdited = .now
activity.trip?.isComplete =
activity.trip?.activities.isEmpty == false
&& activity.trip?.activities.allSatisfy { $0.isComplete } == true
}
}
self.activity = activity
self.isLast = isLast
self.isEditing = isEditing
}
关键点:
withContinuousObservation(options: .didSet)在属性赋值后触发回调_ = activity.name声明对属性的观察兴趣event.matches(\Activity.name)精确判断哪个属性发生了变化- 在 Activity 完成状态变化时,连带更新所属 Trip 的
isComplete状态
核心启发
1. 把任意清单类 App 改成持久化版本
Todo、记账、阅读列表这类 App 的数据结构天然适合 SwiftData。给模型加 @Model,给视图加 @Query,一个下午就能完成迁移。入口 API:@Model + ModelContainer。
2. 用谓词查询替代全量加载
很多 App 启动时把所有数据读到内存。改用 FetchDescriptor 配合 fetchLimit 和谓词,只加载当前屏幕需要的数据。列表滚动性能会明显提升。入口 API:FetchDescriptor(sortBy:fetchLimit:) + #Predicate。
3. 利用模型继承处理异构数据
如果 App 中有多种相似但略有差异的实体(如不同类型的任务、不同级别的会员),可以用模型继承而不是枚举或协议。每个子类有自己的持久化表,同时共享父类属性。入口 API:class SubModel: ParentModel。
4. 用 Continuous Observation 做属性级副作用
需要在属性变化时触发级联更新(如修改订单项后重算订单总价),withContinuousObservation 比 didSet 更灵活,因为它支持跨类型的观察,且与 SwiftData 的持久化生命周期兼容。入口 API:withContinuousObservation(options: .didSet)。
5. 搜索功能的数据库化
把内存中的过滤逻辑搬到数据库谓词中。localizedStandardContains 支持本地化搜索,空搜索时回退到最近数据。配合 ContentUnavailableView 给出空状态提示。入口 API:#Predicate { $0.name.localizedStandardContains(text) }。
关联 Session
- SwiftData — SwiftData 框架的完整概述,适合在动手前先了解整体架构
- SwiftUI — SwiftUI 与 SwiftData 的集成细节,包括
@Query和.modelContainer的更多用法 - Swift — Swift 语言新特性,包括宏系统和 Observation 框架的底层机制
- SwiftData: Dive into inheritance and schema migration — 模型继承和 Schema 迁移的深入讲解,Goal 的继承设计参考了这里的方法
评论
GitHub Issues · utterances