← 所有文章

SwiftData 遷移:輕量級 vs 自訂,以及何時其實不需要 V2

SwiftData 的 schema 遷移機制相較於 Core Data 是一項結構性的改進,但有個陷阱讓不少團隊一再踩中:為了那些 SwiftData 原本就會透過 inline default 自動處理的變更,卻去宣告一個新的 VersionedSchema。結果就是在裝置上發生「Duplicate version checksums across stages detected」的崩潰,即使程式碼看起來沒問題、也能乾淨地編譯通過。這套框架真正的遷移模型由三個部分(VersionedSchemaMigrationStageSchemaMigrationPlan)與三種遷移類型(自動輕量級、宣告式輕量級、自訂)組成1。多數 schema 變更屬於自動類型。有些需要一個宣告式的輕量級 stage。只有少數需要帶有 willMigratedidMigrate 閉包的自訂 stage。

本文會對照 Apple 的官方文件逐一檢視這套遷移模型,點名每種遷移類型各自負責的情境,並涵蓋 iOS 26 全新的類別繼承支援。整體的思考框架是「我要宣告什麼,相對於 SwiftData 會替我處理什麼」,因為這個判斷會決定遷移究竟能乾淨上線,還是在首次啟動時崩潰。

TL;DR

  • SwiftData 遷移由三個協定組合而成:VersionedSchema(某個版本下 model 型別的快照)、MigrationStage(單一 fromVersion 到 toVersion 的轉換,含 .lightweight.custom 兩種 case),以及 SchemaMigrationPlan(依序排列的 stage 清單)1
  • 為既有 @Model 新增一個帶 inline default 的屬性(var foo: Bool = false)並不需要新的 VersionedSchema。SwiftData 會把這次新增當作輕量級遷移自動處理。為此宣告一個 V2 反而會造成「Duplicate version checksums across stages detected」崩潰。
  • 輕量級遷移可處理:新增/重新命名/刪除 entity、attribute、relationship;變更 relationship 型別;以 @Attribute(originalName:) 追蹤重新命名;指定 delete rule。多數 schema 變更都落在這個範圍內。
  • 自訂遷移(MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))負責資料轉換:把一個欄位拆成兩個、計算衍生欄位、在不同 model 之間搬移資料。willMigrate 拿到的是舊的 context;didMigrate 拿到的是新的 context。
  • iOS 26 為 @Model 型別新增了類別繼承2。採用繼承的 schema 會升到新版本,並以一個從前一個扁平 model 版本出發的輕量級 stage 完成轉換。

三件式模型

一次 SwiftData 遷移由三個部分組合而成。

VersionedSchema

某個特定 schema 版本下 model 型別的快照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 都會為自己的 model 類別建立命名空間,如此一來,在遷移期間,多個使用相同 model 名稱的 schema 就能在同一份程式碼庫中並存。

MigrationStage

兩個 VersionedSchema 型別之間的單一轉換3。有兩種 case:

  • .lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type)。宣告一個 SwiftData 不需 App 程式碼即可處理的轉換。參數本身是 VersionedSchema 型別(例如 SchemaV1.self),而非原始的 Schema.Version 值。
  • .custom(fromVersion:toVersion:willMigrate:didMigrate:)。宣告一個帶有程式碼的轉換,這些程式碼會在資料遷移之前和/或之後執行。版本引數的參數型別與 .lightweight 相同。

SchemaMigrationPlan

依序排列的 stage 清單,負責把 schema 從任一較早版本帶到目前版本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 在建立時同時帶入目前 schema 與遷移計畫:

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

在建立 container 時,SwiftData 會讀取持久化儲存中目前的 schema 版本,沿著計畫中的 stage 從該版本一路向前推進到目前版本,並依序套用每個 stage。

輕量級遷移會自動處理哪些變更

多數 schema 變更並不需要自訂 stage1

  • 新增一個帶預設值的 attribute。 在既有 @Model 上加 var foo: Bool = false 屬於自動處理。
  • 新增一個 entity(model 類別)。 當某型別所屬的 VersionedSchema 成為目前版本時,新型別便會出現;既有資料則完整保留。
  • 移除一個 attribute 或 entity。 SwiftData 會卸除該欄位或資料表。
  • 重新命名一個 attribute 或 entity。 為該屬性加上 @Attribute(originalName: "oldName") 即可保留資料;SwiftData 會把舊名對應到新名。
  • 變更 relationship 型別。 一對多、多對多等變更。
  • 指定 delete rule。 @Relationship(deleteRule: .cascade) 及類似的新增都屬於輕量級。

