← 所有文章

SwiftData 迁移:lightweight 与 custom 的取舍,以及何时并不需要 V2

相比 Core Data,SwiftData 的架构迁移在结构上是一次改进,但有一个陷阱让不少团队反复踩中:为那些只要给出内联默认值、SwiftData 就会自动处理的变更,专门声明一个新的 VersionedSchema。结果是代码看着没问题、编译也干干净净,却在真机上崩溃并抛出“Duplicate version checksums across stages detected”。这套框架真正的迁移模型由三个部件(VersionedSchemaMigrationStageSchemaMigrationPlan)和三种迁移类型(自动 lightweight、显式声明的 lightweight、custom)组成1。大多数架构变更都是自动完成的。一部分需要显式声明的 lightweight 阶段。只有极少数需要带 willMigratedidMigrate 闭包的 custom 阶段。

本文对照 Apple 的文档梳理这套迁移模型,逐一点明每种迁移类型所覆盖的场景,并介绍 iOS 26 的类继承支持,以及 iOS 27 beta 把迁移放在了什么位置。全文的视角始终是“哪些需要我自己声明,哪些由 SwiftData 替我处理”,因为正是这个判断决定了迁移是干净上线,还是在首次启动时崩溃。与之配套的问题——如何设计 v1 架构,让后续迁移始终廉价——在SwiftData 真正的成本是架构纪律中讨论。

要点速览

  • SwiftData 迁移由三个协议组合而成:VersionedSchema(某个版本上模型类型的快照)、MigrationStage(从 fromVersion 到 toVersion 的单次转换,分 .lightweight.custom 两种情形)以及 SchemaMigrationPlan(按顺序排列的阶段列表)1
  • 新增一个带内联默认值的 @Model 属性(var foo: Bool = false)并不需要新的 VersionedSchema。SwiftData 会作为 lightweight 迁移自动处理。为此声明 V2 反而会带来“Duplicate version checksums across stages detected”崩溃。
  • lightweight 迁移能够处理:新增、重命名、删除实体、属性和关系;变更关系类型;用 @Attribute(originalName:) 记录重命名;指定删除规则。大多数架构变更都落在这个范围内。
  • custom 迁移(MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))处理的是数据变换:把一列拆成两列、计算派生字段、在模型之间搬运数据。willMigrate 拿到的是旧上下文,didMigrate 拿到的是新上下文。
  • iOS 26 为 @Model 类型加入了类继承2。采用继承的架构需要升到新版本,并从此前扁平模型的版本用一个 lightweight 阶段衔接过来。

三个部件构成的模型

一次 SwiftData 迁移,由三个部件组装而成。

VersionedSchema

特定架构版本上模型类型的快照1。该协议要求提供两项内容:

  • static var versionIdentifier: Schema.Version。一个语义化版本三元组(Schema.Version(1, 0, 0))。
  • static var models: [any PersistentModel.Type]。该版本中 @Model 类型的数组。
enum SchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    final class Item {
        var name: String
        var createdAt: Date
        init(name: String, createdAt: Date) {
            self.name = name
            self.createdAt = createdAt
        }
    }
}

在 enum 中嵌套类型是惯用写法。每个 VersionedSchema 都为自己的模型类提供命名空间,因此同名模型的多个架构可以在迁移期间共存于同一份代码里。

MigrationStage

两个 VersionedSchema 类型之间的单次转换3。它有两种情形:

  • .lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type)。声明一次无需应用代码、由 SwiftData 自行处理的转换。参数传入的是 VersionedSchema 类型本身(例如 SchemaV1.self),而不是原始的 Schema.Version 值。
  • .custom(fromVersion:toVersion:willMigrate:didMigrate:)。声明一次带代码的转换,这些代码会在数据迁移之前和/或之后运行。版本参数的类型与 .lightweight 相同。

SchemaMigrationPlan

把架构从任意早期版本带到当前版本的、按顺序排列的阶段列表1

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self, SchemaV3.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2, migrateV2toV3]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )

    static let migrateV2toV3 = MigrationStage.custom(
        fromVersion: SchemaV2.self,
        toVersion: SchemaV3.self,
        willMigrate: { context in
            // Pre-migration: read old data, prepare it
            try context.save()
        },
        didMigrate: { context in
            // Post-migration: backfill new fields
            let descriptor = FetchDescriptor<SchemaV3.Item>()
            let items = try context.fetch(descriptor)
            for item in items {
                item.computedField = computeFromExisting(item)
            }
            try context.save()
        }
    )
}

ModelContainer 需要同时拿到当前架构和迁移计划:

