SwiftData的真正成本是模式纪律
类型: shipped-code。本文记录了Get Bananas、Return和Reps三款应用中的SwiftData模式决策:这三款应用的模式要么经历了一次干净的迁移,要么因未规划迁移而付出了代价。Get Bananas的ShoppingItem是典型范例。最初的模式不包含lastModified时间戳;后来添加它需要特定的迁移形态,因为现有数据已经在磁盘上,并且该字段被特意设为可选,以修复最初将其作为非可选添加时引发的迁移崩溃。1
SwiftData的API是两个宏。在类上加@Model使其成为持久化类型。在属性上加@Attribute(.unique)赋予其唯一性约束。该框架隐藏了Core Data的栈管理、值转换器编排以及NSManagedObjectContext样板代码。框架不会隐藏的是模式迁移;它只是让迁移变成声明式而非命令式。不关注迁移的代价就是常规更新中清除用户数据的那个bug。
核心论点:SwiftData启动便宜,但草率迁移代价高昂。纪律在于命名、可选性以及从第一天就使用VersionedSchema,而不是在你意识到应该这么做的那一天。
TL;DR
@Model宏将一个类变成持久化的SwiftData类型。框架在编译时根据属性声明生成模式。- 添加新的可选属性是无操作迁移:SwiftData的轻量级迁移会处理它。向现有模式添加非可选属性则需要一个
VersionedSchema加上一个MigrationPlan,告诉框架如何为现有行填充新字段。 - 从第一天就跳过
VersionedSchema的代价是:任何非平凡的v2模式更改都有可能丢弃用户的数据库,因为轻量级路径是保守的,在无法推断迁移时会放弃。 @Attribute(.unique)是自然键(你生成的UUID、你导入的外部ID)的正确工具。@Relationship是父/子引用的正确工具。两者都是宏,会在底层生成正确的Core Data管道。2
@Model实际上做了什么
SwiftData类型是一个应用了@Model宏的Swift类。Get Bananas的ShoppingItem是典型形态:
import Foundation
import SwiftData
@Model
final class ShoppingItem {
@Attribute(.unique) var id: UUID
var name: String
var amount: String
var section: String
var isChecked: Bool
var isOptional: Bool
var sortOrder: Int
var lastModified: Date?
init(id: UUID = UUID(), name: String, amount: String, section: String,
isOptional: Bool = false, sortOrder: Int = 0) {
self.id = id
self.name = name
self.amount = amount
self.section = section
self.isChecked = false
self.isOptional = isOptional
self.sortOrder = sortOrder
self.lastModified = Date()
}
}
关于这个形态有三个细节是API所掩盖的。
@Model不需要单独的持久化存储模式声明。 SwiftData在编译时读取类定义并合成模式。类的属性成为模型的属性;它们的Swift类型成为列类型。没有需要维护的.xcdatamodeld文件(不过Core Data底层的NSManagedObjectModel仍然存在,并且在运行时支撑该模式)。2
@Attribute(.unique)是单列上的约束,而不是PRIMARY KEY声明。 SwiftData的持久化身份是PersistentIdentifier,由系统为每行自动生成。@Attribute(.unique)声明告诉框架”此列每个值最多存储一行”。当你插入一个.unique值已存在的模型时,SwiftData执行upsert:现有行被更新,而非被拒绝。这个语义对于产品代码很关键:.unique不是阻止重复提交的UI层验证;它是悄悄合并的至多一条存储保证。上面的id: UUID模式是跨进程同步的推荐形式(你需要一个稳定的标识符,能在进程内的PersistentIdentifier消失后依然存活),而upsert行为正是当同一UUID从两条同步路径到达时你想要的。
@Model类是引用类型,不是值类型。 修改ShoppingItem实例上的属性会触发SwiftData的变更跟踪;框架登记该变更并在下次上下文保存时持久化。通过@Query进行的SwiftUI集成会重新渲染任何观察匹配谓词的视图。这个模式与@Observable类似(参见SwiftUI由什么构成),只是在其上叠加了持久化。
可选字段是廉价的迁移
ShoppingItem上的lastModified: Date?字段是可选的,而这个可选性至关重要。该字段在v1发布之后被添加,用于支持跨设备同步和冲突解决;用户设备上的现有行没有lastModified值。一个没有默认值的可选字段让SwiftData的轻量级迁移在无需编写任何迁移代码的情况下处理添加:现有行得到nil;新行得到init所设置的任何值。3
轻量级迁移路径是框架的礼貌路径。SwiftData检查新模式和持久化存储,推断最小兼容的更改,并应用它。迁移是自动的;用户什么都看不到;应用在现有数据上正常启动。轻量级路径能干净处理的情况:
- 添加可选属性
- 移除属性(数据被丢弃;现有读取不再看到该列)
- 重命名框架可通过提示匹配的属性(使用
@Attribute(originalName: ...)) - 重命名框架可匹配的
@Model类(使用@Model.originalName或提示)
轻量级路径会放弃的情况:
- 向现有模式添加无默认值的非可选属性(现有行没有值可填充)
- 更改属性的类型(例如,
Int→String) - 将一个模型拆分为两个,或将两个合并为一个
- 任何需要自定义逻辑来迁移的情况
当轻量级路径放弃时,安全的行为是让迁移失败。不安全的行为是丢弃数据库重新开始;框架是保守的,拒绝默默这样做。用户看到应用启动时崩溃并报迁移错误;开发者看到指向模式不匹配的堆栈跟踪;没有人丢失数据,但每个人都失去信心。
从第一天就跳过VersionedSchema的代价会在v2 → v3的边界显现,那时你添加第三个特性,其模式更改超出了轻量级路径的处理能力。
VersionedSchema与MigrationPlan:第一天的纪律
VersionedSchema声明模型模式的特定版本。MigrationPlan声明如何从一个版本迁移到下一个版本。4 形态如下:
import SwiftData
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV1.self]
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] = [
SchemaV1.self,
SchemaV2.self,
]
static var stages: [MigrationStage] = [
MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
]
}
模型类本身移动到版本化模式命名空间内:
extension SchemaV1 {
@Model
final class ShoppingItemV1 { /* v1 fields */ }
}
extension SchemaV2 {
@Model
final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}
ModelContainer使用迁移计划构造:
let container = try ModelContainer(
for: ShoppingItemV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration("ShoppingList")
)
迁移计划为框架提供了一个类型化的图,描述模式如何演化。当v2版的应用对v1数据库启动时,框架沿着迁移计划走,应用所命名的阶段,并把数据库带到v2。当你发布v3时,你向schemas添加SchemaV3.self并在v2与v3之间添加新的MigrationStage。
纪律是即使只有一个版本,也要在v1中发布VersionedSchema。这样做的成本是一个额外的文件和一个额外的enum声明。不这样做的成本是:v2的第一次非平凡模式变更需要追溯地把v1包装在VersionedSchema中,这是可行的,但需要小心地匹配v1的精确形态,以便框架能将现有数据识别为SchemaV1。未来在v2上工作的你将支付这笔税;现在的你可以一次性支付然后忘掉它。
用于困难情况的Custom MigrationStage
轻量级迁移涵盖了大多数添加性变更。类型变更、拆分、合并和条件填充需要MigrationStage.custom:
static var stages: [MigrationStage] = [
MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Read v1 rows; stage any derived state to a transient store
// (UserDefaults / temp file) since the v1 and v2 contexts do
// not share state, and didMigrate cannot read v1.
let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
stageDerivedState(from: v1Items)
},
didMigrate: { context in
// Populate v2-only fields on existing rows
let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
for item in v2Items where item.lastModified == nil {
item.lastModified = Date()
}
try context.save()
}
)
]
这两个闭包在框架应用结构性迁移之前和之后触发。willMigrate针对v1模式运行;didMigrate针对v2模式运行。闭包体是普通的SwiftData代码(fetch描述符、模型上下文保存、运行中应用所用的相同API),针对一个临时的迁移中上下文运行。
在生产中行得通的模式是:保持willMigrate为空,把所有填充逻辑放到didMigrate里。允许在willMigrate内读取v1数据,但从框架视角看v2模式还不存在,所以任何计算都必须暂存到一个didMigrate闭包能读取的临时存储里。更简单的规则:结构性迁移是框架的任务;在现有行上填充v2专属字段是didMigrate的任务。
@Attribute和@Relationship何时名副其实
两个宏在@Model类中完成大部分模式装饰工作。
@Attribute用约束或提示装饰单个属性:
@Attribute(.unique)强制唯一性,如ShoppingItem.id@Attribute(.externalStorage)将大型Data二进制对象存储在数据库之外(图像数据、音频缓冲区)@Attribute(originalName: "old_field_name")在迁移期间将属性匹配到重命名的列@Attribute(.transformable(by: ...))对非Codable类型应用ValueTransformer
正确的纪律:对真正应该唯一的字段使用.unique(你生成的UUID、外部ID),对超过几KB的任何二进制对象使用.externalStorage,当v2的属性重命名否则会丢失v1数据时使用originalName。
@Relationship装饰指向另一个@Model类或其集合的属性:
@Model
final class List {
var name: String
@Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
var items: [ShoppingItem] = []
}
@Model
final class ShoppingItem {
var name: String
var list: List?
}
deleteRule: .cascade意味着删除父List会删除所有子ShoppingItem行。inverse:参数告诉框架子级上的哪个属性指回父级;框架使用它来进行可预测的双向维护。SwiftData有时可以自动推断逆向,并且支持inverse: nil用于显式的单向关系,但安全的默认是只要推断会有歧义就声明inverse:。5
正确的纪律:用显式的deleteRule声明关系(默认是.nullify,这很少是你想要的),并在关系是双向时声明inverse:(而不是依赖框架的推断)。隐式默认通常是错的;显式形式只是一个额外参数,加上一个永远规避的bug。
我会有什么不同的构建方式
集群中的应用要么实施了,要么希望实施的三个模式。
从v1开始就发布VersionedSchema。 每个发布的@Model类从第一天起都应该位于VersionedSchema内部。成本是每个模式版本一个包裹的enum。好处是v2的第一个非平凡变更只是向MigrationPlan.schemas添加一行,而不是为期两天的追溯重构。
把每个时间戳设为可选。 那些为跨设备同步或冲突解决而存在的字段,如lastModified、createdAt和updatedAt,如果v1产品不需要它们,应该在v1中设为可选。可选性让到v2的迁移(当你确实需要它们时)变得便宜。在didMigrate期间为现有行填充它们是一个循环;从v1开始就让它们非可选则是一个可能在用户数据上破坏回填的约束。
使用UUID作为自然键,而不是PersistentIdentifier。 SwiftData的PersistentIdentifier是进程内的。跨设备同步、MCP集成(参见两个智能体生态,一个购物清单)以及任何进程外引用都需要稳定的标识符。带有@Attribute(.unique)的UUID是正确形态;进程内的PersistentIdentifier对任何跨进程边界的事物都是错误形态。
@Model什么时候是错误答案
SwiftData不是合适工具的三种情况:
单记录键/值状态。 应用设置、用户选择的语言、上次同步的时间戳。使用UserDefaults或NSUbiquitousKeyValueStore(参见五个Apple平台,三个共享文件)。SwiftData对单行的开销是浪费的仪式;键值存储是正确的底层。
服务器权威数据,无离线写入。 从REST API获取并只读显示的列表。如果真相之源是服务器,本地缓存只是缓存,那么SwiftData是过度设计。一个简单的Documents/下的Codable快照加上内存缓存数组就够了;如果数据在硬重置后无需保留,那么SwiftData的迁移税不值得支付。
多进程协调。 SwiftData在进程内运行。在iOS应用之外运行的MCP服务器无法读取或写入应用的SwiftData容器。跨进程状态需要不同的形态:iCloud Drive JSON文件、共享App Group容器,或者一个桥接进程的显式同步层。(Get Bananas正是出于这个原因将SwiftData与iCloud Drive JSON配对。)6
数据是很少变化的大型二进制对象。 一个10MB的音频文件、一个50MB的图像数据集。如果二进制对象在SwiftData行内,使用@Attribute(.externalStorage);否则直接使用文件系统,把指向文件URL的元数据放在SwiftData里。
这种模式对在iOS 26+上发布的应用意味着什么
三个要点。
-
宏是简单的部分。迁移才是成本。
@Model和@Attribute是两行声明,隐藏了大量Core Data管道。迁移纪律才是你在应用生命周期里实际支付的;设计v1时要心怀v2。 -
从第一天起的
VersionedSchema对发布应用是不可妥协的。 包裹的enum只是一个额外文件。后期添加的追溯成本要高得多。 -
可选字段和显式关系是廉价的保险。 用于同步元数据的可选时间戳、关系上的显式
deleteRule和inverse:。两者都是微小的声明,却换来大量v2的灵活性。
完整的Apple生态集群:用于Apple Intelligence的类型化App Intents;用于跨LLM智能体的MCP服务器;二者之间的路由问题;用于设备端LLM和Tool协议的Foundation Models;用于iOS锁屏状态机的Live Activities;Apple Watch上的watchOS运行时契约;作为框架底层的SwiftUI内部;用于visionOS场景的RealityKit的空间心智模型;用于视觉层的Liquid Glass模式;用于跨设备覆盖的多平台发布。中心位于Apple生态系列。要了解更广泛的iOS与AI智能体的语境,请参阅iOS智能体开发指南。
FAQ
@Model与Core Data的NSManagedObject有什么区别?
@Model是一个Swift宏,在底层生成NSManagedObject管道。SwiftData使用Core Data作为其支撑存储,因此运行时模型是相同的;区别在于表面。@Model移除了.xcdatamodeld文件、值转换器仪式以及NSManagedObjectContext生命周期管理。你得到的是同一个持久化存储,但有一个Swift形态的API。
如果我从不打算改变模式,需要VersionedSchema吗?
如果你的应用可能发布v2,那就需要。如果它是一次性演示,那就不需要。从v1开始就有VersionedSchema的成本是一个额外的enum声明。在v2追溯添加它的成本是匹配精确的v1模式形态,以便框架识别现有数据,这是可行但容易出错的。大多数发布的应用最终都会需要模式变更;在v1中为它预算。
我什么时候应该使用@Attribute(.unique)?
当字段是行的自然键时:你生成的UUID、你导入的外部ID、你分配的slug。SwiftData将.unique视为upsert:如果你插入一个.unique值已存在的模型,现有行会被更新,而不是追加新行。这种语义是upsert风格同步路径(同一UUID来自两台设备)安全的原因;这也是为什么.unique对于像title这样的显示名字段是错误工具,因为两个用户键入相同标题会悄悄合并他们的行,而不是产生两条不同的记录。
我如何处理向现有模式添加的非可选字段?
使用带有didMigrate闭包的MigrationStage.custom,在现有行上填充该字段。或者,更简单:在新模式版本中将该字段声明为可选,并在访问时惰性填充。可选性是更便宜的迁移;非可选添加需要显式的填充逻辑。
PersistentIdentifier与我自己的UUID有什么区别?
PersistentIdentifier是SwiftData的进程内行ID;它自动生成并在运行进程的生命周期内存活。你自己的带@Attribute(.unique)的UUID是稳定的跨进程、跨设备标识符。在应用内部使用PersistentIdentifier进行进程内引用。对任何跨进程边界的事物(跨设备同步、外部集成、MCP工具、网络调用)使用UUID。
参考资料
-
作者的Get Bananas,一款将SwiftData与iCloud Drive JSON同步以及MCP服务器配对的SwiftUI购物清单应用。
ShoppingItem模型在早期开发周期中演化;lastModified: Date?字段在初始模式之后被添加(提交268a00d,日期2025-12-01,”将lastModified设为可选以修复迁移崩溃”),因为将其设为非可选会在现有行没有值可填充时破坏迁移。 ↩ -
Apple Developer,“SwiftData”和“在应用中添加和编辑持久化数据”。
@Model宏、@Attribute约束表面以及与Core Data的NSManagedObjectModel的关系。 ↩↩ -
Apple Developer,“跨启动保留应用的模型数据”和“为Core Data应用采用SwiftData”。轻量级迁移语义以及触发框架放弃的因素。 ↩
-
Apple Developer,“VersionedSchema”和“SchemaMigrationPlan”。版本化模式声明、迁移阶段定义以及接受迁移计划的
ModelContainer构造器。 ↩ -
Apple Developer,“用枚举和模型类定义数据关系”和“Schema.Relationship”。
@Relationship宏、deleteRule选项(.cascade、.nullify、.deny、.noAction)以及inverse:参数在双向关系维护中的角色。 ↩ -
作者在两个智能体生态,一个购物清单(2026年4月29日)和五个Apple平台,三个共享文件中的分析。Get Bananas + Return的跨进程和跨设备同步模式,在多进程工作流中与SwiftData互补(有时也替代)。 ↩