← 所有文章

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

SwiftData 的結構描述遷移機制相較於 Core Data 是結構性的改進,但有一個團隊不斷掉入的陷阱:為 SwiftData 本可透過內聯預設值自動處理的變更宣告新的 VersionedSchema。結果就是裝置上出現「Duplicate version checksums across stages detected」當機,即使程式碼看起來正確且建置乾淨。框架實際的遷移模型由三個元件組成(VersionedSchemaMigrationStageSchemaMigrationPlan)以及三種遷移類型(自動輕量級、宣告式輕量級、自訂)1。多數結構描述變更屬於自動類型。部分需要宣告式輕量級階段。少數需要使用 willMigratedidMigrate 閉包的自訂階段。

本文對照 Apple 的文件梳理遷移模型、指出每種遷移類型處理的案例,並涵蓋 iOS 26 新的類別繼承支援。核心框架是「我該宣告什麼,而 SwiftData 又會替我處理什麼」,因為這個決策決定了遷移是順利上線還是首次啟動就當機。

TL;DR

  • SwiftData 遷移由三個協定組成:VersionedSchema(特定版本下模型類型的快照)、MigrationStage(具備 .lightweight.custom 案例的單一 fromVersion-to-toVersion 轉換)和 SchemaMigrationPlan(階段的有序清單)1
  • @Model 新增帶內聯預設值的屬性(var foo: Bool = false)並不需要新的 VersionedSchema。SwiftData 會自動將此新增視為輕量級遷移處理。為此宣告 V2 會導致「Duplicate version checksums across stages detected」當機。
  • 輕量級遷移處理:新增、重新命名、刪除實體、屬性、關聯;變更關聯類型;宣告 @Attribute(originalName:) 來追蹤重新命名;指定刪除規則。多數結構描述變更都符合此類別。
  • 自訂遷移(MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))處理資料轉換:將一個欄位拆分為兩個、計算衍生欄位、在模型之間移動資料。willMigrate 取得舊的 context;didMigrate 取得新的 context。
  • iOS 26 為 @Model 類型新增類別繼承2。採用繼承的結構描述會升至新版本,並從先前的扁平模型版本進行輕量級階段轉換。

三元件模型

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 在建立容器時讀取持久化儲存的目前結構描述版本,從該版本依序走過計畫中的階段直到目前版本,並按順序套用每個階段。

輕量級遷移會自動處理的內容

多數結構描述變更不需要自訂階段1

  • 新增帶預設值的屬性。 在現有 @Model 上加入 var foo: Bool = false 是自動處理的。
  • 新增實體(模型類別)。 當新類型所屬的 VersionedSchema 成為目前版本時就會出現;既有資料會被保留。
  • 移除屬性或實體。 SwiftData 會卸除該欄位或資料表。
  • 重新命名屬性或實體。 在屬性上加入 @Attribute(originalName: "oldName") 來保留資料;SwiftData 會將舊值對應至新值。
  • 變更關聯類型。 一對多、多對多等。
  • 指定刪除規則。 @Relationship(deleteRule: .cascade) 等類似新增屬於輕量級。

對於屬於此清單的變更,正確模式是 完全不宣告新的 VersionedSchema,前提是模型類型在其他方面未變更。SwiftData 會針對既有結構描述自動執行輕量級遷移。

陷阱:新增欄位不需要 V2

最常見的 SwiftData 遷移錯誤:開發者新增一個帶內聯預設值的屬性(var foo: Bool = false),接著宣告一個 SchemaV2,參考與 SchemaV1 相同的模型類型。建置乾淨。但在已有 V1 資料的裝置首次啟動時,會因為 Duplicate version checksums across stages detected 而當機,因為 SchemaV1SchemaV2 解析為同一個總和檢查碼(模型類型並未以 SwiftData 認得的方式有所差異)。

正確的模式:保持既有的 VersionedSchema 不變,在模型上加入帶內聯預設值的新屬性,讓 SwiftData 自動的輕量級遷移去處理。不需要 MigrationPlanMigrationStage,也不需要 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(...)
)

只有當變更無法以輕量級方式處理時(資料轉換、模型拆分、需要自訂邏輯的繼承重構)才需要 V2 結構描述。在那些情況下,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 閉包針對新結構描述的 context 執行,所以新欄位是可存取的。舊的 fullName 可能需要延後到新欄位填入後再移除;清理工作則作為後續的 V2-to-V3 階段。

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

