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
@Modelmacro將類別轉換為持久化的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新增沒有預設值的非可選屬性(既有資料列沒有值可填入)
- 變更屬性型別(例如
Int→String) - 將一個模型分割為兩個,或將兩個合併為一個
- 任何需要自訂邏輯才能遷移的變更
當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)將大型Datablob儲存在資料庫之外(影像資料、音訊緩衝)@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新增一行,而不是花兩天回頭重構。
讓每個時間戳記都是可選。 為了跨裝置同步或衝突解決而存在的lastModified、createdAt、updatedAt等欄位,如果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設定、使用者選擇的語言、上次同步的時間戳記。改用UserDefaults或NSUbiquitousKeyValueStore(在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意味著什麼
三個重點。
-
macros是容易的部分。遷移才是成本所在。
@Model與@Attribute是隱藏大量Core Data管線的兩行宣告。在app的整個生命週期裡你真正付出的代價是遷移紀律;設計v1時就要把v2放在心上。 -
對發布中的app而言,從第一天起的
VersionedSchema是不可妥協的。 包裝用的enum只是多一個檔案。事後再加上的代價高出許多。 -
可選欄位與明確的關聯是廉價的保險。 同步中繼資料用可選時間戳記、關聯上明確標示
deleteRule與inverse:。兩者都是極小的宣告,卻能買到大量的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
-
Author’s Get Bananas, a SwiftUI shopping list app that pairs SwiftData with iCloud Drive JSON sync and an MCP server. The
ShoppingItemmodel evolved across the early development cycle; thelastModified: Date?field was added after the initial schema (commit268a00don 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. ↩ -
Apple Developer, “SwiftData” and “Adding and editing persistent data in your app”. The
@Modelmacro, the@Attributeconstraint surface, and the relationship to Core Data’sNSManagedObjectModel. ↩↩ -
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. ↩
-
Apple Developer, “VersionedSchema” and “SchemaMigrationPlan”. Versioned schema declarations, migration stage definitions, and the
ModelContainerconstructor that takes a migration plan. ↩ -
Apple Developer, “Defining data relationships with enumerations and model classes” and “Schema.Relationship”. The
@Relationshipmacro,deleteRuleoptions (.cascade,.nullify,.deny,.noAction), and the role of theinverse:parameter in bidirectional relationship maintenance. ↩ -
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. ↩