let container = try ModelContainer(
    for: SchemaV3.Item.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration(...)
)

创建容器时,SwiftData 会读取持久化存储当前的架构版本,从该版本沿着计划中的阶段一路走到当前版本,并按顺序逐个应用。

lightweight 迁移自动处理的范围

大多数架构变更都不需要 custom 阶段1

  • 新增带默认值的属性。 在既有 @Model 上写 var foo: Bool = false 会被自动处理。
  • 新增实体(模型类)。 当某个 VersionedSchema 成为当前版本时,新类型随之出现,既有数据保持不变。
  • 移除属性或实体。 SwiftData 会删除对应的列或表。
  • 重命名属性或实体。 给属性加上 @Attribute(originalName: "oldName") 即可保留数据,SwiftData 会把旧名映射到新名。
  • 变更关系类型。 一对多、多对多等等。
  • 指定删除规则。@Relationship(deleteRule: .cascade) 这类添加属于 lightweight。

对于列表中的这些变更,只要模型类型没有别的改动,正确的做法就是完全不声明新的 VersionedSchema。SwiftData 会针对既有架构自动执行 lightweight 迁移。

陷阱:加一个字段并不需要 V2

SwiftData 迁移中最常见的错误是这样的:开发者加了一个带内联默认值的新属性(var foo: Bool = false),接着声明了一个 SchemaV2,而它引用的模型类型和 SchemaV1 完全相同。编译一切正常。可是在已有 V1 数据的设备上首次启动时,应用会以 Duplicate version checksums across stages detected 崩溃,因为 SchemaV1SchemaV2 解析出的校验和相同(模型类型并没有发生 SwiftData 认定为差异的变化)。

正确的做法是:不动既有的 VersionedSchema,给模型加上带内联默认值的新属性,交给 SwiftData 的自动 lightweight 迁移。不需要 MigrationPlan,不需要 MigrationStage,也不需要 V2。

// V1 schema
enum SchemaV1: VersionedSchema {
    @Model
    final class Item {
        var name: String
        // BEFORE: just these two properties
        var createdAt: Date
        // AFTER: add a third with inline default
        var isFavorite: Bool = false   // Lightweight, automatic
    }
}

var isFavorite: Bool = false 这样的改动无需任何 MigrationStage 声明即可上线。不传 migrationPlan:ModelContainer 初始化器照常可用:

let container = try ModelContainer(
    for: SchemaV1.Item.self,
    configurations: ModelConfiguration(...)
)

只有当某个变更无法用 lightweight 完成时(数据变换、模型拆分、需要自定义逻辑的继承重构),才真正需要 V2 架构。在这些场景里,V2 是实打实的,并由 SchemaMigrationPlan 统筹整个转换。

何时必须使用 custom 迁移

custom 迁移在三类场景中值回它的复杂度。

1. 把一个字段拆成多个。 一个保存 "Last, First"String 字段,要拆成 firstNamelastName 两个字段。迁移需要读取旧值、解析它,再写入新字段。

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        let descriptor = FetchDescriptor<SchemaV2.Person>()
        let people = try context.fetch(descriptor)
        for person in people {
            let parts = person.fullName.split(separator: ", ", maxSplits: 1)
            person.lastName = String(parts.first ?? "")
            person.firstName = String(parts.dropFirst().first ?? "")
        }
        try context.save()
    }
)

didMigrate 闭包运行在新架构的上下文中,因此可以访问新字段。旧的 fullName 或许需要推迟删除,等新字段填充完毕之后再清理;这次清理属于后续 V2 到 V3 的阶段。

2. 计算派生字段。 依赖既有数据的新 @Attribute,需要在迁移时回填。

3. 在模型之间搬运数据。Item 的数据拆分到 Item 和一个新的 Tag 模型中,这样的重组需要自定义逻辑,从旧数据里派生出标签。

一条通用的判断标准是:架构形状变了,用 lightweight;数据形状变了,用 custom。

willMigratedidMigrate 的区别

custom 阶段有两个闭包,分别在不同时机被调用4

willMigrate 在 SwiftData 应用架构迁移之前运行。闭包收到的模型上下文是架构的上下文。适合在脚下的架构发生变化之前,先抓取数据、做反范式化,或者准备一些辅助状态。

didMigrate 在架构迁移之后运行。模型上下文属于架构。适合回填新字段、计算派生数据,或者为迁移收尾。

如果不需要,两个闭包都可以是 nil。大多数 custom 迁移只用 didMigrate;当迁移需要读取那些在架构变更后就再也访问不到的旧数据时,willMigrate 才派得上用场。