3. 在模型之間移動資料。 將原本在 Item 中的資料拆分至 Item 與一個新的 Tag 模型,這類重組需要自訂邏輯來從舊資料指派標籤。

通則:當結構描述形狀改變時用輕量級;當資料形狀改變時用自訂。

willMigratedidMigrate

自訂階段有兩個閉包,在不同時機被呼叫4

willMigrate 在 SwiftData 套用結構描述遷移之前執行。閉包接收的模型 context 是結構描述的 context。可用來擷取資料、將資料反正規化,或在結構描述底層改變前準備輔助狀態。

didMigrate 在結構描述遷移之後執行。模型 context 屬於結構描述。可用於回填新欄位、計算衍生資料,或敲定遷移。

任一閉包若不需要可設為 nil。多數自訂遷移只使用 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)
    }
}

採用繼承的結構描述會升至新版本,並從先前的扁平模型版本進行輕量級遷移階段。如果繼承保留了既有屬性,轉換是自動的;子類別上的新欄位則遵循標準的內聯預設值模式。

這個模式適用於多個 @Model 類型共享特性的情況:Vehicle 父類別搭配 CarTruckMotorcycle 子類別;Account 父類別搭配 CheckingAccountSavingsAccount 子類別。共用屬性放在父類別;特定屬性放在子類別。

測試遷移

可以編譯通過的遷移不等於可以上線的遷移。以下三種測試模式值得在發佈前執行:

1. 在生產環境資料庫副本上做來回測試。 取得近期生產環境形狀的資料庫(或透過測試產生 V1 合成資料),以支援 V2 的容器開啟它,並驗證資料正確遷移。此測試可捕捉型別檢查器無法發現的自訂遷移錯誤。

2. 舊版本仍能啟動。 建置前一個應用程式版本,執行一次以產生 V1 資料,接著建置新版本並驗證它能啟動而不當機。此測試可捕捉「Duplicate version checksums」陷阱與類似的宣告錯誤。

3. 失敗遷移的恢復。 如果遷移擲出錯誤會發生什麼事?SwiftData 的行為取決於容器的設定;對於正式環境的應用程式,未處理的遷移錯誤不應靜默地刪除使用者資料。明確測試失敗路徑,並決定應用程式的反應(回復、提示、從備份還原)。

本系列的單一真實來源文章涵蓋了相關問題:當 SwiftData 儲存透過跨程序同步被替換時會發生什麼事。遷移是該模式在本地演化方面的對應物。

常見失敗模式

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

為 SwiftData 本可自動處理的變更宣告 V2。 也就是「Duplicate version checksums」當機。修正:別為內聯預設值的屬性新增宣告新結構描述;讓 SwiftData 自動處理。

未進行儲存的自訂遷移程式碼。 一個會修改實體但未呼叫 context.save()didMigrate 閉包,會產生一個只執行一次、卻丟掉成果並在每次啟動時都重新執行的遷移(因為遷移看起來尚未完成)。修正:每個會修改資料的閉包都必須在傳回前 try context.save()

重新命名屬性卻未使用 @Attribute(originalName:) SwiftData 會將新屬性視為新的、舊屬性視為已刪除;舊屬性上的既有資料會被丟棄。修正:宣告 @Attribute(originalName: "oldName") var newName: ...,讓 SwiftData 將資料透過重新命名對應過去。

此模式對 iOS 26+ 應用程式的意義

三點要領。

  1. 預設不要建立 VersionedSchema 階梯。 新增帶內聯預設值的屬性、刪除未使用的欄位、用 @Attribute(originalName:) 重新命名,全都是輕量級且自動的。VersionedSchema 階梯是為了 SwiftData 真的無法自動處理的變更而存在(資料轉換、自訂邏輯、繼承重構)。

  2. MigrationStage.custom 用於資料轉換,不是用於結構描述形狀的變更。 willMigratedidMigrate 閉包是給操作資料的程式碼用的,不是用來宣告結構描述已變更。結構描述形狀的變更要透過輕量級階段流動。

  3. 以真實的 V1 資料測試遷移,不要只用合成測試資料。 在合成來回測試中通過的遷移,在生產環境形狀的資料上仍可能因為邊界情況失敗(結構描述未涵蓋的可空欄位、達到逾時的大型資料集等等)。測試的成本很小;首次啟動遷移當機的代價是真實的。

