← 所有文章

SwiftData 真正的成本在於 Schema 紀律

Get Bananas 的 ShoppingItem 正是說明為何 SwiftData schema 紀律如此重要的經典案例。最初的 schema 並未包含 lastModified 時間戳記;後來要補上時,必須採用特定形狀的 migration,因為既有資料早已存在於磁碟上,而這個欄位之所以被設為 optional,正是為了修正它最初以非 optional 形式加入時所引發的 migration 崩潰。1

SwiftData 的 API 只有兩個 macro。在類別上加註 @Model 就讓它成為一個 persistent 型別。在屬性上加註 @Attribute(.unique) 則賦予它唯一性約束。這個框架隱藏了 Core Data 的堆疊管理、value-transformer 的繁瑣操作,以及 NSManagedObjectContext 的樣板程式碼。但框架並未隱藏的,是 schema migration;它只是讓 migration 從命令式變成宣告式。不留意 migration 所付出的代價,就是那種會在例行更新時抹除使用者資料的 bug。

本文的論點是:SwiftData 起步成本低廉,但草率 migration 的代價高昂。所謂紀律,就是從第一天起就把命名、optional 與否,以及 VersionedSchema 都做對,而不是等到你意識到自己早該這麼做的那一天。

TL;DR

  • @Model macro 將一個類別轉變為 persistent 的 SwiftData 型別。框架會在編譯期依據屬性宣告產生 schema。
  • 新增一個 optional 屬性是不需動作的 migration:SwiftData 的輕量 migration 會自動處理。要在既有 schema 上新增非 optional 屬性,則需要一個 VersionedSchema 加上一個 MigrationPlan,告訴框架如何為既有資料列填入新欄位。
  • 從第一天起就省略 VersionedSchema 的代價是:任何稍具規模的 v2 schema 變更都可能丟失使用者的資料庫,因為輕量路徑趨於保守,一旦無法推斷出 migration 就會直接放棄。
  • @Attribute(.unique) 是處理自然鍵(你自己產生的 UUID、你匯入的外部 ID)的正確工具。@Relationship 則是處理父/子參照的正確工具。兩者都是底層會產生對應 Core Data 機制的 macro。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 不需要另外宣告 persistent-store schema。 SwiftData 在編譯期讀取類別定義並合成出 schema。類別的屬性成為模型的 attribute;其 Swift 型別成為欄位型別。沒有需要維護的 .xcdatamodeld 檔案(不過底層的 Core Data NSManagedObjectModel 依然存在,並且是執行期支撐 schema 的角色)。2

@Attribute(.unique) 是單一欄位的約束,而非 PRIMARY KEY 宣告。 SwiftData 的 persistent identity 是 PersistentIdentifier,會為每一列自動產生。@Attribute(.unique) 宣告告訴框架「此欄位每個值最多只儲存一列」。當你插入一個其 .unique 值已存在的模型時,SwiftData 會執行 upsert:既有資料列會被更新,而不是被拒絕。這個語意對產品程式碼很重要:.unique 並不是阻止重複值被送出的 UI 層驗證;它是一種會悄悄合併的「最多一列」儲存保證。上方的 id: UUID 模式正是跨行程同步所推薦的做法(在這種情境下,你需要一個能在行程內的 PersistentIdentifier 消失後仍然穩定的識別碼),而當同一個 UUID 從兩條同步路徑傳來時,upsert 行為正是你所期望的。

@Model 類別是參考型別,而非值型別。ShoppingItem 實例上更動屬性會觸發 SwiftData 的變更追蹤;框架會登記該變更,並在下一次 context 儲存時持久化。透過 @Query 的 SwiftUI 整合會重新繪製任何觀察相符 predicate 的視圖。這個模式與 @Observable 相似(在 What SwiftUI Is Made Of 中有詳述),只是在其上多疊了一層持久化。

Optional 欄位是廉價的 Migration

ShoppingItem 上的 lastModified: Date? 欄位是 optional 的,而這個 optional 屬性是承重的關鍵。這個欄位是在 v1 上架之後才加入的,用以支援跨裝置同步與衝突解決;使用者裝置上的既有資料列並沒有 lastModified 值。一個沒有預設值的 optional 欄位,讓 SwiftData 的輕量 migration 得以在不撰寫任何 migration 程式碼的情況下處理這次新增:既有資料列得到 nil;新資料列則得到 init 所設定的值。3

