← 所有文章

SwiftData真正的成本是Schema紀律

Get Bananas的ShoppingItem是說明SwiftData schema紀律為何重要的經典案例。原始schema並未包含lastModified時間戳記;後來加入此欄位時必須採用特定的遷移形式,因為現有資料已存放在磁碟上,而此欄位之所以被設為可選,正是為了修正最初將其設為非可選時所引發的遷移當機問題。1

SwiftData的API只是兩個macros。在類別上加@Model使其成為持久化型別。在屬性上加@Attribute(.unique)則賦予唯一性約束。框架隱藏了Core Data的堆疊管理、value-transformer的繁瑣處理,以及NSManagedObjectContext的樣板程式碼。框架沒有隱藏的是schema遷移;它只是讓遷移從命令式變成宣告式。不重視遷移的代價,就是在例行更新時抹除使用者資料的那種bug。

核心論點:SwiftData起步便宜,但隨意遷移的代價昂貴。紀律就是從第一天起便認真看待命名、可選性以及VersionedSchema,而不是等到你意識到應該這麼做的那一天才開始。

TL;DR

  • @Model macro將類別轉換為持久化的SwiftData型別。框架在編譯期從屬性宣告產生schema。
  • 新增可選屬性是無動作遷移:由SwiftData的lightweight migration處理。對既有schema新增非可選屬性,則需要VersionedSchema加上一個MigrationPlan,告訴框架如何為既有資料列填入新欄位的值。
  • 從第一天就略過VersionedSchema的代價是:任何非小幅度的v2 schema變更都有可能導致使用者資料庫被刪除,因為lightweight路徑相當保守,當無法推論遷移方式時就會中止。
  • @Attribute(.unique)是處理自然鍵的正確工具(你產生的UUID、你匯入的外部ID)。@Relationship則是處理父子參照的正確工具。兩者都是macros,會在底層產生對應的Core Data管線。2

@Model實際上做了什麼

SwiftData型別就是一個套用了@Model macro的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不需要單獨的持久化儲存schema宣告。 SwiftData會在編譯期讀取類別定義並合成schema。類別的屬性會成為模型的屬性;它們的Swift型別會成為欄位型別。沒有.xcdatamodeld檔需要維護(雖然Core Data底層的NSManagedObjectModel仍然存在,並在執行期支撐著schema)。2

@Attribute(.unique)是針對單一欄位的約束,不是PRIMARY KEY宣告。 SwiftData的持久化身分是PersistentIdentifier,每筆資料列會自動產生。@Attribute(.unique)宣告告訴框架「此欄位每個值最多儲存一筆資料列」。當你插入一個帶有已存在.unique值的模型時,SwiftData會執行upsert:既有資料列會被更新,而不是被拒絕。這個語意對產品程式碼很重要:.unique不是阻止重複資料被提交的UI層級驗證;它是一個「至多一筆」的儲存保證,會悄悄合併。上述id: UUID的模式是建議用於跨行程同步的做法(你需要一個穩定的識別子,即使行程內的PersistentIdentifier消失也能繼續存在),而upsert行為正是當同一個UUID從兩條同步路徑送達時你想要的結果。

@Model類別是參考型別,不是值型別。 變更ShoppingItem實例上的屬性會觸發SwiftData的變更追蹤;框架會註冊變更,並在下次context儲存時持久化。透過@Query整合到SwiftUI後,任何觀察到符合述詞的視圖都會重新渲染。這個模式類似於@Observable(在What SwiftUI Is Made Of中介紹),只是在上層加上了持久化。

可選欄位是廉價的遷移

ShoppingItem上的lastModified: Date?欄位是可選的,而這個可選性具有承載作用。此欄位是在v1發布之後才加入的,用於支援跨裝置同步與衝突解決;使用者裝置上的既有資料列並無lastModified值。一個沒有預設值的可選欄位,讓SwiftData的lightweight migration能在不寫任何遷移程式碼的情況下處理新增:既有資料列拿到nil;新資料列則拿到init設定的值。3