闭包接收一个 ModelContext,可以查询、修改和保存。闭包是 throwing 的;错误会向外传播出迁移,并中止它。

iOS 26:@Model 的类继承

iOS 26 为 SwiftData 模型引入了类继承2。模型之间从此可以建立父子关系:

@Model
class Vehicle {
    var make: String
    var year: Int
    init(make: String, year: Int) {
        self.make = make
        self.year = year
    }
}

@Model
final class Car: Vehicle {
    var doorCount: Int
    init(make: String, year: Int, doorCount: Int) {
        self.doorCount = doorCount
        super.init(make: make, year: year)
    }
}

采用继承的架构要升到新版本,并从此前扁平模型的版本用一个 lightweight 迁移阶段衔接过来。只要继承保留了既有属性,这次转换就是自动的;子类上的新字段,仍然遵循标准的内联默认值写法。

这套写法适合多个 @Model 类型共享特征的场景:Vehicle 作为父类,CarTruckMotorcycle 作为子类;Account 作为父类,CheckingAccountSavingsAccount 作为子类。共有属性放在父类,各自特有的放在子类。

iOS 27:迁移模型不变,存储变得可观察

iOS 27 beta 完全没有改动迁移机制本身。VersionedSchemaMigrationStageSchemaMigrationPlan 原样延续,上文的每一种写法都照旧适用。iOS 27 新增的东西位于迁移旁边,而不是迁移内部:一个新的“Data store observation”领域,包含 ResultsObserverHistoryObserver 两个类型,外加一个 @Attribute(.codable) 选项,用属性的 Codable 表示来存储该属性6

其中有两项,值得在一篇迁移指南里提上一笔。

@Attribute(.codable) 减轻了未来的迁移压力。 能够声明式地存储一个 Codable 值类型,就少了很多先把结构体摊平成若干列、日后再写一个 custom 阶段把它拼回来的情况。在新属性上采用该选项的架构,遵循前文标准的内联默认值规则;它是一个属性选项,而非架构形状的变化6

HistoryObserver 为 custom 迁移之后的流程收口。 didMigrate 中的回填会写入一批数据行,应用的其余部分(以及任何正在观察该存储的小组件或扩展)都需要知道它们的存在。在 iOS 27 上,通过 HistoryObserver 观察持久化历史的观察者,能看到迁移的事务落地,并可调用 ModelContext.fetchHistory,按模型类型和事务作者过滤,精确读取到底有哪些改动,而不必把所有数据重新取一遍6。关于 observation 的完整脉络,参见 iOS 27 中的 SwiftData:observation 与 history

对规划而言的结论是:27 beta 中没有任何东西会迫使你提升架构版本,也没有迁移代码需要重写。在那些迁移后对账逻辑原本靠轮询或重新取数的地方,换用新的 observation 类型即可。

测试迁移

能编译通过的迁移,未必是能上线的迁移。发布之前有三种测试值得跑一遍。

1. 用生产数据库副本做往返测试。 取一份近期的生产形态数据库(或者用测试生成合成的 V1 数据),用认识 V2 的容器打开它,验证数据是否正确迁移。类型检查器抓不到的 custom 迁移缺陷,正是靠这类测试暴露。

2. 从旧版本启动的验证。 构建上一个应用版本,运行一次以产生 V1 数据,然后构建新版本,确认它能不崩溃地启动。这个测试能抓住“Duplicate version checksums”这类陷阱,以及类似的声明失误。

3. 迁移失败后的恢复。 如果迁移抛出错误会怎样?SwiftData 的行为取决于容器的配置;对生产应用来说,未被处理的迁移错误绝不该悄悄删掉用户数据。请显式地测试失败路径,并决定应用要做什么(回滚、提示用户、从备份恢复)。

同一系列中的 Single Source of Truth 一文讨论了一个相关问题:当 SwiftData 存储因跨进程同步而被替换时会发生什么。迁移,正是那套模式在本地演进方向上的对应物。

跨进程上线与展示迁移进度

有两个运维层面的细节,文档并未突出,但 SwiftData 团队在 WWDC 2026 上专门点了出来5:当应用带有小组件或扩展时,迁移到底在哪里执行;以及迁移进行时,如何驱动进度界面。