輕量 migration 路徑是框架客氣的那條路。SwiftData 檢視新的 schema 與 persistent store,推斷出最小的相容變更並予以套用。Migration 是自動的;使用者什麼也看不到;app 在既有資料上正常啟動。輕量路徑能乾淨俐落處理的情況:

  • 新增一個 optional 屬性
  • 移除一個屬性(資料被丟棄;既有的讀取不再看見該欄位)
  • 重新命名一個框架能藉提示比對的 attribute(使用 @Attribute(originalName: ...)
  • 重新命名一個框架能比對的 @Model 類別(使用 @Model.originalName 或提示)

輕量路徑會放棄的情況:

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

當輕量路徑放棄時,安全的行為是讓 migration 失敗。不安全的行為會是丟棄整個資料庫從頭來過;框架趨於保守,拒絕悄悄這麼做。使用者會看到 app 在啟動時因 migration 錯誤而崩潰;開發者會看到指向 schema 不符的堆疊追蹤;沒有人遺失資料,但每個人都失去信心。

從第一天起就省略 VersionedSchema 的代價,會在 v2 → v3 的交界處浮現——當你加入第三項功能,而它的 schema 變更超出輕量路徑所能處理的範圍時。

VersionedSchema 與 MigrationPlan:第一天就該有的紀律

VersionedSchema 宣告模型 schema 的某個特定版本。MigrationPlan 則宣告如何從一個版本 migrate 到下一個版本。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)
    ]
}

模型類別本身則移入 versioned-schema 的命名空間:

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

ModelContainer 則搭配 migration plan 來建構:

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

Migration plan 給予框架一張 schema 如何演進的型別化圖譜。當執行 v2 的 app 對著一個 v1 資料庫啟動時,框架會走訪 migration plan,套用具名的 stage,並把資料庫帶到 v2。當你上架 v3 時,只需在 schemas 中加入 SchemaV3.self,並在 v2 與 v3 之間加入一個新的 MigrationStage

所謂紀律,就是在 v1 就上架 VersionedSchema,即使當時只有一個版本。這麼做的代價是多一個檔案與一個 enum 宣告。這麼做的代價,則是 v2 第一次稍具規模的 schema 變更時,必須回頭把 v1 包進一個 VersionedSchema 裡。這雖然辦得到,但需要謹慎地比對出與 v1 完全相同的形狀,框架才能將既有資料辨識為 SchemaV1。負責 v2 的未來的你會付這筆稅;而現在的你只需付一次便能一勞永逸。

為棘手情況準備自訂 MigrationStage

輕量 migration 涵蓋了大多數的新增式變更。型別變更、拆分、合併與條件式填值,則需要 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 分別在框架套用結構性 migration 之前與之後觸發。willMigrate 對著 v1 schema 執行;didMigrate 對著 v2 schema 執行。Closure 本體就是一般的 SwiftData 程式碼(fetch descriptor、model context 儲存,與執行中的 app 所用的相同 API),對著一個暫時性的 in-migration context 運作。

能在正式環境中存活的模式,是讓 willMigrate 保持空白,並把所有填值邏輯放進 didMigrate。在 willMigrate 內讀取 v1 資料是被允許的,但從框架的角度來看 v2 schema 此時尚不存在,因此任何運算都必須暫存到一個 didMigrate closure 能讀取的暫時性儲存中。更簡單的原則是:結構性 migration 是框架的工作;為既有資料列填入僅 v2 才有的欄位則是 didMigrate 的工作。

@Attribute@Relationship 何時名副其實

@Model 類別中,多數的 schema 修飾工作由兩個 macro 完成。

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

  • @Attribute(.unique) 強制唯一性,如同 ShoppingItem.id
  • @Attribute(.externalStorage) 將大型 Data blob 儲存在資料庫之外(影像資料、音訊緩衝區)
  • @Attribute(originalName: "old_field_name") 在 migration 期間將屬性對應到一個被重新命名的欄位
  • @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。

跨越 Actor 邊界:傳識別碼,別傳整張圖

