把 SwiftUI List 换成 NSTableView:一次目录树卡顿的排查记录

把 SwiftUI List 换成 NSTableView:一次目录树卡顿的排查记录

macOS · SwiftUI · AppKit · 性能排查|2026-08-26 · macOS 15.7 · Swift 6.2

三轮改 SwiftUI 没修好,最后是一段 32 秒的 Instruments 录像告诉我:卡的不是我写的代码。

我在写一个 macOS 缓存清理工具,其中有个功能是让用户选任意目录,工具递归扫完之后按文件夹层级展示所有文件,标出哪些能清理、哪些是重要文件,勾选后删除。列表用 SwiftUI 的 ListOutlineGroup 实现,扫系统缓存目录时轻松就是一万个节点。

实际用下来,目录树的展开折叠操作明显迟钝:点开一个文件夹,快两秒才有反应。

这篇文章记录我之后做了什么、错在哪、最后怎么修的。文里所有数字要么是 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_getAtKeyPathKeyPath._projectspecialized project)是 SwiftUI 在对每一行视图做求值时产生的------每行的 @ObservedObjectenvironmentObject、各种属性键,都要经过 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

flowchart TB subgraph BEFORE[&#34;换之前:List + OutlineGroup&#34;] L1[&#34;List 收到全部可见行&#34;] --> L2[&#34;每行 DirectoryNodeRow<br/>都挂在整页 SwiftUI 树上&#34;] L2 --> L3[&#34;任何状态变化<br/>框架层重投影一遍行&#34;] end subgraph AFTER[&#34;换之后:NSTableView&#34;] T1[&#34;系统只为视口内的行请求 cell&#34;] --> T2[&#34;离屏 cell 回收复用<br/>不进整页 SwiftUI 树&#34;] T2 --> T3[&#34;cell 内部嵌 NSHostingView<br/>局部 SwiftUI,行视图代码照旧复用&#34;] end

图 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 自动刷新。也就是说勾选这种高频操作完全绕开了整页重算。

sequenceDiagram participant U as 用户 participant T as NSTableView participant C as 视口内 cell Note over U,C: 展开文件夹(结构变化) U->>T: 点击展开 T->>C: 只为视口内的行请求 view T--x 离屏行: 不创建,不评估 Note over U,C: 勾选文件(非结构变化) U->>C: 点复选框 Note over C: cell 内 NSHostingView<br/>自己订阅 model 更新 T--x T: 不触发 reloadData

图 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

项目地址:github.com/GFredR/Cach...

相关推荐
IT大白鼠3 小时前
MSF环境搭建全教程(Kali/Windows/Mac/Docker)
windows·macos·msf
2601_961593424 小时前
视频放大模糊不清?Topaz Video AI v1.7.0 Mac 版高效修复
人工智能·macos·音视频
2501_915106325 小时前
Swift 开发环境搭建指南:三条路径按目标对号入座
开发语言·vscode·ios·objective-c·个人开发·swift·敏捷流程
greasyfork8 小时前
Ps 2026 v27.6 For Mac:专业图像处理工作流解析
图像处理·人工智能·macos·ps
sg_knight9 小时前
openCode 安装与初始化配置(Windows / macOS / Linux)
linux·windows·macos·llm·agent·ai编程·opencode
钱栈up1 天前
Mac 开发机一键发版不用切环境:我这样改造了团队的后端部署脚本Maven编译卡住20分钟?我靠两步定位到2处隐蔽编译错误
开发语言·python·macos
Xxtaoaooo1 天前
600 元 HP 小主机装 macOS:从启动盘、BIOS 到核显加速完整教程
macos·黑苹果·cpolar·mac mini·实战教程
那年窗外下的雪.1 天前
AIDC 学习日志|第 14 天|MAC Flapping 与 EVPN 多归属分层排障
网络·git·学习·macos·github·spine
方芯半导体1 天前
EtherCAT 从站方案中 MAC、PHY 与 MII 的层级关系与实现解析
网络·人工智能·单片机·macos·ethercat