對於清單中的這些變更,正確的做法是:只要 model 型別本身沒有其他改動,就完全不要宣告新的 VersionedSchema。SwiftData 會針對既有 schema 自動執行輕量級遷移。

陷阱:新增一個欄位並不需要 V2

最常見的 SwiftData 遷移錯誤是:開發者為 model 加上一個帶 inline default 的新屬性(var foo: Bool = false),接著宣告一個 SchemaV2,卻參照與 SchemaV1 相同的 model 型別。編譯乾淨無誤。但在已有 V1 資料的裝置上首次啟動時就會崩潰,並丟出 Duplicate version checksums across stages detected,因為 SchemaV1SchemaV2 都解析到相同的 checksum(model 型別的變動方式並未被 SwiftData 認定為有所不同)。

正確的做法是:別動既有的 VersionedSchema,直接在 model 上加入帶 inline default 的新屬性,讓 SwiftData 的自動輕量級遷移去處理它。不需要 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(...)
)

只有當某項變更無法以輕量級方式完成時(資料轉換、model 拆分、需要自訂邏輯的繼承重構)才需要 V2 schema。在那些情況下,V2 是貨真價實的,而 SchemaMigrationPlan 則負責統籌整個轉換。

何時必須採用自訂遷移

自訂遷移在以下三種情況下值得它帶來的複雜度:

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 閉包在新 schema 的 context 下執行,因此可存取新欄位。舊的 fullName 可能需要延後到新欄位都填好之後再移除;這項清理工作可以作為後續一個 V2 到 V3 的 stage 來處理。

2. 計算衍生欄位。 一個依賴既有資料的新 @Attribute,需要在遷移時被回填。

3. 在不同 model 之間搬移資料。 一次重組,把原本屬於 Item 的資料拆分到 Item 與一個新的 Tag model 之間,就需要自訂邏輯,從舊資料指派 tag。

通則是:schema 的形狀改變時用輕量級;資料的形狀改變時用自訂。

willMigrate vs didMigrate

自訂 stage 有兩個閉包,會在不同時間點被呼叫4

willMigrate 在 SwiftData 套用 schema 遷移之前執行。該閉包收到的 model context 是 schema 的 context。可用它在 schema 在底層改變之前,先擷取資料、做反正規化,或準備輔助狀態。

didMigrate 在 schema 遷移之後執行。其 model context 屬於 schema。可用它回填新欄位、計算衍生資料,或為遷移收尾。

任一閉包若不需要都可以是 nil。多數自訂遷移只用 didMigrate;當遷移需要讀取那些在 schema 改變後就再也存取不到的舊資料時,willMigrate 才派得上用場。

閉包收到一個 ModelContext,可進行 fetch、修改與 save。閉包會 throw;錯誤會往外傳遞出遷移流程並使其中止。

iOS 26:@Model 的類別繼承

iOS 26 為 SwiftData model 導入了類別繼承2。現在 model 之間可以有父子關係:

@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)
    }
}

採用繼承的 schema 會升到新版本,並以一個從前一個扁平 model 版本出發的輕量級遷移 stage 完成轉換。只要繼承保留了既有屬性,這次轉換就是自動的;子類別上的新欄位則遵循標準的 inline default 模式。

當多個 @Model 型別共享某些特性時,這個模式正好適用:一個 Vehicle 父類別搭配 CarTruckMotorcycle 子類別;一個 Account 父類別搭配 CheckingAccountSavingsAccount 子類別。共享的屬性放在父類別,特有的屬性放在子類別。

測試遷移

能編譯的遷移不等於能上線的遷移。發布前值得執行三種測試模式:

1. 在生產資料庫副本上做來回測試。 取一份近期、形狀貼近生產的資料庫(或透過測試產生合成的 V1 資料),用支援 V2 的 container 開啟它,並驗證資料正確完成遷移。這個測試能抓出型別檢查器無法察覺的自訂遷移錯誤。

2. 確認舊版本仍能啟動。 建置前一版 App,跑一次讓它產生 V1 資料,接著建置新版 App,驗證它能啟動而不崩潰。這個測試能抓出「Duplicate version checksums」陷阱及類似的宣告錯誤。

3. 遷移失敗的復原。 如果遷移 throw 了會怎樣?SwiftData 的行為取決於 container 的設定;對生產級 App 而言,未經處理的遷移錯誤不應在無聲無息中刪掉使用者資料。明確測試這條失敗路徑,並決定 App 該怎麼做(回復、提示使用者、從備份復原)。

本系列中的 Single Source of Truth 一文探討了一個相關問題:當 SwiftData 儲存因跨行程同步而被替換時會發生什麼。遷移正是該模式在本機演進層面上的對應情境。

跨行程上線遷移並呈現進度

