SwiftData:优化从建模开始

数据集一大,SwiftData 的列表就开始卡。这是这几年里我听到最多的性能抱怨。

开发者的第一反应通常会指向 SwiftUI 的 List。它确实有责任,但并非最主要的问题。真正花时间之后会发现,慢的不只是一开始加载时间长,巨大的内存占用同样会把性能拖垮。本文将聊聊在 SwiftData 中为什么会出现这些问题,以及该从哪里下手改善。

一个很常见的写法

不少应用会自然地写成这样,官方示例也差不多:

Swift
@Model
final class Note {
    var title: String
    var createdAt: Date
    var isPinned: Bool
    var body: String          // 进详情才看
    var attachment: Data      // 进详情才看
}

struct NoteListView: View {
    @Query(sort: \Note.createdAt)
    private var notes: [Note]

    var body: some View {
        List(notes) { note in
            NoteRow(title: note.title)
        }
    }
}

往往列表的 cell 里只需显示标题、日期、置顶状态,正文和附件要到详情页才用。但按日常的 Swift 习惯,把它们放在同一个类型里最省事。数据量小的时候,这样写完全没有问题。数据量上到几千甚至上万,进入这个视图就会明显卡顿。

缺失的惰值机制

ListLazyVStack 在画出可见行之前,要先给整份数据建立标识(identity)。这一步针对的是全部数据,不只是屏幕上那十几行。LazyVStack 还要估算整份内容的高度;List 相对克制,但只要在行上用了 .id(...),懒加载就会被拆掉。

这些是 SwiftUI 自己的问题。更完整的说明见 优化在 SwiftUI List 中显示大数据集的响应效率List 还是 LazyVStack惰性容器的技巧和注意事项

不过,把锅全推给 List 并不公平。同样一套 SwiftUI 机制,Core Data 在面对同样结构、规模的数据集时,进入列表仍然要比 SwiftData 快很多。这是因为我们通常会为 ForEach 提供 objectID 作为标识,读取该标识并不会让数据被填充,绝大多数对象还停在惰值(fault)状态上。

但 SwiftData 没有提供惰值机制。公开的 API 里没有 isFault,也没有 returnsObjectsAsFaultsfetch@Query 返回的都是 [T]T 必须是 PersistentModel。查询一结束,符合条件的那些记录,主模型上的存储属性就会立刻被读进内存,变成可以访问的模型实例。

我用一份很普通的 SwiftData 数据做了对照:标题大约 48 字节,正文 8 KB,附件 32 KB,3000 条。每次用新的 ModelContext,并且关掉未保存的内存变更。只把数组取回来、一个属性都不读,大约 0.30 秒,内存涨了约 176 MB;接着只读 persistentModelID,大约 0.33 秒;把正文和附件也读一遍,大约 0.35 秒。三次几乎一样。

列表明明只要标题,正文和附件却已经一起进来了。有人会说底层仍是 Core Data,内部也许还有占位。

但这不是重点。开发者看不到状态,SwiftData 也没有开关能把填充推迟到 cell 去真正读标题的那一刻。对外表现就是:查完即加载完,缺少了惰值机制。

我在 写在 WWDC 2024 之前 里便写过:SwiftData 不支持数据的惰性加载。到今天(iOS / macOS 27),这件事没有变。

那些看起来有用的选项

官方为 SwiftData 的 FetchDescriptor 提供了 propertiesToFetch。按文档的说法,它应当只取指定属性,访问没取到的字段时再补一次读取。这套能力在 Core Data 里有对应的 API,而且非常有效。

同一份数据上,Core Data 把 propertiesToFetch 设为 title 后,3000 条大约 0.009 秒,比完整取出快了不止一个数量级。

但真正用在 SwiftData 上,会发现完全没有作用。

这不只是因为 SwiftData 至今没有字典这种返回类型。即便框架读到了 propertiesToFetch,仍然会把所有属性都填上。

