← 所有文章

SwiftData 的真正成本是 schema 纪律

Get Bananas 的 ShoppingItem 正是说明 SwiftData schema 纪律为何重要的典型例子。最初的 schema 并不包含 lastModified 时间戳;后来要补上它时,需要采用一种特定的迁移形态,因为既有数据已经落在磁盘上,而这个字段之所以被设为可选,正是为了修复它最初被作为非可选字段添加时触发的一次迁移崩溃。1

SwiftData 的 API 只有两个宏。在类上加 @Model 让它成为持久化类型。在属性上加 @Attribute(.unique) 为它赋予唯一性约束。这个框架隐藏了 Core Data 的栈管理、value-transformer 的繁琐操作以及 NSManagedObjectContext 的样板代码。它没有隐藏的,是 schema 迁移;它只是把迁移从命令式变成了声明式。不在迁移上花心思的代价,就是那种在常规更新时把用户数据抹掉的 bug。

核心论点:SwiftData 起步成本低,而草率迁移的成本高。这份纪律就是从第一天起就处理好命名、可选性和 VersionedSchema,而不是等到你意识到本该如此的那一天。

TL;DR

  • @Model 宏把一个类变成可持久化的 SwiftData 类型。框架在编译期根据属性声明生成 schema。
  • 新增一个可选属性是一次无操作的迁移:SwiftData 的轻量迁移会处理它。向既有 schema 新增一个非可选属性,则需要一个 VersionedSchema 加上一个 MigrationPlan,告诉框架如何为既有行填充这个新字段。
  • 不从第一天就用上 VersionedSchema 的代价是:任何非平凡的 v2 schema 变更都有抹掉用户数据库的风险,因为轻量路径很保守,一旦无法推断迁移就会直接放弃。
  • @Attribute(.unique) 是处理自然键(你自己生成的 UUID、你导入的外部 ID)的正确工具。@Relationship 是处理父/子引用的正确工具。两者都是在底层生成正确 Core Data 管道的宏。2

@Model 实际做了什么

一个 SwiftData 类型就是一个应用了 @Model 宏的 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 消失后仍然稳定的标识符),而当同一个 UUID 从两条同步路径到来时,upsert 行为正是你想要的。

@Model 类是引用类型,不是值类型。 修改 ShoppingItem 实例上的某个属性会触发 SwiftData 的变更跟踪;框架记录这次变更,并在下一次 context 保存时把它持久化。通过 @Query 的 SwiftUI 集成会重新渲染任何观察匹配谓词的视图。这个模式类似于 @Observable(见 SwiftUI 由什么构成),只是在其上叠加了持久化。

可选字段是廉价的迁移

ShoppingItem 上的 lastModified: Date? 字段是可选的,而这种可选性是承重的。这个字段是在 v1 发布之后才加上的,用于支持跨设备同步和冲突解决;用户设备上的既有行没有 lastModified 值。一个没有默认值的可选字段让 SwiftData 的轻量迁移在不写任何迁移代码的情况下处理这次新增:既有行得到 nil;新行得到 init 设定的任意值。3

