把 SwiftUI List 换成 NSTableView:一次目录树卡顿的排查记录
macOS · SwiftUI · AppKit · 性能排查|2026-08-26 · macOS 15.7 · Swift 6.2
三轮改 SwiftUI 没修好,最后是一段 32 秒的 Instruments 录像告诉我:卡的不是我写的代码。
我在写一个 macOS 缓存清理工具,其中有个功能是让用户选任意目录,工具递归扫完之后按文件夹层级展示所有文件,标出哪些能清理、哪些是重要文件,勾选后删除。列表用 SwiftUI 的 List 加 OutlineGroup 实现,扫系统缓存目录时轻松就是一万个节点。
实际用下来,目录树的展开折叠操作明显迟钝:点开一个文件夹,快两秒才有反应。
这篇文章记录我之后做了什么、错在哪、最后怎么修的。文里所有数字要么是 Instruments 录出来的,要么是我自己写的基准跑出来的,没有一个是估的。拿不到的数字,我也会直接写明拿不到。
我以为我知道原因
当时的代码长这样:
swift
List {
ForEach(model.tree) { node in
OutlineGroup([node], children: \.children) { item in
DirectoryNodeRow(node: item).environmentObject(model)
}
}
}
我当时的判断是:OutlineGroup 把整棵子树一次性喂给了 List,点开一个文件夹,SwiftUI 要把那棵子树的所有行都实例化一遍,行多了自然卡。这个判断听起来很有道理,我甚至没去验证就动手改了。
后面会看到,这个判断只对了一小半。
第一次改:懒加载,被打脸
我把 OutlineGroup 拆掉,自己维护一个 expandedFolderIDs 集合,只收集"已展开路径"下的可见行:
swift
func visibleRows() -> [DirectoryNode] {
var rows: [DirectoryNode] = []
func walk(_ nodes: [DirectoryNode]) {
for n in nodes {
rows.append(n)
if n.isFolder, expandedFolderIDs.contains(n.id), let c = n.children {
walk(c)
}
}
}
walk(model.tree)
return rows
}
没展开的文件夹,整棵子树都不会被构造。发出去,第二天反馈回来:好像比之前更迟钝了。
更迟钝了。行。
第二次改:分批渲染,这次有效
我猜"更迟钝"是因为一次构造的行数还是太多------展开一个有几千文件的文件夹,首屏还是要把这几千行全部铺进 List。于是加分批:一次只构造前 400 行,滚到底部自动追加下一批。
swift
private let revealBatch = 400
// 滚动到底自动追加,按钮兜底
这次有改善,但还不够。
第三次改:加缓存,又被打脸
有改善,我胆子大了点,又加了一层 visibleRowsCache,把行集合缓存起来,想省掉重复收集的开销。发出去,又不如刚才了。
回退。
两轮打脸之后我承认一个事实:我在没有测量数据的情况下连续改了三次,方向全靠猜,每轮唯一的验证就是改完之后的体感。这样下去不行,得看数据。
不猜了,上 Instruments
顺带说一个坑:我是 SwiftPM 工程,Instruments 用 Launch 模式直接跑会报错(它按 Xcode 构建产物找二进制,找不到 SPM 的 .build/debug 目录)。正确姿势是先把 app 跑起来,再在 Instruments 里选 Attach to Process 挂上去。模板选 Time Profiler,录了 32 秒,操作就是复现卡顿:扫一个大目录、展开一个上千文件的文件夹、滚动、勾选若干行。
录完看右侧的 Heaviest Stack Trace,长这样:

图 1 · Time Profiler,32.264 秒,6,702 个样本
32 秒录下来的东西
先看线程分布,数字都是录像里直接读的:
| 线程 | CPU 时间 | 占比 |
|---|---|---|
| 主线程 | 5.49 s | 82.0% |
| com.apple.NSEventThread | 201 ms | 3.0% |
| 其余工作线程合计 | 约 1 s | 约 15% |
主线程吃掉 82%。卡顿发生在主线程,符合直觉。但接下来这份调用栈列表才是重点:
| 栈帧 | 所属 | 耗时 |
|---|---|---|
| SwiftUICore / SwiftUI 六帧 | SwiftUI 框架 | 合计 5.41 s |
| swift_getAtKeyPath | libswiftCore | 338 ms |
| KeyPath._project... | libswiftCore | 331 ms |
| specialized project | libswiftCore | 190 ms |
| DirectoryRowItem... | 我的代码 | 130 ms |
| UUID.uuidString | Foundation | 35 ms |
这张表我盯了很久。把六个 SwiftUI / SwiftUICore 的大帧加起来是 5.41 秒,占主线程 5.49 秒的 98.5%。我自己写的 DirectoryRowItem 一共 130 毫秒,2.4%。
也就是说:卡顿的时间里,我的代码基本不在场。
那三个加起来约 860 毫秒的 KeyPath 调用(swift_getAtKeyPath、KeyPath._project、specialized project)是 SwiftUI 在对每一行视图做求值时产生的------每行的 @ObservedObject、environmentObject、各种属性键,都要经过 KeyPath 投影。这是框架的机制,不是 bug,也不在我能优化的范围内。35 毫秒的 UUID.uuidString 是我行 ID 生成方式的小问题,但它连零头都算不上。
回到我最开始那个判断------"OutlineGroup 一次性喂整棵树导致行太多"。数据说明,行数确实是一部分,但真正的成本是每一行被 SwiftUI 评估时都要走一遍框架层的投影和布局。所以我把一万个节点藏起来只露四百个,实际体感改善有限------那四百行照样每行都被投影,而且"懒加载 + 分批 + 缓存"这些结构本身又给 SwiftUI 添了新的重算面积。这解释了为什么第二轮加缓存反而更慢。
顺手写了个基准,结果三边打平
改 NSTableView 之前,我想先量化一下"行数据构造"这一步到底差多少,就在仓库里加了个 ListBench 命令行工具:构造 10,401 个节点的目录树,分别模拟全量构造、懒加载 400 行、视口 60 行三种方案,跑三次取平均。
| 方案 | 3 次平均 | max |
|---|---|---|
| 全树实例化(10,401 行) | 2.92 ms | 2.99 ms |
| 懒加载 400 行/批 | 2.94 ms | 3.02 ms |
| 视口 60 行(模拟 NSTableView) | 2.86 ms | 3.02 ms |
三边打平,都在 3 毫秒左右。
这个结果我一开始有点失望,后来觉得它恰恰是全文最重要的一组数字:纯数据构造根本不是瓶颈,三个数量级都够不着卡顿的那两秒。谁要是拿着这种基准跟你说"NSTableView 比 List 快十倍",那数字是从别处来的。真正的差距在 SwiftUI 框架层那 5.41 秒,而那部分只有 Instruments 看得见。
换 NSTableView
既然瓶颈是"每一行都要被 SwiftUI 评估",那就让大部分行根本不进 SwiftUI。方案是把列表区域换成 NSTableView,包一层 NSViewRepresentable:
图 2 · 换前后的差别:行的 SwiftUI 树从"整页"缩到"cell 内"
两点依据。第一,NSTableView 只为视口内的行调用 tableView(_:viewFor:row:),离屏的 cell 走 prepareForReuse 回收复用,常驻的 view 数量只跟视口高度有关,跟总行数无关------这是 AppKit 的文档行为。第二,cell 是 NSView 不是 some View,没有 body 重算,没有 KeyPath 投影风暴。Instruments 里那 5.41 秒对应的机制,在这套结构里对离屏行直接不存在。
关键代码骨架(真实代码有删减):
swift
struct DirectoryTableView: NSViewRepresentable {
@ObservedObject var model: DirectoryAnalysisModel
var dataRows: [DirectoryRowItem]
func updateNSView(_ nsView: NSScrollView, context: Context) {
// 只有结构版本变了才 reload,勾选不递增版本
guard context.coordinator.lastRevision != model.tableRevision else { return }
context.coordinator.lastRevision = model.tableRevision
context.coordinator.dataRows = dataRows // didSet 里 reloadData
}
}
final class RowHostCell: NSTableCellView {
private let hosting = NSHostingView(rootView: AnyView(EmptyView()))
func configure(with row: DirectoryRowItem, model: DirectoryAnalysisModel) {
// 行内容还是 SwiftUI,原来那套 DirectoryNodeRow 一行没改
hosting.rootView = AnyView(
DirectoryNodeRow(node: row.node).environmentObject(model)
)
}
}
这里最重要的一行是 updateNSView 开头的 guard。SwiftUI 每帧都会调 updateNSView,但只有展开/折叠/重新扫描这类结构变化才递增 tableRevision、才 reloadData。勾选文件只改 model 里的 selectedIDs,版本不动,cell 不回收,cell 里的 NSHostingView 自己订阅 model 自动刷新。也就是说勾选这种高频操作完全绕开了整页重算。
图 3 · 两类操作的路径分离
发出去,这次确实好了。
迁移的代价
NSTableView 不是白拿的,这几个坑都是实际踩过的:
- 右键菜单。 SwiftUI 的
.contextMenu挂在自绘 cell 里不生效,得子类化 NSTableView 重写menu(for:),按右键位置算行号。另外菜单项的目标节点要用NSMenuItem.representedObject携带------我第一版用一个共享变量暂存,菜单弹着的时候表格一 reload 就指错行了。 - 左右留白。
List自带的 inset 没了,行内容贴着窗口边。cell 里补了 12pt 内边距。 - 行高。
NSTableView.rowHeight是固定值,我按字号算了一个钳制在 40--56 的值。行高不齐的复杂内容得用usesAutomaticRowHeights,我暂时没这个需求。 - reload 时机。 就是上面那个 revision 机制,想清楚哪些变化是"结构"、哪些只是"数据",这个分类做错了要么卡要么显示不刷新。
另外说清楚适用范围:如果你的列表就几十行、没有展开折叠,别换,SwiftUI 的 List 完全够用,换 NSViewRepresentable 是给自己找麻烦。我的判断标准是两条同时满足------Instruments 里 SwiftUI 框架层占主线程大头,且行数规模在千行以上。
哪些数字我没有
写文章最怕顺手编一个"从 2000ms 降到 50ms"出来。我没有的东西,直接列在下面:
- 端到端操作耗时。我没在"点击展开"前后打点,所以给不出"展开从 X ms 降到 Y ms"。我有的是 32 秒录像里的热点分布,和换完后卡顿消失的实际结果。
- 帧率。没录 FPS trace。
- 内存。没跑 Allocations。
- "快 N 倍"。基准测试已经证明纯构造维度三边打平,倍数无从谈起;框架层那 5.41 秒在新结构里对离屏行不存在,但"不存在"没法折算成倍数。
复盘
三轮改 SwiftUI,两轮越改越卡,根因是同一个:我没看过任何测量数据就动手,第一轮猜"行数太多",第三轮猜"重复收集太贵",两个猜测都跟真正的瓶颈(框架层逐行投影)不在一个维度上。如果一开始就录那 32 秒,第一眼看到"业务代码只占 2.4%"就会直接跳到换控件的结论,三轮返工全能省掉。
所以整件事说到底就一句话:SwiftUI 卡的时候,先看 Time Profiler 里自己的代码占多少。占比小的话,别在业务层折腾了,该换控件换控件。
附录:复现条件
- 机器:iMac (Retina 5K, 27-inch, 2019),3.6 GHz 8 核 i9,32 GB 内存
- 系统:macOS 15.7.4
- Instruments 用 Attach to Process 挂正在运行的进程;SwiftPM 工程别用 Launch 模式,会报找不到二进制
- 操作序列:扫描大目录 → 展开一个千文件级文件夹 → 滚动 → 勾选若干行 → 停止
- ListBench 在仓库
Bench/ListBench/下,默认不进Package.swift的 targets,想跑的话把那个 executableTarget 加回去,swift run ListBench