Lightweight migration路徑是框架的禮貌路徑。SwiftData檢查新schema與持久化儲存,推論出最小的相容變更並套用。遷移是自動的;使用者看不到任何東西;app在既有資料上正常啟動。lightweight路徑能乾淨處理的情況包括:

  • 新增可選屬性
  • 移除屬性(資料被丟棄;既有讀取看不到該欄位)
  • 透過提示能讓框架配對的屬性重新命名(使用@Attribute(originalName: ...))
  • 框架能配對的@Model類別重新命名(使用@Model.originalName或提示)

lightweight路徑會中止的情況包括:

  • 對既有schema新增沒有預設值的非可選屬性(既有資料列沒有值可填入)
  • 變更屬性型別(例如IntString)
  • 將一個模型分割為兩個,或將兩個合併為一個
  • 任何需要自訂邏輯才能遷移的變更

當lightweight路徑中止時,安全的行為是讓遷移失敗。不安全的行為會是丟棄資料庫並從頭開始;框架很保守,拒絕悄悄這麼做。使用者看到app在啟動時當機並出現遷移錯誤;開發者看到指向schema不符的堆疊追蹤;沒人遺失資料,但所有人都失去了信心。

從第一天就略過VersionedSchema的成本,會在v2 → v3的邊界顯現,當你新增第三個schema變更超出lightweight路徑能處理的範圍時。

VersionedSchema與MigrationPlan:第一天就要建立的紀律

VersionedSchema宣告模型schema的特定版本。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)
    ]
}

模型類別本身會移到版本化schema命名空間之中:

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")
)

遷移計畫提供框架一張型別化的schema演進圖。當v2版app對v1資料庫啟動時,框架會走過遷移計畫,套用具名的階段,並把資料庫帶到v2。當你發布v3時,在schemas中加入SchemaV3.self,並在v2與v3之間新增一個MigrationStage即可。

紀律就是即使只有一個版本,也要在v1就帶入VersionedSchema。這麼做的成本是多一個檔案與一個額外的enum宣告。這麼做的代價是:v2的第一個非小幅度schema變更需要回頭把v1包進VersionedSchema,雖然可行,但需要很小心地對齊v1的精確形式,框架才能將既有資料識別為SchemaV1。未來在做v2的你會付出這筆稅;現在的你只要付一次,就可以一勞永逸。

處理棘手情況的Custom MigrationStage

Lightweight migrations涵蓋大部分的新增式變更。型別變更、分割、合併,以及條件式填值需要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()
        }
    )
]

兩個closure會在框架套用結構性遷移之前與之後執行。willMigrate對v1 schema執行;didMigrate對v2 schema執行。closure內部是一般的SwiftData程式碼(fetch descriptors、模型context儲存、執行中的app所使用的同樣API),操作對象是一個遷移期間的暫態context。

在生產環境能存活下來的模式是:讓willMigrate保持空白,把所有填值邏輯都放在didMigrate裡。在willMigrate內部讀取v1資料是允許的,但從框架的視角來看,v2 schema此時尚不存在,所以任何運算都必須暫存到didMigrate closure能讀取的暫態儲存中。更簡單的規則:結構性遷移是框架的工作;在既有資料列上填入v2專屬欄位則是didMigrate的工作。

@Attribute@Relationship何時名副其實

兩個macros包辦了@Model類別大部分的schema裝飾工作。

@Attribute 為單一屬性附加約束或提示:

  • @Attribute(.unique)強制唯一性,例如ShoppingItem.id
  • @Attribute(.externalStorage)將大型Data blob儲存在資料庫之外(影像資料、音訊緩衝)
  • @Attribute(originalName: "old_field_name")在遷移期間將屬性對應到一個重新命名過的欄位
  • @Attribute(.transformable(by: ...))對非Codable型別套用ValueTransformer

正確的紀律:對真正應該唯一的欄位使用.unique(你產生的UUID、外部ID),對任何超過幾KB的blob使用.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,且明確單向的關聯也支援inverse: nil,但安全的預設做法是,只要推論可能模稜兩可就明確宣告inverse:5

正確的紀律:用明確的deleteRule宣告關聯(預設是.nullify,通常不是你想要的),且只要關聯是雙向的就宣告inverse:(而不是依賴框架的推論)。隱含預設通常是錯的;明確形式只是多一個參數,卻能省下一個永遠埋著的bug。