轻量迁移路径是框架的”客气”路径。SwiftData 检查新的 schema 和持久化存储,推断出最小的兼容变更,并应用它。迁移是自动的;用户看不到任何东西;应用在既有数据上正常启动。轻量路径能干净处理的情形:

  • 新增一个可选属性
  • 移除一个属性(数据被丢弃;既有的读取不再看到该列)
  • 重命名一个框架能凭提示匹配的特性(使用 @Attribute(originalName: ...)
  • 重命名一个框架能匹配的 @Model 类(使用 @Model.originalName 或一个提示)

轻量路径会放弃的情形:

  • 向既有 schema 新增一个没有默认值的非可选属性(既有行没有值可用来填充它)
  • 改变某个属性的类型(例如 IntString
  • 把一个模型拆成两个模型,或把两个合并成一个
  • 任何需要自定义逻辑才能迁移的情况

当轻量路径放弃时,安全的行为是让迁移失败。不安全的行为会是丢弃数据库重新来过;框架很保守,拒绝悄无声息地这么做。用户看到应用在启动时随迁移错误崩溃;开发者看到一段指向 schema 不匹配的栈追踪;没人丢数据,但所有人都失去了信心。

不从第一天就用上 VersionedSchema 的代价会在 v2 → v3 的边界上显现出来——当你加入第三个功能,而它的 schema 变更超出了轻量路径所能处理的范围时。

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 的应用对着一个 v1 数据库启动时,框架沿着迁移计划走,应用命名的各个阶段,把数据库带到 v2。当你发布 v3 时,你向 schemas 加入 SchemaV3.self,并在 v2 与 v3 之间加入一个新的 MigrationStage

这份纪律就是在 v1 里就发布 VersionedSchema,即便当时只有一个版本。这样做的代价是一个多出来的文件和一个多出来的 enum 声明。这样做的代价是:v2 第一次非平凡的 schema 变更需要回头把 v1 包进一个 VersionedSchema,这能做到,但需要小心地匹配确切的 v1 形态,以便框架能把既有数据识别为 SchemaV1。做 v2 的未来的你会付这笔税;现在的你可以付一次然后就忘了它。

应对疑难情形的自定义 MigrationStage

轻量迁移涵盖了大多数加性变更。类型变更、拆分、合并和有条件的填充则需要一个 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()
        }
    )
]

这两个闭包在框架应用结构性迁移之前和之后触发。willMigrate 对着 v1 schema 运行;didMigrate 对着 v2 schema 运行。闭包体是普通的 SwiftData 代码(fetch descriptor、model context 保存,与运行中的应用所用的 API 相同),针对一个临时的迁移中 context 操作。

在生产环境中行得通的模式,是让 willMigrate 保持为空,把所有填充逻辑放进 didMigrate。在 willMigrate 内部读取 v1 数据是允许的,但从框架的视角看 v2 schema 此时还不存在,所以任何计算都得暂存到一个 didMigrate 闭包能读取的临时存储里。更简单的规则:结构性迁移是框架的工作;在既有行上填充 v2 专属字段是 didMigrate 的工作。

@Attribute@Relationship 名副其实

@Model 类里,两个宏完成了大部分 schema 修饰工作。

@Attribute 用一个约束或提示修饰单个属性:

  • @Attribute(.unique) 强制唯一性,如 ShoppingItem.id
  • @Attribute(.externalStorage) 把大型 Data 二进制块存储在数据库之外(图片数据、音频缓冲区)
  • @Attribute(originalName: "old_field_name") 在迁移期间把一个属性匹配到一个被重命名的列
  • @Attribute(.transformable(by: ...)) 对一个非 Codable 类型应用一个 ValueTransformer

正确的纪律:对真正应当唯一的字段(你生成的 UUID、一个外部 ID)使用 .unique,对任何超过几 KB 的二进制块使用 .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: nil 也受支持,用于明确的单向关系,但安全的默认做法是:只要推断可能有歧义,就声明 inverse:5

正确的纪律:用明确的 deleteRule 声明关系(默认是 .nullify,而这很少是你想要的),并在关系是双向时声明 inverse:(而不是依赖框架的推断)。隐式默认值通常是错的;显式形式只是多一个参数,却换来一个被永久避免的 bug。

跨越 actor 边界:传标识符,别传对象图

@Model 类不是 Sendable,而正确的做法是别再试图把它变成 Sendable。这个实例是指向一张由 ModelContext 持有的活动对象图的引用;框架无法保证那张图可以安全地从另一个 actor 读取,所以这个类型被刻意保持为非 Sendable。强行让它遵从协议并不能让数据竞争消失;那只是把它藏起来。7

行得通的模式是传身份和纯值,然后在另一端重新取数据。PersistentIdentifierSendable,所以它能干净地跨过边界。把目的地需要的任何标量值取出来(一个名字、一个标志位、放在一个小结构体里的增量),把它们与标识符一同传过去,让接收方 actor 用这个标识符从它自己的 context 重新取出模型:

