SwiftData的真正成本是模式纪律
Get Bananas的ShoppingItem是说明为什么SwiftData模式纪律至关重要的标准范例。最初的模式中并不包含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消失后仍然存在),而当同一个UUID从两条同步路径到达时,upsert行为正是你想要的。
@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上工作的你将支付这笔税;现在的你只需付一次,然后就可以忘掉它。
用于困难情况的自定义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代码(获取描述符、模型上下文保存、运行中应用使用的相同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: ...))将一个ValueTransformer应用于非Codable类型
正确的纪律:对真正应当唯一的字段使用.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);否则直接使用文件系统,在SwiftData中保留指向文件URL的元数据。
该模式对在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?字段是在初始模式之后添加的(2025-12-01的提交268a00d,”Make lastModified optional to fix migration crash”),因为将其设为非可选会在现有行没有可填充值时破坏迁移。 ↩ -
Apple Developer,“SwiftData”和“Adding and editing persistent data in your app”。
@Model宏、@Attribute约束表面,以及与Core Data的NSManagedObjectModel的关系。 ↩↩ -
Apple Developer,“Preserving your app’s model data across launches”和“Adopting SwiftData for a Core Data app”。轻量级迁移语义以及触发框架放弃的情况。 ↩
-
Apple Developer,“VersionedSchema”和“SchemaMigrationPlan”。版本化模式声明、迁移阶段定义,以及接收迁移计划的
ModelContainer构造器。 ↩ -
Apple Developer,“Defining data relationships with enumerations and model classes”和“Schema.Relationship”。
@Relationship宏、deleteRule选项(.cascade、.nullify、.deny、.noAction),以及inverse:参数在双向关系维护中的作用。 ↩ -
作者在两个智能体生态、一份购物清单(2026年4月29日)和五个Apple平台,三份共享文件中的分析。Get Bananas + Return的跨进程和跨设备同步模式,在多进程工作流中补充(有时取代)SwiftData。 ↩