我會用怎樣不同的方式建構

這個系列裡的apps已實作或希望實作的三個模式。

從v1就帶入VersionedSchema 每一個發布的@Model類別都應該從第一天就活在VersionedSchema裡。成本是每個schema版本多一個包裝用的enum。好處是v2的第一個非小幅度變更只需在MigrationPlan.schemas新增一行,而不是花兩天回頭重構。

讓每個時間戳記都是可選。 為了跨裝置同步或衝突解決而存在的lastModifiedcreatedAtupdatedAt等欄位,如果v1產品還用不到,就應該在v1宣告為可選。可選性能讓v2(當你真的需要時)的遷移變得便宜。在didMigrate期間為既有資料列填入這些欄位只是一個迴圈;在v1就把它們設為非可選,則是一個可能讓使用者資料的回填作業破功的約束。

用UUID當自然鍵,而不是PersistentIdentifier SwiftData的PersistentIdentifier是行程內的。跨裝置同步、MCP整合(在Two Agent Ecosystems, One Shopping List中介紹),以及任何跨行程的參照,都需要一個穩定的識別子。一個搭配@Attribute(.unique)UUID是正確的形式;行程內的PersistentIdentifier對任何跨越行程邊界的事物都是錯誤的形式。

@Model是錯誤答案的時候

SwiftData不適合的三種情境:

單筆紀錄的key/value狀態。 App設定、使用者選擇的語言、上次同步的時間戳記。改用UserDefaultsNSUbiquitousKeyValueStore(在Five Apple Platforms, Three Shared Files中介紹)。SwiftData為了單一資料列付出的開銷是浪費的儀式;key-value儲存才是正確的基底。

伺服器權威、無離線寫入的資料。 從REST API抓回並只供唯讀顯示的清單。如果真實來源在伺服器、本機只是快取,SwiftData就過度殺雞用牛刀。在Documents/裡放一個簡單的Codable快照,加上記憶體中的快取陣列就夠了;如果資料無需在硬重置後存活,就不值得繳SwiftData的遷移稅。

多行程協調。 SwiftData在單一行程內運作。在iOS app外部執行的MCP伺服器無法讀寫app的SwiftData容器。跨行程狀態需要不同的形式:iCloud Drive JSON檔、共享App Group容器,或一個明確橋接行程的同步層。(Get Bananas正是因為這個原因才把SwiftData與iCloud Drive JSON搭配使用。)6

資料是極少變動的大型blob。 10MB的音訊檔、50MB的影像資料集。如果blob放在SwiftData資料列內,就用@Attribute(.externalStorage);否則就直接用檔案系統,並在SwiftData中放指向檔案URL的中繼資料。

這個模式對iOS 26+上發布的app意味著什麼

三個重點。

  1. macros是容易的部分。遷移才是成本所在。 @Model@Attribute是隱藏大量Core Data管線的兩行宣告。在app的整個生命週期裡你真正付出的代價是遷移紀律;設計v1時就要把v2放在心上。

  2. 對發布中的app而言,從第一天起的VersionedSchema是不可妥協的。 包裝用的enum只是多一個檔案。事後再加上的代價高出許多。

  3. 可選欄位與明確的關聯是廉價的保險。 同步中繼資料用可選時間戳記、關聯上明確標示deleteRuleinverse:。兩者都是極小的宣告,卻能買到大量的v2彈性。

完整的Apple Ecosystem系列:給Apple Intelligence的型別化App Intents;給跨LLM代理的MCP伺服器;兩者之間的路由問題;給裝置端LLM與Tool協定的Foundation Models;給iOS Lock Screen狀態機的Live Activities;Apple Watch上的watchOS執行期合約;作為框架基底的SwiftUI內部;給visionOS場景的RealityKit的空間心智模型;視覺層的Liquid Glass模式;跨裝置觸及的多平台發布。樞紐位於Apple Ecosystem Series。對於更廣泛的iOS搭配AI agents的脈絡,請參閱iOS Agent Development guide

FAQ

@Model與Core Data的NSManagedObject有何不同?

