← 所有文章

SwiftData的真正成本是模式纪律

类型: shipped-code。本文记录了Get Bananas、Return和Reps三款应用中的SwiftData模式决策:这三款应用的模式要么经历了一次干净的迁移,要么因未规划迁移而付出了代价。Get Bananas的ShoppingItem是典型范例。最初的模式不包含lastModified时间戳;后来添加它需要特定的迁移形态,因为现有数据已经在磁盘上,并且该字段被特意设为可选,以修复最初将其作为非可选添加时引发的迁移崩溃。1

SwiftData的API是两个宏。在类上加@Model使其成为持久化类型。在属性上加@Attribute(.unique)赋予其唯一性约束。该框架隐藏了Core Data的栈管理、值转换器编排以及NSManagedObjectContext样板代码。框架不会隐藏的是模式迁移;它只是让迁移变成声明式而非命令式。不关注迁移的代价就是常规更新中清除用户数据的那个bug。

核心论点:SwiftData启动便宜,但草率迁移代价高昂。纪律在于命名、可选性以及从第一天就使用VersionedSchema,而不是在你意识到应该这么做的那一天。

TL;DR

  • @Model宏将一个类变成持久化的SwiftData类型。框架在编译时根据属性声明生成模式。
  • 添加新的可选属性是无操作迁移:SwiftData的轻量级迁移会处理它。向现有模式添加非可选属性则需要一个VersionedSchema加上一个MigrationPlan,告诉框架如何为现有行填充新字段。
  • 从第一天就跳过VersionedSchema的代价是:任何非平凡的v2模式更改都有可能丢弃用户的数据库,因为轻量级路径是保守的,在无法推断迁移时会放弃。
  • @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不需要单独的持久化存储模式声明。 SwiftData在编译时读取类定义并合成模式。类的属性成为模型的属性;它们的Swift类型成为列类型。没有需要维护的.xcdatamodeld文件(不过Core Data底层的NSManagedObjectModel仍然存在,并且在运行时支撑该模式)。2

@Attribute(.unique)是单列上的约束,而不是PRIMARY KEY声明。 SwiftData的持久化身份是PersistentIdentifier,由系统为每行自动生成。@Attribute(.unique)声明告诉框架”此列每个值最多存储一行”。当你插入一个.unique值已存在的模型时,SwiftData执行upsert:现有行被更新,而非被拒绝。这个语义对于产品代码很关键:.unique不是阻止重复提交的UI层验证;它是悄悄合并的至多一条存储保证。上面的id: UUID模式是跨进程同步的推荐形式(你需要一个稳定的标识符,能在进程内的PersistentIdentifier消失后依然存活),而upsert行为正是当同一UUID从两条同步路径到达时你想要的。

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

可选字段是廉价的迁移

ShoppingItem上的lastModified: Date?字段是可选的,而这个可选性至关重要。该字段在v1发布之后被添加,用于支持跨设备同步和冲突解决;用户设备上的现有行没有lastModified值。一个没有默认值的可选字段让SwiftData的轻量级迁移在无需编写任何迁移代码的情况下处理添加:现有行得到nil;新行得到init所设置的任何值。3

轻量级迁移路径是框架的礼貌路径。SwiftData检查新模式和持久化存储,推断最小兼容的更改,并应用它。迁移是自动的;用户什么都看不到;应用在现有数据上正常启动。轻量级路径能干净处理的情况:

  • 添加可选属性
  • 移除属性(数据被丢弃;现有读取不再看到该列)
  • 重命名框架可通过提示匹配的属性(使用@Attribute(originalName: ...)
  • 重命名框架可匹配的@Model类(使用@Model.originalName或提示)

轻量级路径会放弃的情况:

  • 向现有模式添加无默认值的非可选属性(现有行没有值可填充)
  • 更改属性的类型(例如,IntString
  • 将一个模型拆分为两个,或将两个合并为一个
  • 任何需要自定义逻辑来迁移的情况

当轻量级路径放弃时,安全的行为是让迁移失败。不安全的行为是丢弃数据库重新开始;框架是保守的,拒绝默默这样做。用户看到应用启动时崩溃并报迁移错误;开发者看到指向模式不匹配的堆栈跟踪;没有人丢失数据,但每个人都失去信心。

从第一天就跳过VersionedSchema的代价会在v2 → v3的边界显现,那时你添加第三个特性,其模式更改超出了轻量级路径的处理能力。

VersionedSchema与MigrationPlan:第一天的纪律

VersionedSchema声明模型模式的特定版本。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)
    ]
}