// 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 上被加载的关系和惰性加载属性,会对着错误的 context 解析(或根本无法解析),随之而来的 bug 是那种悄无声息的类型。标识符加上取出的值才是安全的契约;对象图不是。一个 ModelActor 通过持有一个 context、对外提供值而非实例,把这份纪律封装了起来。7

CloudKit 同步与 app group 权限的陷阱

把一个 SwiftData 存储移入一个 app group 容器,好让 widget 或 extension 能读取它,会以一种在应用发布后才咬人的方式与 CloudKit 同步相互作用。两个事实让其余一切随之而来。

首先是存储位置。使用默认的 ModelConfiguration 时,当应用从无 group 演进到有 app group,SwiftData 会替你把既有存储复制进 app group 容器;Apple 的措辞是 SwiftData”把既有存储复制到 app group 容器”。8 使用自定义存储 URL 时,位置归你管:你自己把文件复制到新容器,并把配置指向它。默认路径之所以方便,恰恰是因为框架做了这次复制;自定义路径用这份方便换取了控制权。

其次是权限(entitlement)。每一个读取 CloudKit 同步存储的 app group 成员都必须携带相同的 CloudKit 权限,因为这些进程中的每一个都会以自身的名义同步那个容器。这个要求就是陷阱:一个 widget 或 extension 既没有运行时预算,也没有前台窗口去驱动一次冗长的同步,而把 CloudKit 权限交给它,等于强迫它去尝试。修复办法是拆成两个 ModelConfiguration 实例:一个同步存储(带 CloudKit 权限,由主应用持有)和一个位于 app group 容器中的本地存储,让 widget 和 extension 读取它而从不同步。把同步放在前台应用能做好它的地方,把供读取共享的数据排除在同步路径之外。8

如果重来我会怎么构建

cluster 里的应用们要么已经在发布、要么但愿当初发布了的三个模式。

从 v1 就发布 VersionedSchema 每一个要发布的 @Model 类都应当从第一天起就活在一个 VersionedSchema 里。代价是每个 schema 版本一个包裹用的 enum。好处是 v2 第一次非平凡的变更只是往 MigrationPlan.schemas 加一行,而不是一次为期两天的回溯式重构。

让每一个时间戳都可选。lastModifiedcreatedAtupdatedAt 这些为跨设备同步或冲突解决而存在的字段,如果 v1 产品不需要它们,就应当在 v1 里设为可选。可选性让迁移到 v2(当你确实需要它们时)变得廉价。在 didMigrate 期间为既有行填充它们只是一个循环;从 v1 就把它们设为非可选,则是一个可能在用户数据上把回填搞崩的约束。

用 UUID 作为自然键,而不是 PersistentIdentifier SwiftData 的 PersistentIdentifier 是进程内的。跨设备同步、MCP 集成(见 两个 Agent 生态,一份购物清单)以及任何进程外引用,都需要一个稳定的标识符。带 @Attribute(.unique)UUID 是正确的形态;对于任何跨越进程边界的东西,进程内的 PersistentIdentifier 都是错误的形态。

什么时候 @Model 是错误答案

SwiftData 不是合适工具的三种情形:

单条记录的键/值状态。 应用设置、用户选定的语言、上次同步的时间戳。请用 UserDefaultsNSUbiquitousKeyValueStore(见 五个 Apple 平台,三个共享文件)。SwiftData 为单行付出的开销是一种浪费的仪式感;键值存储才是合适的底料。

没有离线写入的服务器权威数据。 一份从 REST API 取来、只读展示的列表。如果真相之源是服务器、本地缓存只是缓存,那么 SwiftData 就是杀鸡用牛刀。Documents/ 里一个简单的 Codable 快照加上一个内存缓存数组就够了;如果数据本就不必熬过一次硬重置,那么 SwiftData 的迁移税就不值得付。