有兩個操作細節,文件並未特別凸顯,但 SwiftData 團隊在 WWDC 2026 上有特別點出5:當 App 帶有 widget 或 extension 時遷移在哪裡執行,以及在遷移執行時如何驅動進度 UI。

由單一行程掌管遷移。 widget 與 extension 拿不到主 App 所擁有的同等執行期資源,因此無法安全地執行遷移。建議的做法是:把 SchemaMigrationPlan1 完全排除在 widget 與 extension target 之外,並且絕不從這些行程進行遷移。挑選一個行程(通常是主 App)作為資料庫的擁有者。如果某個 widget 開啟 container,而磁碟上的儲存仍處於未版本化(較舊)的 schema,開啟就會出錯。把這個錯誤當成「需要遷移」的訊號:呈現 UI 請使用者開啟主 App,讓 App 執行遷移,再由 App 把遷移後的 schema 版本寫入一個共享的 UserDefault。widget 下次便會讀取該值,並以 App 已遷移到的版本來開啟 container。這個模式讓單一寫入者全權負責,避免兩個行程競相演進同一份檔案。

進度由 stage 數量計算,而非掛鐘時間。 SwiftData 並未提供專門的遷移進度 API5。要驅動一個進度指示器,可統計計畫中自訂遷移 stage 的總數,並覆寫各 stage 的 didMigrate 處理器4,讓每個 stage 回報自己的位置,「第 N 個 stage,共 M 個」。這個數字反映的是已完成的 stage 數,而非經過的時間,因此進度條會以離散步進前進,而非平滑移動。隨之而來的設計決策是:遷移期間 App 要顯示什麼。一個光禿禿的轉圈圈看起來就像卡死了,使用者會掉頭離開。在資料允許的範圍內讓 App 保持部分可用,或至少說明每個 stage 正在新增什麼(遷移所解鎖的新功能),讓等待讀起來像是朝著某個目標前進,而不是一段空白的死時間。

常見失敗模式

來自 SwiftData 失敗紀錄的三種模式:

為 SwiftData 本可自動處理的變更宣告 V2。 也就是「Duplicate version checksums」崩潰。修正方式:別為 inline default 的屬性新增去宣告新 schema;讓 SwiftData 自動處理它們。

自訂遷移程式碼沒有 save。 一個修改了 entity 卻沒呼叫 context.save()didMigrate 閉包,會產生一種「執行一次、丟掉成果、每次啟動都重跑」的遷移(因為該遷移看起來像沒完成)。修正方式:每個會修改資料的閉包在 return 之前都必須 try context.save()

重新命名屬性卻沒用 @Attribute(originalName:) SwiftData 會把新屬性當成全新的、把舊屬性當成已刪除的;舊屬性上的既有資料就會被丟掉。修正方式:宣告 @Attribute(originalName: "oldName") var newName: ...,讓 SwiftData 在重新命名的過程中把資料對應過去。

這個模式對 iOS 26+ App 的意義

三個重點。

  1. 預設不要搭 VersionedSchema 階梯。 用 inline default 新增屬性、刪除未使用的欄位、用 @Attribute(originalName:) 重新命名,全都是輕量級且自動的。VersionedSchema 階梯是給那些 SwiftData 確實無法自動處理的變更用的(資料轉換、自訂邏輯、繼承重構)。

  2. MigrationStage.custom 用於資料轉換,而非 schema 形狀的變更。 willMigratedidMigrate 閉包是給操作資料的程式碼用的,不是用來宣告 schema 已經改變。schema 形狀的變更應透過輕量級 stage 流動。

  3. 用真實的 V1 資料測試遷移,而不只是合成測試資料。 在合成來回測試中過關的遷移,仍可能在形狀貼近生產、帶有各種邊界情況的資料上失敗(schema 未涵蓋的可空欄位、會碰到逾時的大型資料集等等)。測試的成本很小;遷移在首次啟動就崩潰的代價卻是真實的。

完整的 Apple Ecosystem 系列:型別化的 App IntentsMCP server路由抉擇Foundation Models執行期與工具鏈 LLM 的分野三個介面single source of truth 模式兩個 MCP ServerApple 開發的 hooksLive ActivitieswatchOS 執行期SwiftUI 內部機制RealityKit 的空間心智模型SwiftData schema 紀律Liquid Glass 模式多平台上線平台矩陣Vision 框架Symbol EffectsCore ML 推論Writing Tools APISwift TestingPrivacy Manifest把無障礙視為平台SF Pro 字體排印visionOS 空間模式Speech 框架我拒絕寫的主題。系列匯整頁在 Apple Ecosystem 系列。若想了解更廣的「iOS 結合 AI agent」脈絡,請參閱 iOS Agent 開發指南