模型类本身移动到版本化模式命名空间内:

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

迁移计划为框架提供了一个类型化的图,描述模式如何演化。当v2版的应用对v1数据库启动时,框架沿着迁移计划走,应用所命名的阶段,并把数据库带到v2。当你发布v3时,你向schemas添加SchemaV3.self并在v2与v3之间添加新的MigrationStage

纪律是即使只有一个版本,也要在v1中发布VersionedSchema。这样做的成本是一个额外的文件和一个额外的enum声明。这样做的成本是:v2的第一次非平凡模式变更需要追溯地把v1包装在VersionedSchema中,这是可行的,但需要小心地匹配v1的精确形态,以便框架能将现有数据识别为SchemaV1。未来在v2上工作的你将支付这笔税;现在的你可以一次性支付然后忘掉它。

用于困难情况的Custom 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模式运行;didMigrate针对v2模式运行。闭包体是普通的SwiftData代码(fetch描述符、模型上下文保存、运行中应用所用的相同API),针对一个临时的迁移中上下文运行。

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

@Attribute@Relationship何时名副其实

两个宏在@Model类中完成大部分模式装饰工作。

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

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

正确的纪律:对真正应该唯一的字段使用.unique(你生成的UUID、外部ID),对超过几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。

我会有什么不同的构建方式

集群中的应用要么实施了,要么希望实施的三个模式。

从v1开始就发布VersionedSchema 每个发布的@Model类从第一天起都应该位于VersionedSchema内部。成本是每个模式版本一个包裹的enum。好处是v2的第一个非平凡变更只是向MigrationPlan.schemas添加一行,而不是为期两天的追溯重构。

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

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

@Model什么时候是错误答案

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

单记录键/值状态。 应用设置、用户选择的语言、上次同步的时间戳。使用UserDefaultsNSUbiquitousKeyValueStore(参见五个Apple平台,三个共享文件)。SwiftData对单行的开销是浪费的仪式;键值存储是正确的底层。

服务器权威数据,无离线写入。 从REST API获取并只读显示的列表。如果真相之源是服务器,本地缓存只是缓存,那么SwiftData是过度设计。一个简单的Documents/下的Codable快照加上内存缓存数组就够了;如果数据在硬重置后无需保留,那么SwiftData的迁移税不值得支付。

多进程协调。 SwiftData在进程内运行。在iOS应用之外运行的MCP服务器无法读取或写入应用的SwiftData容器。跨进程状态需要不同的形态:iCloud Drive JSON文件、共享App Group容器,或者一个桥接进程的显式同步层。(Get Bananas正是出于这个原因将SwiftData与iCloud Drive JSON配对。)6

数据是很少变化的大型二进制对象。 一个10MB的音频文件、一个50MB的图像数据集。如果二进制对象在SwiftData行内,使用@Attribute(.externalStorage);否则直接使用文件系统,把指向文件URL的元数据放在SwiftData里。

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

三个要点。

  1. 宏是简单的部分。迁移才是成本。 @Model@Attribute是两行声明,隐藏了大量Core Data管道。迁移纪律才是你在应用生命周期里实际支付的;设计v1时要心怀v2。

  2. 从第一天起的VersionedSchema对发布应用是不可妥协的。 包裹的enum只是一个额外文件。后期添加的追溯成本要高得多。

  3. 可选字段和显式关系是廉价的保险。 用于同步元数据的可选时间戳、关系上的显式deleteRuleinverse:。两者都是微小的声明,却换来大量v2的灵活性。

