SwiftData 的性能问题本质上是存储问题
WWDC 2026 的 SwiftData 小组实验室由负责框架底层各个层级的工程师坐镇,其中包括在 Apple 各平台上维护 SQLite 的工程师,以及 Core Data 和 SwiftData 的负责人。他们回答的主线是对大多数开发者追求性能方式的一次有益纠正:一旦 SwiftData 进入您的应用,真正昂贵的是 I/O,而不是您的 Swift 代码,性能提升来自少读取数据、理解存储引擎,而非增加并发。下文大部分内容都以 Apple 和 SQLite 的官方文档为依据;凡是属于实验室工程推理而非有文档佐证的论断,都会专门标注。
WWDC 2026 SwiftData 小组实验室。
摘要
- SwiftData 的 SQLite 存储默认使用 write-ahead logging(WAL),这意味着多个读取者可以与单个写入者并发运行。它并不是读/写锁——实验室指出,开发者在这一点上经常搞错。23
- 在不实例化对象的前提下读取:
fetchCount(_:)返回匹配数量,fetchIdentifiers(_:)返回[PersistentIdentifier],两者都不会加载模型对象。将它们与历史观察配合使用,可以判断是否真的需要刷新。45 @Model对象不是Sendable,也不应强行让它们成为Sendable。要跨越 actor 边界,应传递PersistentIdentifier(它是Sendable)以及任何提取出来的值,然后在目标上下文中重新获取。6- SwiftData 没有与 Core Data 下推到 SQL 的聚合查询(sum、average、min、max)等价的能力。变通方案是共存:让一个 Core Data 栈针对同一个存储文件运行,由它来计算聚合值。8
- 实验室的性能建议是:在断定数据库慢之前,先做性能分析,弄清楚 SwiftUI 为什么会重新获取数据,因为视图过度失效看起来像 I/O 问题,但其实不是。110
WAL:并发读取者、单个写入者,而非锁
实验室带来的最有价值的纠正,与存储层的并发有关。SwiftData 构建在 Core Data 的 SQLite 存储之上,而该存储从 iOS 7 起就默认采用 write-ahead logging。3 在 WAL 下,正如 SQLite 文档所述:“WAL 提供了更高的并发性,因为读取者不会阻塞写入者,写入者也不会阻塞读取者。读取与写入可以并发进行。”2 同一时刻仍然只有一个写入者,但那种“用互斥锁串行化所有数据库访问”的心智模型是错误的:您的读取不必排在写入之后等待。
实验室的表述(根据录音转述)是:人们把单一写入者规则当成了读/写锁,围绕一个并不存在的约束去做架构设计。1 准确的模型是 WAL 模型:按照多个并发读取加上一个串行化写入来设计,而不是按照全局互斥来设计。
少读取:在不加载对象的前提下计数和识别
如果成本在于 I/O,那么收益最高的做法就是不再加载您并不需要的对象。SwiftData 为此提供了两个原语,二者都已在 API 中得到验证:
ModelContext 上的 fetchCount(_:) 接受一个 FetchDescriptor,以 Int 形式返回匹配模型的数量,而不会实例化其中任何一个。4 当您需要为徽标或分区标题获取一个计数时,这严格优于先获取再调用 .count。
fetchIdentifiers(_:) 针对一个描述符返回 [PersistentIdentifier],同样不会加载模型对象;还有一个 fetchIdentifiers(_:batchSize:) 重载可以对这项工作进行分批。5 实验室建议的用法(转述)是将其与历史观察配合:当变更到来时,获取受影响的标识符,并与视图实际显示的内容进行比对,然后再决定是否需要重新加载任何东西。1 历史和观察 API 本身在 SwiftData 的 iOS 27 观察与历史中有所介绍;fetchIdentifiers 则是让它们变得高效的轻量级读取手段。在 SwiftUI 之外可以采用的观察类型是 ResultsObserver,这是为 2027 年的版本引入的、基于 Swift Observation 的观察器,它支持与 @Query 相同的原语,包括通过 sectionBy: 进行的键路径分区。9
Sendable 边界真实存在,而模型图并不会跨越它
SwiftData 模型是引用类型,在其上下文内部连成一张图,而且它们不是 Sendable。实验室直言不讳:您无法理智地强行让它们变成 Sendable,因为这张图不是线程安全的,而在另一个 actor 上部分加载它会带来麻烦。1 受支持的模式是使用 PersistentIdentifier 作为跨越边界时移动的身份标识,它是 Sendable、Hashable 和 Codable 的。6 把您需要的值提取到一个结构体里,附上 PersistentIdentifier,把它交给另一个 actor,如果您需要活动对象,就在目标上下文中重新获取模型。
有一个值得记住的精确细节:Apple 指出,解码得到的 PersistentIdentifier 与默认存储创建的 PersistentIdentifier 并不总被视为等价,因此应把该标识符当作一个稳定的跨上下文句柄,而不要假设解码出来的副本就等于一个活动对象。6
同样的“传身份而非传图”的纪律,也体现在跨进程的场景中。当您把一个存储移入某个 app group 以便与小组件或扩展共享时,默认配置会替您把现有存储复制到 app group 容器中;若使用自定义存储 URL,则由您自己管理位置。7 无论哪种方式,各个进程都是通过存储及其标识符来协调的,而不是在彼此之间传递活动对象。
聚合查询的缺口,以及 Core Data 这个逃生口
实验室点名的一项真实限制:SwiftData 没有与 Core Data 基于 NSExpression 的聚合查询等价的能力——后者会把 sum、average、min 和 max 下推到 SQLite,让数据库在不加载行的情况下完成计算。8 在 SwiftData 中,您只能获取这些行再在内存里做归约,这在大表上就失去了意义。对于 min 或 max,您可以用一个排序描述符加上等于 1 的获取上限来获取;而对于真正的聚合,实验室指向了共存方案。
正如 Apple 在 WWDC 2023 上所阐述的,共存就是“两个完全独立的持久化栈,一个 Core Data 栈和一个 SwiftData 栈,它们与同一个持久化存储对话”。8 两个栈都指向同一个存储 URL;由于 SwiftData 会自动启用持久化历史跟踪,Core Data 一侧也必须启用 NSPersistentHistoryTrackingKey,否则该存储只能以只读方式打开。8 配置妥当之后,您就可以让 Core Data 针对 SwiftData 所拥有的那个文件运行下推到 SQL 的聚合查询。这比大多数应用所需的机制要复杂,但当您确实需要数据库侧聚合时,它就是有文档可循的路径。
分析失效,而不只是分析数据库
实验室最实用的性能指导(转述)是:一个 SwiftData 应用表面上的 I/O 成本,往往是伪装起来的 SwiftUI 失效问题——一个失效过于频繁的视图会重新获取数据,而性能分析器会把这次重新获取显示为数据库耗时,可真正的症结在于这个视图根本就不该刷新。1 修复方法与帮助解决任何 SwiftUI 性能问题的视图隔离纪律是一样的,在 SwiftUI 性能与互操作中有所介绍:把大视图拆分成依赖范围更窄的小视图,并把已经获取好的模型向下传递,以免查询重新运行。
工具链支持这种判读。Instruments 提供了一个 SwiftUI 模板,把 SwiftUI instrument 与 Hangs and Hitches instrument 捆绑在一起;一个 File Activity 模板,其 Reads and Writes instrument 会显示真实的磁盘流量(仅限真机,不包括模拟器);还有 Core Data 模板,其 Data Persistence instrument 会报告错误页、获取和保存。10 把 SwiftUI 视图和持久化视图放在一起运行,能告诉您一次重新获取究竟是真实的读取,还是由过度失效触发的多余读取。
实验室就基准测试提出的一个告诫(转述):从上到下到处都是缓存——SQLite 的页缓存、操作系统的文件缓存,以及存储控制器,因此一次“很快”的运行可能是命中缓存,而不是真正的改进。请针对一个现实中足够大的数据集来测量,并使用 File Activity instrument 来确认确实发生了真实的 I/O。1
关于增加并发
实验室最强烈的一条意见——也是应当当作工程推理而非文档事实来看待的部分——是告诫不要把并发当作性能修复手段。工程师们形容 SwiftData 的连接池是刻意设有上限的,并主张一旦超过少量并发操作,您就会触及存储硬件的天花板,因此更多的上下文换来的是收益递减,代价却是更多的内存和更多的 I/O。1 Apple 并没有给出具体的并发上限的文档,所以不要从任何人那里(包括本文)接受一个硬性数字。站得住脚的结论是方向性的:在闪存设备上,堆砌并发写入者并不是可靠的提速方式,而 WAL 模型已经免费为您提供了并发读取。
从中应当汲取什么
实验室围绕存储引擎重新构建了对 SwiftData 性能的理解。经过验证的杠杆都很具体:依靠 WAL 的并发读取,而不是惧怕一把锁;使用 fetchCount 和 fetchIdentifiers 来避免加载对象;跨 actor 移动 PersistentIdentifier 而不是模型图;在您需要一个真正的聚合时求助于 Core Data 共存。性能分析的纪律则是:在优化数据库之前,先确认 I/O 成本确实存在,因为罪魁祸首往往是一个本不该刷新却刷新了的视图。
常见问题
SwiftData 在写入期间会锁定数据库吗?
不会(就读/写锁这个意义而言)。该存储使用 SQLite write-ahead logging,允许多个读取者与单个写入者并发运行;读取不会阻塞写入者,写入者也不会阻塞读取。23 同一时刻只有一个写入者,但读取会与之并行进行。
我该如何在不加载记录的情况下对其计数或检查?
使用 ModelContext.fetchCount(_:) 获取匹配数量,使用 ModelContext.fetchIdentifiers(_:) 获取 [PersistentIdentifier] 值,两者都不会实例化模型对象。45 把 fetchIdentifiers 与历史观察结合起来,可以在重新加载之前判断某次变更是否真的影响到视图所显示的内容。
我该如何把一个 SwiftData 对象传递给另一个 actor?
您不要传递对象。@Model 类型不是 Sendable。请传递 PersistentIdentifier(它是 Sendable)以及任何提取出来的值,然后在目标上下文中重新获取。6 不要把活动的模型图跨越边界交出去。
SwiftData 能在数据库里做 sum/average/min/max 吗?
不能。SwiftData 没有与 Core Data 基于 NSExpression 的 SQL 下推聚合等价的能力。8 对于 min/max,可用一个排序加上等于 1 的获取上限来获取;对于真正的聚合,则针对同一个存储文件运行一个 Core Data 栈(共存),这需要匹配存储 URL,并在 Core Data 一侧启用持久化历史跟踪。8
本博客的 SwiftData 系列在 schema 纪律和迁移指南中介绍了 schema 与迁移纪律,在观察与历史中介绍了 iOS 27 的观察和历史 API。本文补充了性能与存储这一层。完整的系列主页是 Apple 生态系统系列。
参考文献
-
Apple, WWDC 2026 session 8017, SwiftData Group Lab. 根据本地转录的录音转述;Apple 未为这些实验室发布官方字幕,因此此处的措辞是转述而非引用,确切的表述未经核实。它是以下内容的来源:读/写锁误解的表述、将
fetchIdentifiers与历史结合用于刷新门控的建议、@Model非 Sendable 的传递指导、视图失效伪装成 I/O 的观点、“从上到下到处都是缓存”的基准测试告诫,以及连接池/并发天花板的立场(这属于实验室的工程推理,而非有文档记载的行为;此处不主张任何具体的并发数字,因为 Apple 并未给出相关文档)。 ↩↩↩↩↩↩↩ -
SQLite, Write-Ahead Logging. WAL 并发模型的来源:“WAL 提供了更高的并发性,因为读取者不会阻塞写入者,写入者也不会阻塞读取者”,同一时刻只有一个写入者。 ↩↩↩
-
Apple, Technical Q&A QA1809: Setting the SQLite journaling mode for a Core Data store. 用以说明 write-ahead logging 自 iOS 7 和 OS X Mavericks 起就是 Core Data SQLite 存储默认日志模式的来源;SwiftData 构建在 Core Data 的 SQLite 存储之上。 ↩↩↩
-
Apple,
ModelContext.fetchCount(_:). 签名func fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModel;返回与描述符匹配的模型数量,而不会将它们实例化。 ↩↩↩ -
Apple,
ModelContext.fetchIdentifiers(_:)和fetchIdentifiers(_:batchSize:). 针对一个 fetch descriptor 返回[PersistentIdentifier]而不加载模型对象,并带有一个分批的重载。 ↩↩↩ -
Apple,
PersistentIdentifier. SwiftData 模型的聚合身份标识;它是Sendable、Hashable和Codable的,使其成为跨 actor 边界移动时应使用的类型。Apple 指出,解码得到的PersistentIdentifier与默认存储创建的PersistentIdentifier并不总被视为等价,因此应将其当作一个稳定的跨上下文句柄。 ↩↩↩↩ -
Apple, Adopting SwiftData for a Core Data app. 用以说明 app group 行为的来源:当一个应用演进为使用 app group 容器时,在默认配置下 SwiftData 会把现有存储复制到 app group 容器中;若使用自定义存储 URL,则由您自己管理位置。 ↩
-
Apple, WWDC 2023 session 10189, Migrate to SwiftData, 和
NSExpression. 用以说明共存(“两个完全独立的持久化栈,一个 Core Data 栈和一个 SwiftData 栈,与同一个持久化存储对话”)、二者必须使用相同存储 URL 且 Core Data 栈必须启用NSPersistentHistoryTrackingKey(否则存储只能以只读方式打开)的要求,以及 Core Data 基于NSExpression的 SQL 聚合(SwiftData 并未提供与之等价的能力)的来源。 ↩↩↩↩↩↩ -
Apple, WWDC 2026 session 274, What’s new in SwiftData. 用以说明
ResultsObserver的来源——这是基于 Swift Observation 的观察类型,支持与@Query相同的原语,包括通过sectionBy:进行的键路径分区,将随 2027 年的平台版本发布。 ↩ -
Apple, WWDC 2025 session 306, Optimize SwiftUI performance with Instruments, 以及 Instruments 的 File Activity 和 Core Data 模板。用以说明以下内容的来源:SwiftUI Instruments 模板(将 SwiftUI instrument 与 Hangs and Hitches instrument 捆绑在一起)、File Activity 模板的 Reads and Writes instrument(仅限真机),以及报告错误页、获取和保存的 Data Persistence instrument。 ↩↩