多进程协调。 SwiftData 在一个进程内部运作。一个跑在 iOS 应用之外的 MCP server 无法读写该应用的 SwiftData 容器。跨进程状态需要一种不同的形态:一个 iCloud Drive JSON 文件、一个共享的 App Group 容器,或一个在进程之间架桥的显式同步层。(Get Bananas 把 SwiftData 与 iCloud Drive JSON 配对,正是出于这个原因。)6

数据是很少变动的大型二进制块。 一个 10MB 的音频文件、一个 50MB 的图片数据集。如果这些二进制块在 SwiftData 行内部,就用 @Attribute(.externalStorage);否则直接用文件系统,并在 SwiftData 里用指向文件 URL 的元数据。

这个模式对在 iOS 26+ 上发布的应用意味着什么

三点收获。

  1. 宏是容易的部分。迁移才是成本。 @Model@Attribute 是隐藏了大量 Core Data 管道的两行声明。迁移纪律才是你在应用整个生命周期里真正要付的;设计 v1 时就要想着 v2。
  2. 对要发布的应用而言,从第一天就用上 VersionedSchema 没得商量。 那个包裹用的 enum 只是多一个文件。日后才补上它的回溯成本要高得多。
  3. 可选字段和显式关系是廉价的保险。 同步元数据用可选时间戳,关系上用显式的 deleteRuleinverse:。两者都是微小的声明,却换来大量 v2 灵活性。

完整的 Apple 生态 cluster:面向 Apple Intelligence 的带类型 App Intents;面向跨 LLM agent 的 MCP server;二者之间的路由问题;面向端上 LLM 与 Tool 协议的 Foundation Models;面向 iOS 锁屏状态机的 Live Activities;Apple Watch 上的 watchOS 运行时契约;面向框架底料的 SwiftUI 内部机制;面向 visionOS 场景的 RealityKit 空间心智模型;面向视觉层的 Liquid Glass 模式;面向跨设备覆盖的多平台发布。中枢在 Apple 生态系列。想要更广阔的 iOS-加-AI-agent 背景,请参阅 iOS Agent 开发指南

FAQ

@Model 和 Core Data 的 NSManagedObject 有什么区别?

@Model 是一个 Swift 宏,它在底层生成 NSManagedObject 管道。SwiftData 把 Core Data 用作其后备存储,所以运行时模型是相同的;区别在于表面层。@Model 去掉了 .xcdatamodeld 文件、value-transformer 的仪式感,以及 NSManagedObjectContext 的生命周期管理。你得到的是同一个持久化存储,配上一套 Swift 风格的 API。

如果我从不打算改 schema,还需要 VersionedSchema 吗?

如果你的应用可能发布 v2,需要。如果它是一次性的演示,不需要。从 v1 就用上 VersionedSchema 的代价是一个多出来的 enum 声明。在 v2 时回头补上它的代价,是匹配确切的 v1 schema 形态以便框架识别既有数据,这能做到但容易出错。大多数要发布的应用最终都会需要一次 schema 变更;在 v1 里就为它留好预算。

我应该什么时候用 @Attribute(.unique)

当该字段是这一行的自然键时:你生成的一个 UUID、你导入的一个外部 ID、你指派的一个 slug。SwiftData 把 .unique 当作 upsert 处理:如果你插入一个其 .unique 值已存在的模型,既有行会被更新,而不是追加一行新的。正是这套语义让 upsert 风格的同步路径(同一个 UUID 从两台设备到来)变得安全;这也是为什么 .uniquetitle 这类展示名字段是错误的工具,因为两个用户敲入同一个标题会悄然合并他们的行,而不是产生两条不同的记录。

我该如何处理向既有 schema 新增的非可选字段?

用一个带 didMigrate 闭包的 MigrationStage.custom,在闭包里为既有行填充该字段。或者,更简单的办法:在新的 schema 版本里把该字段声明为可选,并在访问时惰性填充它。可选性是更廉价的迁移;新增非可选字段需要显式的填充逻辑。

PersistentIdentifier 与我自己的 UUID 有什么区别?