完整的Apple生态集群:用于Apple Intelligence的类型化App Intents;用于跨LLM智能体的MCP服务器;二者之间的路由问题;用于设备端LLM和Tool协议的Foundation Models;用于iOS锁屏状态机的Live Activities;Apple Watch上的watchOS运行时契约;作为框架底层的SwiftUI内部;用于visionOS场景的RealityKit的空间心智模型;用于视觉层的Liquid Glass模式;用于跨设备覆盖的多平台发布。中心位于Apple生态系列。要了解更广泛的iOS与AI智能体的语境,请参阅iOS智能体开发指南

FAQ

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

@Model是一个Swift宏,在底层生成NSManagedObject管道。SwiftData使用Core Data作为其支撑存储,因此运行时模型是相同的;区别在于表面。@Model移除了.xcdatamodeld文件、值转换器仪式以及NSManagedObjectContext生命周期管理。你得到的是同一个持久化存储,但有一个Swift形态的API。

如果我从不打算改变模式,需要VersionedSchema吗?

如果你的应用可能发布v2,那就需要。如果它是一次性演示,那就不需要。从v1开始就有VersionedSchema的成本是一个额外的enum声明。在v2追溯添加它的成本是匹配精确的v1模式形态,以便框架识别现有数据,这是可行但容易出错的。大多数发布的应用最终都会需要模式变更;在v1中为它预算。

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

当字段是行的自然键时:你生成的UUID、你导入的外部ID、你分配的slug。SwiftData将.unique视为upsert:如果你插入一个.unique值已存在的模型,现有行会被更新,而不是追加新行。这种语义是upsert风格同步路径(同一UUID来自两台设备)安全的原因;这也是为什么.unique对于像title这样的显示名字段是错误工具,因为两个用户键入相同标题会悄悄合并他们的行,而不是产生两条不同的记录。

我如何处理向现有模式添加的非可选字段?

使用带有didMigrate闭包的MigrationStage.custom,在现有行上填充该字段。或者,更简单:在新模式版本中将该字段声明为可选,并在访问时惰性填充。可选性是更便宜的迁移;非可选添加需要显式的填充逻辑。

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

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

参考资料


  1. 作者的Get Bananas,一款将SwiftData与iCloud Drive JSON同步以及MCP服务器配对的SwiftUI购物清单应用。ShoppingItem模型在早期开发周期中演化;lastModified: Date?字段在初始模式之后被添加(提交268a00d,日期2025-12-01,”将lastModified设为可选以修复迁移崩溃”),因为将其设为非可选会在现有行没有值可填充时破坏迁移。 

  2. Apple Developer,“SwiftData”“在应用中添加和编辑持久化数据”@Model宏、@Attribute约束表面以及与Core Data的NSManagedObjectModel的关系。 

  3. Apple Developer,“跨启动保留应用的模型数据”“为Core Data应用采用SwiftData”。轻量级迁移语义以及触发框架放弃的因素。 

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

  5. Apple Developer,“用枚举和模型类定义数据关系”“Schema.Relationship”@Relationship宏、deleteRule选项(.cascade.nullify.deny.noAction)以及inverse:参数在双向关系维护中的角色。 

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

相关文章

Two Agent Ecosystems, One Shopping List: An MCP Server Living Alongside an iOS App

Get Bananas runs on iOS, macOS, watchOS, visionOS. It also lives inside Claude Desktop as an MCP server. The bridge is i…

19 分钟阅读

Foundation Models On-Device LLM: The Tool Protocol

iOS 26's Foundation Models framework puts a 3B-parameter LLM on every Apple Intelligence device. The Tool protocol is th…

15 分钟阅读

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 分钟阅读