SwiftData 遷移:lightweight 與 custom 的取捨,以及何時根本不需要 V2
比起 Core Data,SwiftData 的架構遷移在結構上是一次改良,卻有一個陷阱讓不少團隊一再踩中:為了那些只要給出行內預設值、SwiftData 就會自動處理的變更,特地宣告一個新的 VersionedSchema。結果是程式碼看起來沒問題、建置也乾乾淨淨,卻在實機上以「Duplicate version checksums across stages detected」崩潰。這套框架真正的遷移模型由三個部件(VersionedSchema、MigrationStage、SchemaMigrationPlan)與三種遷移類型(自動 lightweight、明確宣告的 lightweight、custom)構成1。多數架構變更都是自動完成的。有一部分需要明確宣告的 lightweight 階段。只有極少數需要帶有 willMigrate 與 didMigrate 閉包的 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拿到的是舊的 context,didMigrate拿到的是新的 context。 - 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)。宣告一次不需要 App 程式碼、由 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 資料的裝置上首次啟動時,App 會以 Duplicate version checksums across stages detected 崩潰,因為 SchemaV1 與 SchemaV2 解析出的檢查碼相同(模型型別並沒有發生 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 欄位,要拆成 firstName 與 lastName 兩個欄位。遷移必須讀取舊值、解析它,再寫入新欄位。
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 閉包在新架構的 context 中執行,因此可以存取新欄位。舊的 fullName 可能得延後移除,等新欄位填好之後再清理;這道清理屬於後續 V2 到 V3 的階段。
2. 計算衍生欄位。 依賴既有資料的新 @Attribute,需要在遷移時回填。
3. 在模型之間搬移資料。 把 Item 的資料拆分到 Item 與一個新的 Tag 模型,這種重組需要自訂邏輯,從舊資料中指派標籤。
一條通用的判準是:架構的形狀變了,用 lightweight;資料的形狀變了,用 custom。
willMigrate 與 didMigrate 的差別
custom 階段有兩個閉包,分別在不同時機被呼叫4:
willMigrate 在 SwiftData 套用架構遷移之前執行。閉包收到的模型 context,是舊架構的 context。適合在腳下的架構改變之前,先取出資料、做反正規化,或準備一些輔助狀態。
didMigrate 在架構遷移之後執行。模型 context 屬於新架構。適合回填新欄位、計算衍生資料,或替遷移收尾。
若用不到,兩個閉包都可以是 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 當父類別,Car、Truck、Motorcycle 當子類別;Account 當父類別,CheckingAccount、SavingsAccount 當子類別。共有的屬性放在父類別,各自特有的放在子類別。
iOS 27:遷移模型不變,儲存變得可觀察
iOS 27 beta 完全沒有更動遷移機制本身。VersionedSchema、MigrationStage 與 SchemaMigrationPlan 原樣延續,上面每一種寫法都照舊適用。iOS 27 新增的東西位於遷移旁邊,而不是遷移內部:一個新的「Data store observation」區塊,包含 ResultsObserver 與 HistoryObserver 兩個型別,外加一個 @Attribute(.codable) 選項,以屬性的 Codable 表示法來儲存該屬性6。
其中有兩項,值得在一篇談遷移的指南裡提上一筆。
@Attribute(.codable) 減輕了未來的遷移壓力。 能夠宣告式地儲存一個 Codable 值型別,就少了許多先把結構攤平成好幾個欄位、日後再寫一個 custom 階段把它拼回來的情況。在新屬性上採用這個選項的架構,遵循前面標準的行內預設值規則;它是一個屬性選項,而不是架構形狀的變化6。
HistoryObserver 替 custom 遷移之後的流程收尾。 didMigrate 中的回填會寫入一批資料列,App 的其他部分(以及任何正在觀察該儲存的小工具或擴充功能)都需要知道它們的存在。在 iOS 27 上,透過 HistoryObserver 觀察持久化歷史的觀察者,能看見遷移的交易落地,並可呼叫 ModelContext.fetchHistory,依模型型別與交易作者篩選,精準讀出到底改了哪些東西,而不必把所有資料重新抓一遍6。關於 observation 的完整脈絡,請見 iOS 27 中的 SwiftData:observation 與 history。
就規劃而言的結論是:27 beta 中沒有任何東西會逼你提升架構版本,也沒有遷移程式碼需要重寫。在那些遷移後對帳邏輯原本靠輪詢或重新抓取的地方,換上新的 observation 型別即可。
測試遷移
能編譯過的遷移,未必是能上線的遷移。發布之前,有三種測試值得跑一遍。
1. 用正式環境資料庫的副本做來回測試。 取一份近期的正式環境形態資料庫(或用測試產生合成的 V1 資料),以認得 V2 的容器開啟它,驗證資料是否正確遷移。型別檢查器抓不到的 custom 遷移錯誤,正是靠這類測試現形。
2. 舊版本仍能啟動。 建置前一個 App 版本,執行一次以產生 V1 資料,接著建置新版本,確認它能不崩潰地啟動。這個測試能抓出「Duplicate version checksums」這類陷阱,以及類似的宣告失誤。
3. 遷移失敗後的復原。 如果遷移拋出錯誤會怎樣?SwiftData 的行為取決於容器的設定;對正式上線的 App 來說,未被處理的遷移錯誤絕不該悄悄刪掉使用者資料。請明確測試失敗路徑,並決定 App 要怎麼做(回復、提示使用者、從備份還原)。
同一系列中的 Single Source of Truth 一文談的是一個相關問題:當 SwiftData 儲存因跨行程同步而被替換時會發生什麼事。遷移,正是那套模式在本地演進方向上的對應物。
跨行程上線與呈現遷移進度
有兩個運維層面的細節,文件並未特別強調,SwiftData 團隊卻在 WWDC 2026 上點了出來5:當 App 帶有小工具或擴充功能時,遷移究竟在哪裡執行;以及遷移進行時,該如何驅動進度介面。
遷移只屬於一個行程。 小工具與擴充功能拿不到主 App 那樣的執行資源,因此無法安全地執行遷移。給出的指引是:把 SchemaMigrationPlan1 從小工具與擴充功能的 target 中徹底移出,永遠不要從它們發起遷移。挑一個行程作為資料庫的擁有者,通常就是主 App。如果小工具開啟容器時,磁碟上的儲存仍停在未版本化(較舊)的架構,開啟就會失敗。請把這個錯誤當成「需要遷移」的訊號:顯示介面請使用者打開主 App,由 App 完成遷移,再把遷移後的架構版本寫入共用的 UserDefault。小工具下次讀取這個值,就能以 App 已經遷移到的版本開啟容器。這套做法讓寫入者始終只有一個,避免兩個行程搶著演進同一個檔案。
進度是用階段數算出來的,不是用實際經過時間。 SwiftData 並未提供專用的遷移進度 API5。想驅動進度指示器,就統計計畫中 custom 遷移階段的總數,並覆寫每個階段的 didMigrate 處理邏輯4,讓每個階段回報自己的位置,也就是「第 N 個,共 M 個」。這個數字反映的是已完成的階段數,而非已耗費的時間,因此進度列是一格一格跳,而不是平滑前進。與之相配的設計決策,是遷移期間 App 要給使用者看什麼:光禿禿的轉圈圖示會被讀成卡住,使用者隨即離開。在資料允許的前提下,讓 App 保持部分可用;至少也要說明每個階段正在加入什麼(這次遷移解鎖了哪些新功能),這樣等待才會被讀成朝著某個目標前進,而不是白白浪費的時間。
常見的失敗模式
從 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 以後的 App 意味著什麼
三點結論。
-
預設不要搭
VersionedSchema階梯。 新增帶行內預設值的屬性、刪除不再使用的欄位、以@Attribute(originalName:)重新命名——全都是 lightweight 而且自動的。VersionedSchema階梯留給 SwiftData 真的無法自動處理的變更(資料轉換、自訂邏輯、繼承重構)。 -
MigrationStage.custom用於資料轉換,而不是架構形狀的變化。willMigrate與didMigrate閉包,是給操作資料的程式碼用的,不是用來宣告「架構變了」。架構形狀的變化,一律走 lightweight 階段。 -
用真實的 V1 資料測試遷移,不要只用合成的測試資料。 在合成資料來回測試中通過的遷移,仍可能在正式環境形態的資料上因邊界情況而失敗(架構沒考慮到的可為 null 欄位、大到觸發逾時的資料集等等)。測試的代價很小,而首次啟動就遷移崩潰的代價是實打實的。
完整的 Apple Ecosystem 系列:帶型別的 App Intents;MCP 伺服器;路由的抉擇;Foundation Models;執行期 LLM 與工具鏈 LLM 的區分;三個介面;single source of truth 模式;兩個 MCP 伺服器;給 Apple 開發用的 hooks;Live Activities;watchOS 執行環境;SwiftUI 的內部構造;RealityKit 的空間心智模型;SwiftData 架構紀律;Liquid Glass 模式;多平台交付;平台矩陣;Vision 框架;Symbol Effects;Core ML 裝置端推論;Writing Tools API;Swift Testing;Privacy Manifest;作為平台能力的無障礙;SF Pro 字體排印系統;visionOS 空間模式;Speech 框架;我拒絕書寫的那些題目。系列首頁在 Apple Ecosystem 系列。若想了解 iOS 搭配 AI 代理的更廣背景,請參閱 iOS Agent Development 指南。
常見問題
我是不是一定需要 SchemaMigrationPlan?
不是。只有一個架構版本的 App(首次發布,或一路以來只做過 lightweight 變更的 App)並不需要 SchemaMigrationPlan。ModelContainer 初始化器可以直接接收架構中的模型。要到第一次宣告 custom 遷移階段時(或開發者第一次想明確宣告版本階梯時),migrationPlan: 參數才變得必要。
我怎麼知道自己的改動算不算 lightweight?
Apple 給出的 lightweight 適用清單是1:新增實體、屬性與關係,移除它們,以 @Attribute(originalName:) 重新命名,變更關係的基數,指定刪除規則。如果改動符合其中之一,而模型類別的結構沒有其他變化,那麼遷移就是自動的,也不需要 VersionedSchema 階梯。如果改動需要資料轉換(計算、拆分、搬移資料),那就屬於 custom。
willMigrate 與 didMigrate 可以同時設定嗎?
可以。兩個閉包各自都是選擇性的,但也可以同時提供。willMigrate 在 SwiftData 遷移之前,針對舊架構的 context 執行;didMigrate 在遷移之後,針對新架構的 context 執行。兩者分別負責準備與收尾。
如果遷移拋出錯誤會發生什麼事?
錯誤會從 ModelContainer 的初始化往外傳播,容器開啟失敗。之後 App 的行為,取決於開發者怎麼處理這個錯誤:有的 App 會顯示復原介面,有的會嘗試從備份還原,有的則刪掉損壞的儲存重新來過。SwiftData 不會在遷移失敗時悄悄刪除使用者資料;如何面對失敗,是 App 自己的責任。
如何在不影響正式環境資料的前提下測試遷移?
建立一個測試 target,做出一個指向暫存檔案 URL 的 ModelContainer,往裡面填入 V1 資料,再用包含遷移計畫的新容器開啟它,驗證遷移後的資料是否符合預期。這套做法在單元測試與整合測試中都行得通;若想得到最貼近現實的結果,請使用一份真實正式環境形態資料庫的副本。
iOS 26 的類別繼承能用在既有架構上嗎?
可以,搭配一次 lightweight 遷移即可。採用繼承的 App 要升到新的架構版本(例如 V4),並宣告 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。原本扁平的父類別屬性保持不變,子類別特有的屬性則以行內預設值的方式加入。這種結構上的變化,由 SwiftData 的 lightweight 遷移負責處理。
參考資料
-
Apple Developer Documentation:
VersionedSchema與SchemaMigrationPlan協定參考,也就是遷移模型本身。關於架構演進的完整敘述,另見相關指南 Adopting SwiftData for a Core Data app。 ↩↩↩↩↩↩↩↩ -
Apple Developer:SwiftData: Dive into inheritance and schema migration(WWDC 2025 第 291 場)。介紹 iOS 26 中 SwiftData 類別繼承的登場。 ↩↩
-
Apple Developer Documentation:
MigrationStage,其中包含.lightweight(fromVersion:toVersion:)與.custom(fromVersion:toVersion:willMigrate:didMigrate:)兩種情況。 ↩ -
Apple Developer Documentation:該情況的簽章請見
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)。willMigrate 針對舊 context 執行、didMigrate 針對新 context 執行的語意,記載於 WWDC 2025 第 291 場 SwiftData: Dive into inheritance and schema migration,與前面引用 iOS 26 繼承特性的是同一場。 ↩↩↩ -
WWDC 2026 SwiftData Group Lab(第 8017 場)。內容轉述自 WWDC 2026 SwiftData Group Lab 的本地逐字稿錄音;Apple 並未為這類 Lab 提供官方字幕。其中,小工具與擴充功能的遷移限制(遷移只屬於一個行程、錯誤即是需要遷移的訊號、遷移後的版本存入
UserDefault),以及依階段數計算進度的技巧(由於沒有專用的進度 API,覆寫每個階段的didMigrate處理邏輯來回報「第 N 個,共 M 個」),都是 SwiftData 工程團隊在座談中說明的內容。SchemaMigrationPlan與MigrationStage.custom的didMigrate等符號,已與 1 和 4 所引的 Apple Developer 文件核對一致;至於沒有專用進度 API 這一點,反映的是座談中工程團隊自己的說法。 ↩↩ -
Apple Developer Documentation:
ResultsObserver與HistoryObserver(兩者皆為 iOS 27.0 beta,歸在 SwiftData 的「Data store observation」主題下),以及Schema.Attribute.Option.codable(iOS 27.0 beta),「以該屬性的 codable 表示法來儲存它」。依據 WWDC26 第 274 場 What’s new in SwiftData,HistoryObserver公開了一個可觀察的eventCounter,每當有新交易落地就會遞增,程式碼則以呼叫ModelContext.fetchHistory並依模型型別與交易作者篩選的方式來回應。 ↩↩↩