Swift
var descriptor = FetchDescriptor<Note>(
    sortBy: [SortDescriptor(\.createdAt)]
)
descriptor.propertiesToFetch = [\.title]
let notes = try context.fetch(descriptor)

打开 -com.apple.CoreData.SQLDebug 1 可以观察到:即便写了 propertiesToFetch,生成的 SQL 仍是把所有列都选出来的 SELECT

我始终认为这是 SwiftData 里一个长期存在的 Bug。不过这个问题已经搁了这么久,我怀疑背后有不好解决的技术原因,比如一套通用的 DataStoreSnapshot 处理不了惰值这种特例。

在需要跟着数据变的列表场景里,SwiftData 的其他选项也各有不足:

  • ResultsObserver 只是把 @Query 从视图里搬出来,结果仍是完整模型。
  • fetchIdentifiers 很快,但 @Query 不支持。
  • 分页能减少条数,但无法动态响应数据变化。
  • @Attribute(.externalStorage) 只是换了大文件的存放位置,但并不影响填充逻辑。

这些接口不是没用,只是都发生在模型已经定宽之后。主模型如果又宽又重,后面再调也救不回来。

这是疏漏,还是设计

既然底层就是 Core Data,为什么 SwiftData 要把惰值这种重要的自动优化拿掉?这个问题我问过自己很久。起初,我更愿意把它看成首发不完整,等官方把开关重新打开。几年过去,它们没有以开发者能用的形式回来。我逐渐觉得,这未必是忘了做,而是框架想成为什么样的东西。

苹果把 SwiftData 放在「少想一层」这一侧:一个 @Model、一个 @Query,和 SwiftUI 默认就能配合。Core Data 那些办法之所以强,正因为它们要求你理解对象图、缓存、结果类型、部分填充。把这些再交出来,学习成本会回去。性能上,则更多地指望设备变快——CPU、内存、已经很普遍的固态存储。数据量不大的应用,在这样的机器上把整行加载掉,往往还能过得去。

SwiftData 面向的是现在的移动设备和固态存储,Core Data 里那些为旧硬件长出来的能力,不一定会被请回来。

一个重要的原则其实已经换了:Core Data 是对象图管理框架,持久化是为对象图服务的;SwiftData 则是面向 SwiftUI、把学习门槛压低的持久化包装

这几年框架并不是没有在性能上做改善。谓词的表达能力在增强,更多筛选可以留在 SQLite 里完成,而不必先把全部数据拉进内存再 filterHistoryObserver 改善的是什么时候动手:同步、增量更新、要不要刷新,都会轻很多。

谓词如何转成 SQL、这些年补了什么,见 Swift Predicate:用法、构成及注意事项如何为 SwiftData 动态构建复杂谓词

但这些更新都不改变基本的原则:对象对外是完整的,查询结果是模型而不是「只要这几列」,复杂细节尽量藏起来。少读几列、只认 ID、让列表在惰值外壳上完成标识,都不在这个原则里。

我完全可以接受 SwiftData 的这个设计原则。真正可惜的是,官方文档和示例几乎没有把「列表模型应当更瘦」写成一开始就要做的事。Demo 里的 Trip、Note,字段都住在同一个类型上,对教学很友好,也让很多已经上线的应用按同一形状建了库。随着数据集膨胀,CloudKit 又限制了迁移方式,一旦真正出现性能瓶颈,想再解决就变得非常困难。

能力可以按原则不做;但示范缺了,代价会落在第一版模型已经发到用户设备上的人身上。

从建模上把数据拆开

在关系的加载上,SwiftData 和 Core Data 仍是一致的:关联对象默认懒加载。属性上消失的延迟,在关系上还在。而这正是解决 SwiftData 列表性能问题的核心。

Core Data 里,拆分多半对着「明显很大」的东西:原图、很长的正文。列表实体上仍可以放日期、状态、摘要,读标题时其余属性可以继续停在惰值里。