一個 @Model 類別並非 Sendable,而正確的做法是別再試圖讓它變成 Sendable。該實例是指向一張由 ModelContext 持有的活躍物件圖的參考;框架無法保證那張圖能安全地從另一個 actor 讀取,因此這個型別刻意被設計為非 Sendable。強行讓它符合並不會讓資料競爭消失;只是把它藏了起來。7

行得通的模式,是傳遞身分識別與單純的值,再在另一端重新 fetch。PersistentIdentifierSendable 的,因此能乾淨地跨越邊界。把目的地所需的任何純量值取出(一個名稱、一個旗標、一個放進小型 struct 的差異量),與識別碼一併傳遞,再讓接收端的 actor 用該識別碼從自己的 context 重新 fetch 出模型:

// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)

// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem

要避免的失敗模式,是傳遞模型圖本身。當圖的一部分跨越邊界時,接收方得到的會是一個在另一端只能部分水合的模型:那些在來源 context 中從未被 fault 進來的關係與惰性載入屬性,會對著錯誤的 context 解析(或根本無法解析),而隨之而來的 bug 屬於那種無聲無息的類型。識別碼加上取出的值才是安全的契約;整張圖則不是。ModelActor 透過持有一個 context、並提供值而非實例,將這份紀律封裝了起來。7

CloudKit 同步與 App-Group Entitlement 的陷阱

把一個 SwiftData store 搬進 app-group 容器,好讓 widget 或 extension 能讀取它,這件事與 CloudKit 同步互動的方式會在 app 上架之後反咬一口。有兩個事實能推導出其餘的一切。

首先是 store 的位置。在使用預設 ModelConfiguration 的情況下,當 app 從無 group 演進為有 app-group 時,SwiftData 會替你把既有的 store 複製到 app-group 容器中;Apple 的措辭是 SwiftData「將既有的 store 複製到 app group 容器」。8 若使用自訂的 store URL,位置就由你掌控:你自己把檔案複製到新容器,並讓 configuration 指向它。預設路徑之所以便利,正是因為框架替你做了複製;自訂路徑則是以那份便利換取掌控權。

其次是 entitlement。每一個讀取 CloudKit 同步 store 的 app-group 成員,都必須帶有相同的 CloudKit entitlement,因為這些行程每一個都會代表自己同步那個容器。這個要求正是陷阱:widget 或 extension 既沒有執行期預算,也沒有前景視窗去驅動一場冗長的同步,而把 CloudKit entitlement 交給它,等於逼它去嘗試。解法是拆成兩個 ModelConfiguration 實例:一個同步 store(帶 CloudKit entitlement,由主 app 持有),以及一個位於 app-group 容器、供 widget 與 extension 在不進行任何同步的情況下讀取的本地 store。把同步放在前景中的 app 能好好完成它的地方,並把共用以供讀取的資料留在同步路徑之外。8

我會用不同方式打造的部分

有三個模式,是這個系列群集中的 app 要麼已經採用,要麼但願自己當初採用了。

從 v1 就上架 VersionedSchema 每一個上架的 @Model 類別都應從第一天起就活在一個 VersionedSchema 之內。代價是每個 schema 版本多一個包裝用的 enum。好處是 v2 的第一次稍具規模的變更,只是對 MigrationPlan.schemas 加一行,而非為期兩天的回頭重構。

讓每個時間戳記都是 optional。lastModifiedcreatedAtupdatedAt 這類為了跨裝置同步或衝突解決而存在的欄位,若 v1 產品並不需要它們,就應在 v1 設為 optional。Optional 屬性讓往 v2 的 migration(當你確實需要它們時)維持廉價。在 didMigrate 期間為既有資料列填值只是一個迴圈;而從 v1 就讓它們成為非 optional,則是一個可能在使用者資料的回填上出錯的約束。

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

@Model 何時是錯誤的答案

有三種情況下 SwiftData 並非正確的工具:

單筆鍵/值狀態。 App 設定、使用者選擇的語言、上一次同步的時間戳記。請使用 UserDefaultsNSUbiquitousKeyValueStore(在 Five Apple Platforms, Three Shared Files 中有詳述)。SwiftData 為單一資料列所付出的額外負擔是浪費的儀式;鍵值儲存才是正確的基底。