PersistentIdentifier 是 SwiftData 的进程内行 ID;它自动生成,并在运行中的进程的生命周期内有效。你自己的、带 @Attribute(.unique)UUID 是一个稳定的跨进程、跨设备标识符。在应用内部的进程内引用上用 PersistentIdentifier。对任何跨越进程边界的东西(跨设备同步、外部集成、MCP 工具、网络调用)用 UUID。

References


  1. 作者的 Get Bananas,一款把 SwiftData 与 iCloud Drive JSON 同步及一个 MCP server 配对的 SwiftUI 购物清单应用。ShoppingItem 模型在早期开发周期里几经演进;lastModified: Date? 字段是在最初的 schema 之后才加上的(2025-12-01 的提交 268a00d,”Make lastModified optional to fix migration crash”),因为把它设为非可选会在既有行没有值可填充时破坏迁移。 

  2. Apple Developer,“SwiftData”“Adding and editing persistent data in your app”@Model 宏、@Attribute 约束表面,以及与 Core Data 的 NSManagedObjectModel 的关系。 

  3. Apple Developer,“Preserving your app’s model data across launches”“Adopting SwiftData for a Core Data app”。轻量迁移语义,以及什么会触发框架放弃。 

  4. Apple Developer,“VersionedSchema”“SchemaMigrationPlan”。版本化 schema 声明、迁移阶段定义,以及接收迁移计划的 ModelContainer 构造器。 

  5. Apple Developer,“Defining data relationships with enumerations and model classes”“Schema.Relationship”@Relationship 宏、deleteRule 选项(.cascade.nullify.deny.noAction),以及 inverse: 参数在双向关系维护中的作用。 

  6. 作者在 两个 Agent 生态,一份购物清单(2026 年 4 月 29 日)与 五个 Apple 平台,三个共享文件 中的分析。Get Bananas + Return 的跨进程、跨设备同步模式,在一个多进程工作流里补足(有时也替代)SwiftData。 

  7. Apple Developer,“PersistentIdentifier”(遵从 Sendable)与 “ModelActor”。SwiftData 团队在 WWDC 2026 SwiftData Group Lab 期间确认,@Model 对象不是 Sendable,也不应被强行让它遵从,因为它们是活在一个 context 内部的引用图;推荐的边界契约是传递 SendablePersistentIdentifier 加上取出的纯值,并在目的地 context 上重新取数据,而传递模型对象图会让接收方拿到一个部分填充的对象。转述自 WWDC 2026 SwiftData Group Lab 的本地转录录音;Apple 未为这些 lab 发布官方字幕。 

  8. Apple Developer,“Adopting SwiftData for a Core Data app”,其中指出使用默认配置时”SwiftData 把既有存储复制到 app group 容器”,而自定义存储 URL 则把位置留给你来管理。app group 成员的 CloudKit 权限要求,以及为把 widget 和 extension 排除在同步路径之外而做的两个 ModelConfiguration 拆分(一个同步、一个本地),是在 WWDC 2026 SwiftData Group Lab 期间描述的。转述自 WWDC 2026 SwiftData Group Lab 的本地转录录音;Apple 未为这些 lab 发布官方字幕。 

相关文章

SwiftData 迁移:轻量级与自定义,以及何时你并不需要 V2

SwiftData 的迁移模型使用 VersionedSchema、MigrationStage 和 SchemaMigrationPlan。大多数 schema 变更并不需要 V2 schema;而真正需要的那些场景,确实需要。

5 分钟阅读

iOS 27 中的 SwiftData:观察与历史记录

iOS 27 为 SwiftData 带来一等的变更观察能力:用 ResultsObserver 跟踪变更、用 HistoryObserver 观察持久化历史,并支持 codable 属性存储。

4 分钟阅读

清理层才是真正的AI智能体市场

Charlie Labs从构建智能体转向清理智能体留下的烂摊子。AI智能体市场正从生成转向证明。清理才是持久的层级。

2 分钟阅读