但 SwiftData 没有惰值这层保护。主模型上多一个很少用的字符串、一段设置、一个 Codable,列表都会一起加载。于是模型的切分不再只针对大字段,而要用更严格的标准,按使用频率来切。相对过去,模型会碎得多,设计阶段的任务也要重得多:

Swift
@Model
final class Note {
    var title: String
    var createdAt: Date
    var isPinned: Bool
    var preview: String?                 // 列表要用的摘要,刻意多存一份
    var body: NoteBody?
    var attachment: NoteAttachment?
}

@Model
final class NoteBody {
    var text: String
    var note: Note?
}

@Model
final class NoteAttachment {
    var data: Data // 缩略图
    var original: AttachmentOriginal?
    var note: Note?
}

@Model
final class AttachmentOriginal {
    @Attribute(.externalStorage)
    var data: Data // 完整图
    var attachment: NoteAttachment?
}

还是最初的 3000 条数据。模型切分后,Cell 中只加载 Note,加载时间大约 0.029 秒,内存占用大约多 0.9 MB。相对肥模型,时间大约快 10 倍,内存占用从约 176 MB 降到不足 1 MB。

拆实体这件事,Core Data 时代就该做。但在 Core Data 中,我们即便分割模型,也通常会将缩略图和原始图放在一个模型里,因为可以用 propertiesToFetch 按需去取。在 SwiftData 里,这样的切分力度就不够了。如果这两样不是必然一起出现,再往下切,是目前仅有的改善办法。

一个常见的担心是:cell 里除了主模型,还要用关系上的数据,会不会更慢?

在机械硬盘上,这种按条去读会被放大。现在的设备已经是 SSD,关系对应的是另一次很快的按主键读取。列表真正怕的,是一次查询把所有宽字段加载成几千个对象;不是用户滚动时多碰几十条详情。

如果 cell 里稳定需要某一小段摘要,把它多存一份在列表实体上,通常比每次都去点关系更合适。要避免的,是把「打开详情页才用得着」的东西,放进 @Query 直接去查的那张表。

拆的时候仍然要遵守 SwiftData 关系自己的约束:显式逆关系、CloudKit 下必须可选、对多数组不要在循环里反复 append。见 SwiftData 中的关系:变化与注意事项

只拿 ID 行不行

如果你的 SwiftData 应用已经上线,且碰到了明显的性能问题,是否有临时的改善手段呢?

尽管官方没有提供「结果是 ID、还能跟着数据变」的 Query,不过把 fetchIdentifiersHistoryObserver(iOS 27+)拼在一起,开发者还是能做出一份会跟着数据变的 ID 列表。

ForEach 的数据换成 [id],并在每个 cell 里用 id 再加载数据,不失为一个权宜之计。

不过,和一开始就加载瘦模型相比,cell 的初始结构会变重。更要紧的是,[id] 里没有任何可以用来推算行高的内容。数据要等 cell 出现时才逐个读入,这个过程中行高会从占位值跳到实际值,List / LazyVStack 已经算好的布局随之被修正,滚动时就可能出现抖动或跳变。给 cell 一个可供参考的高度,是可以考虑的改善办法。

了解如何通过 id 获取数据,请阅读 NSManagedObjectID 与 PersistentIdentifier

这是不得已的办法,不是替代方案。

SwiftData 的悖论

SwiftData 吸引人的地方,正是少写一层:一个类、一个查询,列表就能转起来。拆得更碎,这层简单就被拿掉了。

  • 插入一条笔记要同时创建摘要和详情
  • 开了云同步,每条关系还得是可选的、带逆关系
  • 新人很难从「声明一个 Note」想到,标题和正文、原图和缩略图不该放在同一个类型里。

拆开之后,汇总也会略显别扭。以前一层就能写完的东西,会变成更深的关系。

这就造成一个悖论:SwiftData 本意是降低学习门槛,可要是没真正理解它的限制,省下来的那层简单,会被性能问题抹平,而且更难收拾。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