以伺服器為權威、沒有離線寫入的資料。 一份從 REST API 取得並以唯讀方式顯示的清單。若事實的來源是伺服器、而本地快取只是快取,SwiftData 就是殺雞用牛刀。一份放在 Documents/ 的簡單 Codable 快照,加上一個記憶體快取的陣列就已足夠;倘若資料在硬重置後不必存活,那份 SwiftData migration 稅就不值得繳。

多行程協作。 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. Macro 是容易的部分。Migration 才是成本。 @Model@Attribute 是兩行的宣告,隱藏了大量的 Core Data 機制。Migration 紀律才是你在 app 的生命週期中真正付出的代價;設計 v1 時就要把 v2 放在心上。

  2. 對上架的 app 而言,從第一天就有 VersionedSchema 沒有商量餘地。 包裝用的 enum 只是多一個檔案。日後才回頭補上它的代價,則高出許多。

  3. Optional 欄位與明確的關係是廉價的保險。 為同步中繼資料準備的 optional 時間戳記、關係上明確的 deleteRuleinverse:。兩者都是微小的宣告,卻換來大量的 v2 彈性。

完整的 Apple Ecosystem 系列群集:為 Apple Intelligence 而生、具型別的 App Intents;為跨 LLM agent 而生的 MCP 伺服器;以及兩者之間的 路由問題;用於裝置端 LLM 與 Tool protocol 的 Foundation Models;用於 iOS 鎖定畫面狀態機的 Live Activities;Apple Watch 上的 watchOS 執行期 契約;作為框架基底的 SwiftUI 內部結構;用於 visionOS 場景的 RealityKit 空間心智模型;用於視覺層的 Liquid Glass 模式;以及為跨裝置觸及而生的 多平台上架。系列中樞位於 Apple Ecosystem 系列。若想了解更廣泛的 iOS 結合 AI agent 的脈絡,請參閱 iOS Agent Development 指南

FAQ

@Model 與 Core Data 的 NSManagedObject 有什麼差別?

@Model 是一個 Swift macro,在底層產生 NSManagedObject 的機制。SwiftData 以 Core Data 作為其後端儲存,因此執行期模型是相同的;差別在於表層。@Model 移除了 .xcdatamodeld 檔案、value-transformer 的儀式,以及 NSManagedObjectContext 的生命週期管理。你得到的是同一個 persistent store,但搭配一個 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 這類顯示名稱欄位上是錯誤的工具,因為兩位輸入相同 title 的使用者會悄悄合併彼此的資料列,而非產生兩筆截然不同的紀錄。

我該如何處理在既有 schema 上新增的非 optional 欄位?

使用一個帶有 didMigrate closure 的 MigrationStage.custom,在既有資料列上填入該欄位。或者,更簡單的做法:在新的 schema 版本中將該欄位宣告為 optional,並在存取時惰性填值。Optional 屬性是較廉價的 migration;非 optional 的新增則需要明確的填值邏輯。

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. 

  7. Apple Developer, “PersistentIdentifier” (conforms to Sendable) and “ModelActor”. The SwiftData team confirmed during the WWDC 2026 SwiftData Group Lab that @Model objects are not Sendable and should not be forced to conform, because they are a reference graph living inside a context; the recommended boundary contract is to pass the Sendable PersistentIdentifier plus extracted plain values and re-fetch on the destination context, and that passing the model graph leaves the receiver with a partially hydrated object. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. 

  8. Apple Developer, “Adopting SwiftData for a Core Data app”, which states that with the default configuration “SwiftData copies the existing store to the app group container,” while a custom store URL leaves the location for you to manage. The CloudKit-entitlement requirement for app-group members and the two-ModelConfiguration split (one synced, one local) for keeping widgets and extensions out of the sync path were described during the WWDC 2026 SwiftData Group Lab. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. 

相關文章

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

SwiftData 的遷移模型由 VersionedSchema、MigrationStage 與 SchemaMigrationPlan 組成。多數 schema 變更其實不需要 V2 schema;真正需要的情況才需要。

5 分鐘閱讀

iOS 27 的 SwiftData:Observation 與 History

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

4 分鐘閱讀

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

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

2 分鐘閱讀