迁移只属于一个进程。 小组件和扩展拿不到主应用那样的运行时资源,因此无法安全地执行迁移。给出的指引是:把 SchemaMigrationPlan1 从小组件和扩展的 target 中彻底移出,永远不要从它们发起迁移。选定一个进程作为数据库的所有者,通常就是主应用。如果小组件打开容器时,磁盘上的存储仍处于未版本化(较旧)的架构,打开操作会报错。请把这个错误当作“需要迁移”的信号:给出界面提示用户打开主应用,由应用完成迁移,并把迁移后的架构版本写入共享的 UserDefault。小组件下次读取该值,就能以应用已经迁移到的版本打开容器。这套做法保证始终只有一个写入方,避免两个进程争着演进同一个文件。

进度按阶段数计算,而不是按挂钟时间。 SwiftData 没有提供专门的迁移进度 API5。要驱动进度指示器,就统计计划中 custom 迁移阶段的总数,并重写每个阶段的 didMigrate 处理逻辑4,让每个阶段报告自己的位置,即“第 N 个,共 M 个”。这个数字反映的是已完成的阶段数,而非已用时间,因此进度条是一格一格跳,而不是平滑推进。与之配套的设计决策,是迁移期间应用给用户看什么:一个光秃秃的转圈会被读成卡死,用户随即流失。在数据允许的前提下让应用保持部分可用,至少也要说明每个阶段在添加什么(这次迁移解锁了哪些新功能),这样等待才会被读成朝着某个目标的进展,而不是白白耗掉的时间。

常见失败模式

从 SwiftData 的失败记录中总结出三种情形。

为 SwiftData 本可自动处理的变更声明 V2。 也就是“Duplicate version checksums”崩溃。解决办法:不要为内联默认值的属性新增声明一份新架构,交给 SwiftData 自动处理。

不保存的 custom 迁移代码。 一个修改了实体却没有调用 context.save()didMigrate 闭包,会产生这样一次迁移:跑了一遍,丢掉成果,然后每次启动都重跑(因为这次迁移看起来尚未完成)。解决办法:凡是修改数据的闭包,返回前都必须执行 try context.save()

重命名属性时没有加 @Attribute(originalName:) SwiftData 会把新属性当成新增、把旧属性当成删除,旧属性上的既有数据随之丢失。解决办法:声明 @Attribute(originalName: "oldName") var newName: ...,让 SwiftData 跨越这次重命名把数据映射过去。

这套模式对 iOS 26+ 应用意味着什么

三点结论。

  1. 默认不要搭 VersionedSchema 阶梯。 新增带内联默认值的属性、删除弃用字段、用 @Attribute(originalName:) 重命名——全都是 lightweight 且自动的。VersionedSchema 阶梯留给 SwiftData 确实无法自动处理的变更(数据变换、自定义逻辑、继承重构)。

  2. MigrationStage.custom 用于数据变换,而不是架构形状的变化。 willMigratedidMigrate 闭包是给操作数据的代码用的,不是用来声明“架构变了”。架构形状的变化,走 lightweight 阶段。

  3. 用真实的 V1 数据测试迁移,不要只用合成测试数据。 在合成数据往返中通过的迁移,仍可能在生产形态的数据上因边界情况失败(架构没考虑到的可空字段、大到触发超时的数据集等等)。测试的成本很小,而首次启动就迁移崩溃的代价是实打实的。

完整的 Apple Ecosystem 系列:带类型的 App IntentsMCP 服务器路由之问Foundation Models运行时 LLM 与工具链 LLM 的区分三个界面single source of truth 模式两个 MCP 服务器面向 Apple 开发的 hooksLive ActivitieswatchOS 运行时SwiftUI 的内部构造RealityKit 的空间心智模型SwiftData 架构纪律Liquid Glass 模式多平台交付平台矩阵Vision 框架Symbol EffectsCore ML 端侧推理Writing Tools APISwift TestingPrivacy Manifest作为平台能力的无障碍SF Pro 排版体系visionOS 空间模式Speech 框架我拒绝写的那些主题。系列主页在 Apple Ecosystem 系列。若想了解 iOS 与 AI 智能体结合的更广背景,请参阅 iOS Agent Development 指南

常见问题

我是不是总需要 SchemaMigrationPlan

不是。只有一个架构版本的应用(首个发布版本,或者一路只做过 lightweight 变更的应用)并不需要 SchemaMigrationPlanModelContainer 初始化器可以直接接收架构中的模型。只有在第一次声明 custom 迁移阶段时(或者开发者第一次想显式声明版本阶梯时),migrationPlan: 参数才变得必要。

我怎么判断自己的改动是不是 lightweight?

Apple 给出的 lightweight 适用清单是1:新增实体、属性和关系,移除它们,用 @Attribute(originalName:) 重命名,变更关系的基数,指定删除规则。如果改动落在其中之一,且模型类的结构没有别的变化,那么迁移就是自动的,不需要 VersionedSchema 阶梯。如果改动需要数据变换(计算、拆分、搬运数据),那就是 custom。

