SwiftData: Dive into inheritance and schema migration
2025年6月9日
一句话判断
SwiftData 终于支持 class inheritance 了。但别高兴太早——继承只在你的模型确实构成 “is-a” 关系、且查询模式同时需要 deep 和 shallow search 时才值得用。Apple 同时给出了从 iOS 17 到 iOS 26 的完整迁移方案,很实用。
这场 Session 讲了什么
Rishi Verma 延续了 SampleTrips app 的故事线,从 iOS 17 一路演进到 iOS 26。
Class inheritance 支持: Trip 模型作为基类,派生出 PersonalTrip(增加 reason 枚举)和 BusinessTrip(增加 perdiem 属性)。子类需要 @available(iOS 26, *) 标记。schema 注册时把子类加到 modelContainer modifier 的列表中。
什么时候用继承: Apple 给了明确的判断标准——
- 模型形成自然层级关系(is-a 关系)。
- 你同时需要 deep search(查所有 Trip)和 shallow search(只查 PersonalTrip)。
- 如果你总是只查基类、从不查子类类型 → 把子类做成属性更合理。
- 如果你总是只查子类、从不用基类类型 → 考虑扁平化模型。
Predicate 中用 is 关键字过滤子类:
let predicate = #Predicate<Trip> { trip in
trip is PersonalTrip
}
Schema Migration Plan: 展示了从 iOS 17(version 2)到 iOS 18(version 3)再到 iOS 26(version 4)的完整迁移链。继承相关的迁移是 lightweight migration(自动处理),之前的版本有自定义 migration stage 做去重。
查询优化:
propertiesToFetch只加载需要的属性(迁移去重时只需要 name)。relationshipsToPrefetch预加载关系(迁移时知道要 reassign livingAccommodation)。fetchLimit限制结果数量(widget 只需要最近一条 trip)。
变更观察: 本地变化用 withObservationTracking(Observable protocol)。跨进程/跨 container 的变化用 SwiftData History。新增 sortBy 和 limit 到 history fetch,可以高效获取最新 history token,避免每次全量扫描。
值得深挖的点
继承的陷阱:不要用继承来共享公共属性。 如果所有有 name 属性的模型都继承自一个基类,层级关系会变得混乱。这种场景应该用 protocol。
Deep vs shallow search 的判断很实际。 很多时候你的模型设计应该跟着查询模式走,而不是反过来。如果你发现某个子类从未被单独查询过,那它可能不应该是一个子类。
History fetch 的 sortBy + limit 是个性能利器。 以前要拿最新 token 需要 fetch 全部 history 再取最后一条。现在 sort by transactionIdentifier descending + limit 1,一步到位。
preservedValueOnDeletion 的联动。 在 iOS 18 版本中标记的 preservedValueOnDeletion 属性,在 iOS 26 用 history 做变更追踪时会提供 tombstone 信息——可以知道被删除的是哪条数据。
代码片段
完整的 Schema Migration Plan(iOS 17 -> 18 -> 26):
struct SampleTripsMigrationPlan: SchemaMigrationPlan {
static var schemas: [VersionedSchema.Type] = [
SampleTripsSchemaV1.self, // iOS 17
SampleTripsSchemaV2.self, // iOS 17 - dedup
SampleTripsSchemaV3.self, // iOS 18 - unique/index
SampleTripsSchemaV4.self, // iOS 26 - inheritance
]
static var migrateStages: [MigrationStage] = [
MigrationStage.custom(
fromVersion: SampleTripsSchemaV1.self,
toVersion: SampleTripsSchemaV2.self,
willMigrate: { context in
// 去重逻辑
let trips = try context.fetch(FetchDescriptor<Trip>())
// ... dedup
}
),
MigrationStage.custom(
fromVersion: SampleTripsSchemaV2.self,
toVersion: SampleTripsSchemaV3.self,
willMigrate: { context in /* dedup again */ }
),
MigrationStage.lightweight(
fromVersion: SampleTripsSchemaV3.self,
toVersion: SampleTripsSchemaV4.self
),
]
}
@available(iOS 26, *)
struct SampleTripsSchemaV4: VersionedSchema {
static var versionIdentifier = Schema.Version(4, 0, 0)
static var models: [any PersistentModel.Type] = [
Trip.self, BusinessTrip.self, PersonalTrip.self
]
}
高效 History fetch 避免不必要的 refetch:
// 获取最新 token
var descriptor = FetchDescriptor<DefaultHistoryTransaction>()
descriptor.sortBy = [SortDescriptor(\.transactionIdentifier, order: .reverse)]
descriptor.fetchLimit = 1
let latestToken = try modelContext.fetchHistory(descriptor).first
// 只查感兴趣的变化
let predicate = #Predicate<DefaultHistoryTransaction> { txn in
txn.timestamp > lastTokenDate
}
最佳实践
先画查询模式再决定模型结构。 列出你的所有 fetch/query 场景,统计 deep vs shallow search 的比例。继承不是默认选择。
迁移测试要覆盖完整升级路径。 用户可能从任何历史版本直接升级到最新版。用 versioned schema + migration plan 确保每一步都能正确执行。
History token 要持久化。 存在 UserDefaults 或文件中,下次启动时从上次 token 继续,避免重复处理。
@available 标记要同步。 子类、version schema、migration stage 都需要标注 iOS 26 availability,否则编译器会报错。
还有什么值得关注
- SwiftData 的
Querymacro 会自动反映其他 model context 的变更(包括 widget 写入),但手动 fetch 不会。如果你用 fetch API,需要配合 history 判断是否需要 refetch。 - CloudKit 同步与继承模型完全兼容,无需额外配置。
- 配套视频:“What’s new in Swift” 有 Observable 和
withObservationTracking的最新变化。