@Model是一個Swift macro,會在底層產生NSManagedObject管線。SwiftData以Core Data為其後端儲存,所以執行期模型是相同的;差別在於表面。@Model移除了.xcdatamodeld檔、value-transformer儀式,以及NSManagedObjectContext生命週期管理。你得到的是同一個持久化儲存,但搭配Swift形式的API。

如果我從不打算更動schema,還需要VersionedSchema嗎?

如果你的app可能發布v2,需要。如果這是一次性demo,則不需要。從v1就帶入VersionedSchema的成本是多一個enum宣告。在v2再回頭加上的成本是要對齊v1的精確schema形式,讓框架能識別既有資料,雖然可行但容易出錯。大多數發布中的app最終都會需要schema變更;在v1就為此預留空間。

何時應該使用@Attribute(.unique)?

當該欄位是該資料列的自然鍵時:你產生的UUID、你匯入的外部ID、你指派的slug。SwiftData把.unique視為upsert:如果你插入一個.unique值已存在的模型,既有資料列會被更新,而不是新增一筆。這個語意正是讓upsert風格的同步路徑(同一個UUID從兩台裝置送來)安全的關鍵;這也是為什麼.unique不適用於像title這類顯示名稱欄位的原因——兩個使用者打了相同標題會悄悄合併彼此的資料列,而不是產生兩筆獨立紀錄。

我該如何處理對既有schema新增的非可選欄位?

使用搭配didMigrate closure的MigrationStage.custom,在既有資料列上填入該欄位。或者更簡單:在新schema版本中將欄位宣告為可選,並在存取時惰性填入。可選性是更便宜的遷移;新增非可選欄位則需要明確的填值邏輯。

PersistentIdentifier與我自己的UUID差在哪裡?

PersistentIdentifier是SwiftData的行程內資料列ID;它會自動產生並只存活於執行中行程的生命週期。你自己以@Attribute(.unique)修飾的UUID則是穩定的跨行程、跨裝置識別子。在app內的行程內參照使用PersistentIdentifier。任何跨越行程邊界的事物(跨裝置同步、外部整合、MCP工具、網路呼叫)則使用UUID。

References


  1. Author’s Get Bananas, a SwiftUI shopping list app that pairs SwiftData with iCloud Drive JSON sync and an MCP server. The ShoppingItem model evolved across the early development cycle; the lastModified: Date? field was added after the initial schema (commit 268a00d on 2025-12-01, “Make lastModified optional to fix migration crash”) because making it non-optional broke migration when existing rows had no value to populate it. 

  2. Apple Developer, “SwiftData” and “Adding and editing persistent data in your app”. The @Model macro, the @Attribute constraint surface, and the relationship to Core Data’s NSManagedObjectModel

  3. Apple Developer, “Preserving your app’s model data across launches” and “Adopting SwiftData for a Core Data app”. Lightweight migration semantics and what triggers the framework to bail. 

  4. Apple Developer, “VersionedSchema” and “SchemaMigrationPlan”. Versioned schema declarations, migration stage definitions, and the ModelContainer constructor that takes a migration plan. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” and “Schema.Relationship”. The @Relationship macro, deleteRule options (.cascade, .nullify, .deny, .noAction), and the role of the inverse: parameter in bidirectional relationship maintenance. 

  6. Author’s analysis in Two Agent Ecosystems, One Shopping List, April 29, 2026, and Five Apple Platforms, Three Shared Files. The Get Bananas + Return cross-process and cross-device sync patterns that complement (and sometimes replace) SwiftData inside a multi-process workflow. 

相關文章

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

SwiftData 的遷移模型使用 VersionedSchema、MigrationStage 和 SchemaMigrationPlan。多數結構描述變更不需要 V2 結構描述;需要的情況才真的需要。

3 分鐘閱讀

iOS 27 的 SwiftData:觀察與歷史記錄

iOS 27 為 SwiftData 帶來一流的變更觀察能力,包括 ResultsObserver、可觀察持久化歷史的 HistoryObserver,以及 codable 屬性儲存。

3 分鐘閱讀

清理層才是真正的 AI 代理市場

Charlie Labs 從建構代理轉向清理代理留下的爛攤子。AI 代理市場正從生成轉向證明。清理才是耐久的那一層。

2 分鐘閱讀