willMigratedidMigrate 可以同时设置吗?

可以。两个闭包各自都是可选的,但也可以同时提供。willMigrate 在 SwiftData 迁移之前、针对旧架构的上下文运行;didMigrate 在迁移之后、针对新架构的上下文运行。二者分别负责准备与收尾。

如果迁移抛出错误会怎样?

错误会从 ModelContainer 的初始化中向外传播,容器打开失败。此后应用的行为,取决于开发者如何处理这个错误:有的应用会展示恢复界面,有的会尝试从备份还原,有的则删除损坏的存储重新开始。SwiftData 不会在迁移失败时悄悄删除用户数据;如何应对失败,是应用自己的责任。

如何在不影响生产数据的前提下测试迁移?

建一个测试 target,创建一个指向临时文件 URL 的 ModelContainer,往里填入 V1 数据,然后用包含迁移计划的新容器打开它,验证迁移后的数据是否符合预期。这套做法在单元测试和集成测试中都适用;若想得到最贴近现实的结果,请使用一份真实生产形态数据库的副本。

iOS 26 的类继承能用在既有架构上吗?

可以,配合一次 lightweight 迁移即可。采用继承的应用需要升到新的架构版本(例如 V4),并声明 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。原本扁平的父类属性保持不变,子类特有的属性以内联默认值的方式加入。这种结构性变化由 SwiftData 的 lightweight 迁移负责处理。

参考资料


  1. Apple Developer Documentation:VersionedSchemaSchemaMigrationPlan 协议参考,即迁移模型本身。关于架构演进的完整叙述,另见相关指南 Adopting SwiftData for a Core Data app。 

  2. Apple Developer:SwiftData: Dive into inheritance and schema migration(WWDC 2025 第 291 场)。介绍 iOS 26 中 SwiftData 类继承的引入。 

  3. Apple Developer Documentation:MigrationStage,其中包含 .lightweight(fromVersion:toVersion:).custom(fromVersion:toVersion:willMigrate:didMigrate:) 两种情形。 

  4. Apple Developer Documentation:情形的签名参见 MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)。willMigrate 针对旧上下文运行、didMigrate 针对新上下文运行的语义,记录在 WWDC 2025 第 291 场 SwiftData: Dive into inheritance and schema migration 中,与前文引用 iOS 26 继承特性的是同一场。 

  5. WWDC 2026 SwiftData Group Lab(第 8017 场)。内容转述自 WWDC 2026 SwiftData Group Lab 的本地转写录音;Apple 并未为这类 Lab 提供官方字幕。其中,小组件与扩展的迁移限制(迁移只属于一个进程,报错即是需要迁移的信号,迁移后的版本存入 UserDefault)以及按阶段数计算进度的技巧(由于不存在专门的进度 API,重写每个阶段的 didMigrate 处理逻辑来报告“第 N 个,共 M 个”),均由 SwiftData 工程团队在座谈中说明。SchemaMigrationPlanMigrationStage.customdidMigrate 等符号,已与 14 所引的 Apple Developer 文档核对一致;至于不存在专门的进度 API 这一点,反映的是座谈中工程团队自己的表述。 

  6. Apple Developer Documentation:ResultsObserverHistoryObserver(均为 iOS 27.0 beta,归入 SwiftData 的“Data store observation”主题),以及 Schema.Attribute.Option.codable(iOS 27.0 beta),“使用该属性的 codable 表示来存储它”。根据 WWDC26 第 274 场 What’s new in SwiftDataHistoryObserver 暴露了一个可观察的 eventCounter,每当有新事务落地就会递增,代码则通过调用 ModelContext.fetchHistory 并按模型类型和事务作者过滤来做出响应。 

相关文章

SwiftData 真正的成本在于 Schema 纪律

SwiftData 的 API 只有两个宏,真正的成本出现在上线之后。可选字段是廉价的迁移;新增非可选字段则需要 VersionedSchema。

15 分钟阅读

iOS 27 中的 SwiftData:观察与历史记录

iOS 27 为 SwiftData 带来一等的变更观察能力:用 ResultsObserver 跟踪变更、用 HistoryObserver 观察持久化历史,并支持 codable 属性存储。

9 分钟阅读

安装与更新 Codex CLI:Mac、Linux、Windows

安装、更新、锁定版本与卸载 OpenAI Codex CLI 的每一种方式:安装脚本、npm、Homebrew、winget,覆盖 macOS、Linux、WSL 与 Windows。

9 分钟阅读