FAQ

我是不是一定都需要 SchemaMigrationPlan

不需要。只有單一 schema 版本的 App(首次發布,或一路上只做過輕量級變更的 App)並不需要 SchemaMigrationPlanModelContainer 初始化器可直接接收 schema 的 models。migrationPlan: 參數會在第一次宣告自訂遷移 stage 時(或開發者第一次想宣告明確的版本階梯時)才變得必要。

我要怎麼知道我的變更是不是輕量級?

Apple 列出的可採輕量級的清單1:新增 entity/attribute/relationship、移除它們、以 @Attribute(originalName:) 重新命名、變更 relationship 的基數、指定 delete rule。如果某項變更符合其中之一、且 model 類別結構本身沒有其他改動,遷移就是自動的,不需要 VersionedSchema 階梯。如果某項變更需要資料轉換(計算、拆分、搬移資料),它就屬於自訂。

willMigratedidMigrate 可以同時設定嗎?

可以。兩個閉包各自都是選用的,但也可以兩個都提供。willMigrate 在 SwiftData 遷移之前針對舊 schema 的 context 執行;didMigrate 在遷移之後針對新 schema 的 context 執行。兩者分別涵蓋準備與收尾。

如果遷移 throw 了錯誤會怎樣?

錯誤會往外傳遞出 ModelContainer 的初始化過程。container 開啟失敗。App 的後續行為取決於開發者如何處理該錯誤:有些 App 顯示復原 UI,有些嘗試從備份還原,有些則刪掉損毀的儲存並重新開始。SwiftData 不會在遷移失敗時無聲地刪掉使用者資料;這個失敗交由 App 自行處理。

我要怎麼在不影響生產資料的前提下測試遷移?

建立一個測試 target,建立一個指向暫存檔案 URL 的 ModelContainer,以 V1 資料填入它,再用包含遷移計畫的新 container 開啟它。驗證遷移後的資料是否符合預期。這個模式在單元測試與整合測試中都適用;若想得到最貼近真實的結果,請使用一份真實、形狀貼近生產的資料庫副本。

iOS 26 的類別繼承能搭配既有 schema 運作嗎?

可以,搭配一次輕量級遷移即可。採用繼承的 App 會升到新的 schema 版本(例如 V4),並宣告一個 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。扁平的父類別屬性保持不變,子類別特有的屬性則以 inline default 加入。SwiftData 的輕量級遷移會處理這項結構性變更。

References


  1. Apple Developer Documentation: VersionedSchema and SchemaMigrationPlan protocol references. The migration model. See also the related guide Adopting SwiftData for a Core Data app for the full schema-evolution narrative. 

  2. Apple Developer: SwiftData: Dive into inheritance and schema migration (WWDC 2025 session 291). The introduction of SwiftData class inheritance in iOS 26. 

  3. Apple Developer Documentation: MigrationStage with the .lightweight(fromVersion:toVersion:) and .custom(fromVersion:toVersion:willMigrate:didMigrate:) cases. 

  4. Apple Developer Documentation: MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:) for the case signature. The willMigrate-runs-against-old-context and didMigrate-runs-against-new-context semantics are documented in WWDC 2025 session 291 SwiftData: Dive into inheritance and schema migration, the same session referenced for the iOS 26 inheritance addition. 

  5. WWDC 2026 SwiftData Group Lab (session 8017). Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. The widget-and-extension migration gating (one process owns the migration, the error path is the migration signal, the migrated version is stored in a UserDefault) and the stage-count progress technique (override the per-stage didMigrate handler to report stage N of M, since no dedicated progress API exists) were described by the SwiftData engineering panel. The SchemaMigrationPlan and MigrationStage.custom didMigrate symbols are confirmed against the Apple Developer documentation cited in 1 and 4; the absence of a dedicated progress API reflects the panel’s own framing during the lab. 

相關文章

SwiftData 真正的成本在於 Schema 紀律

SwiftData 的 API 只有兩個 macro,真正的成本是上架之後才浮現。新增 optional 欄位是廉價的 migration;新增非 optional 欄位則需要 VersionedSchema。

7 分鐘閱讀

iOS 27 的 SwiftData:Observation 與 History

iOS 27 為 SwiftData 帶來一流的變更觀察能力,透過 ResultsObserver 追蹤變更、透過 HistoryObserver 觀察持久化歷史,並支援 codable 屬性儲存。

4 分鐘閱讀

現在,AI助理才是你的讀者

第一方邊緣數據:AI助理請求我網頁的頻率,約為真人造訪的66倍,而且其中多數是即時、由使用者觸發的擷取,而非訓練用的爬取。

1 分鐘閱讀