完整的 Apple 生態系列:類型化的 App Intents;MCP 伺服器;路由問題;Foundation Models;執行時 vs 工具 LLM 區別;三種介面;單一真實來源模式;兩個 MCP 伺服器;Apple 開發的 hooks;Live Activities;watchOS 執行時合約;SwiftUI 內部;RealityKit 的空間心智模型;SwiftData 結構描述紀律;Liquid Glass 模式;多平台出貨;平台矩陣;Vision 框架;Symbol Effects;Core ML 推論;Writing Tools API;Swift Testing;Privacy Manifest;Accessibility 作為平台;SF Pro 字體;visionOS 空間模式;Speech 框架;我拒絕書寫的主題。中心點在 Apple 生態系列。如需更廣的「iOS 搭配 AI 代理」相關內容,請參閱 iOS Agent Development 指南

FAQ

我一定需要 SchemaMigrationPlan 嗎?

不一定。只有單一結構描述版本的應用程式(初始版本,或只做過輕量級變更的應用程式)不需要 SchemaMigrationPlanModelContainer 初始化器可直接接受結構描述的模型。當第一次宣告自訂遷移階段時(或開發者第一次想宣告明確的版本階梯時),migrationPlan: 參數才會變得必要。

我怎麼知道我的變更屬於輕量級?

Apple 的輕量級可行清單1:新增實體、屬性、關聯,移除它們,使用 @Attribute(originalName:) 重新命名,變更關聯基數,指定刪除規則。如果變更符合上述其中一項,且模型類別結構在其他方面未變更,遷移會自動處理且不需要 VersionedSchema 階梯。如果變更需要資料轉換(計算、拆分、移動資料),則屬於自訂類型。

willMigratedidMigrate 可以同時設定嗎?

可以。兩個閉包個別都是選用的,但也可以同時提供。willMigrate 在 SwiftData 遷移之前針對舊結構描述的 context 執行;didMigrate 在之後針對新結構描述的 context 執行。兩者分別涵蓋準備與敲定。

如果遷移擲出錯誤會怎麼樣?

錯誤會從 ModelContainer 初始化中傳播出來。容器會無法開啟。應用程式的行為取決於開發者如何處理錯誤:有些應用程式會顯示恢復 UI、有些會嘗試從備份還原、有些會刪除損毀的儲存並重新開始。SwiftData 不會在遷移失敗時靜默刪除使用者資料;失敗由應用程式自行處理。

我要如何在不影響生產環境資料的情況下測試遷移?

建立一個測試 target,建立一個指向暫存檔案 URL 的 ModelContainer,以 V1 資料填充,接著用包含遷移計畫的新容器開啟它。驗證遷移後的資料符合預期。此模式在單元測試與整合測試中都適用;若要獲得最貼近現實的結果,使用實際生產環境形狀資料庫的副本。

iOS 26 的類別繼承能與既有結構描述搭配運作嗎?

可以,透過輕量級遷移即可。採用繼承的應用程式會升至新的結構描述版本(例如 V4)並宣告 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)。扁平的父類別屬性保留,子類別專屬屬性以內聯預設值新增。SwiftData 的輕量級遷移會處理結構性變更。

References


  1. Apple Developer Documentation:VersionedSchemaSchemaMigrationPlan 協定參考。遷移模型。亦請參閱相關指南 Adopting SwiftData for a Core Data app,取得完整的結構描述演化敘述。 

  2. Apple Developer:SwiftData: Dive into inheritance and schema migration(WWDC 2025 session 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 針對舊 context 執行、didMigrate 針對新 context 執行的語意,記載於 WWDC 2025 session 291 SwiftData: Dive into inheritance and schema migration,即 iOS 26 繼承新增功能所引用的同一場議程。 

相關文章

SwiftData's Real Cost Is Schema Discipline

SwiftData's API is two macros. The cost is what happens after you ship. Optional fields are the cheap migration; non-opt…

15 分鐘閱讀

The Privacy Manifest Deep Dive: What Counts As Data Collection

Apple's privacy manifest is a structured contract, not a checkbox: four sections, five required-reason API categories, S…

14 分鐘閱讀

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分鐘閱讀