把卡顿理解成「GC 不好」会把真正的病——无界分配、短命大对象晋升、钉在错误堆上的像素——掩盖掉。Dart VM 用分代回收:新生代复制,老年代标记-清扫(必要时压缩)。Flutter 每秒 60/120 次 build,若热路径每帧都 new 大对象,GC 就会变成掉帧源。原理要落到「对象活多久、有多大、在哪块堆」。
读完你应该能独立回答三个问题:为什么一千个临时 Widget 往往无害而一帧一份大 List<Offset> 有害、晋升为什么是性能事件、ImageCache / ui.Image / FFI 指针为什么在 DevTools Memory 里看不见同一笔账。
1. 先画边界:三块堆,GC 只管其中一块
一次 Flutter 进程里至少有三块互不混淆的内存:
- Dart 堆:Isolate 私有(更准确:每个 Isolate 有自己的对象图;同 group 共享代码和元数据,不共享可变对象)。Widget、Element、State、普通 List/Map、闭包,都在这里。分代 GC 管它。
- 原生堆(C++):DisplayList、Layer 镜像、字体缓存、Codec 中间缓冲、FFI 分配。Dart GC 看不见,要
free/dispose/ Engine 析构。 - GPU 显存:纹理、MSAA、离屏、字形图集。Raster 线程持有。
RepaintBoundary缓存也在这儿。
ui.Image 在 Dart 里只是句柄。句柄被 ImageCache 或 State 钉住,原生像素和 GPU 纹理就不会放。只看 DevTools 的 Dart 堆下降,RSS 可能纹丝不动——像素不在那一堆。
不变量:跨 Isolate 不能共享可变对象;跨 UI/Raster 不能共享 Dart 对象。 要传,就拷贝或转移所有权(TransferableTypedData、纹理 id)。
2. 新生代:半空间复制,为「这一帧用完就扔」而设计
新对象从 TLAB / bump-pointer 在新生代分配,几乎是加一个指针。新生代用 scavenge:活对象从 from-space 拷到 to-space,其余当垃圾。空间小(数 MB 量级,随负荷可调),停顿短,目标是亚毫秒到一两毫秒。
build() 里创建一棵浅 Widget 树,通常符合这个模型:对象很小、立刻没有引用、下一轮 scavenge 就没了。这就是为什么「少 new Widget」很少是第一优化——Widget 构造便宜,活得也短。
危险的是同一拍里分配 大 的新生代对象:几万个 Offset、整页解码前的 Uint8List、超长字符串拼接。它们很快把新生代填满,scavenge 变勤;若还被某个长命对象够到,下一节的晋升就会发生。
@override
void paint(PaintingContext context, Offset offset) {
final points = <Offset>[]; // 每帧一份,点多了就是分配事件
for (final p in particles) {
points.add(Offset(p.x, p.y));
}
context.canvas.drawPoints(PointMode.points, points, _paint);
}
热路径应复用 List / Float32List / Paint / Path,或把缓冲放进 State 里原地更新。不是因为「new 慢」,是因为你在 8.3ms 里制造 GC 工作。
3. 老年代:活过几次 scavenge 的对象
对象连续几次 scavenge 还活着,就 晋升 进老年代。老年代用标记-清扫,标记可以并行,清扫可以并发;空间不够或碎片严重时 compact,停顿明显变长。老年代 GC 一旦撞上帧,Timeline 上会出现和 Vsync 重合的大块 STW 或很长的 mutator 暂停。
晋升是性能事件,因为它意味着:
- 这块内存从「廉价复制」变成「要参与整堆标记」。
- 若它其实是短命的(只是太大、逃过了新生代的几次拷贝),老年代被垃圾污染,标记成本被抬高。
- 老年代对象若指向新生代,写屏障要记账(remembered set)。热路径上对老对象反复塞新 List,写屏障 + 晋升双开。
典型错误:把每帧的 List<Offset> 存进 State 却每次 = <Offset>[...] 换新表,旧表若还被动画闭包、缓存、Riverpod 容器握着,就晋升;图片解码结果没人 dispose,却被全局 cache 引用,原生堆和 Dart 句柄一起钉死。
4. 写屏障、跨代指针、Isolate
向一个老年代对象的字段写入指向新生代的引用,VM 必须记下,否则 scavenge 只扫新生代会漏根。这就是写屏障。你感觉不到它,除非热路径每帧在改一个长命 State 里的大对象图。
Isolate 堆不共享。compute 的入参和回包要拷贝(可拷类型:num、String、List/Map 的树、typed data 的拷贝;TransferableTypedData 转移所有权)。worker 上 GC 再狠,也减不掉 UI Isolate 的 scavenge;它能做的是让 UI 少分配——把 JSON 解析、图像变换放过去,只把小结果拷回来。
回传一个 20MB 的 Uint8List,是在 UI Isolate 再分配 20MB。那一瞬间新生代/老年代都可能被打满。大缓冲应在 worker 里变成更小的结构,或走纹理/文件,不要当消息。
5. Flutter 特有的钉住:缓存、图片、Layer
GC 只回收没有入边的 Dart 对象。Framework 里有几处合法的「长命根」:
ImageCache:默认约 1000 张、100MB 上限(可改)。它按ImageProvider的 key 钉ImageStream。列表快速滚动会把解码结果打进缓存;上限太高,RSS 高;上限太低,同一张图反复解码,IO 和 Raster 来回打。ui.Image/Codec:必须dispose。不 dispose 的句柄会让原生像素活过所有 Dart 引用的直觉寿命。- Layer /
LayerHandle:Raster 可能仍在用上一帧的 Layer。句柄是跨线程生命周期的 Dart 侧锚点。 KeepAlive/AutomaticKeepAliveClientMixin:Tab、列表项被刻意钉住,Element 和 State 不随滑出而 unmount。内存换的是切换速度,不是泄漏,但账单要记账。- 闭包与
Ticker:AnimationController没dispose,Ticker 继续要帧,对象图活着,Vsync 也不停。
泄漏排查先问在哪块堆:DevTools Memory 看 Dart;原生用 Instruments / Android Studio Profiler;显存看纹理数量和 ImageCache 字节。三套工具,三套结论。
6. 分配与帧时间:GC 事件要和 Vsync 叠图
DevTools Timeline 里 GC 是独立事件。它与掉帧重合,才是「GC 造成的卡」。不重合,只是堆在涨,卡在别处。
看这些量,而不是看「GC 坏了」的感觉:
| 信号 | 更可能的原因 |
|---|---|
| 新生代 scavenge 极勤,停顿仍短 | 热路径小对象太多,先复用缓冲 |
| 偶发长停顿,老年代 mark/compact | 短命大对象晋升,或长命图太大 |
| Dart 堆稳,RSS 涨 | 原生像素、字体、GPU 纹理 |
| isolate 回包瞬间掉帧 | 主 Isolate 正在拷贝/分配大消息 |
Release 和 Debug 的分配器、JIT、检查器都不一样。不要拿 Debug 的 GC 曲线做上线结论。Profile 模式更接近 Release,仍带 Timeline。
ImageCache 的 key 是 ImageProvider 的 == / hashCode。同一 URL 但每次 new NetworkImage(url) 且你重写坏了相等性,缓存会当不同图,解码 N 次、钉 N 份。反过来,key 太粗(都当同一张)会显示错图。这是 Dart 堆上的字典问题,像素却在原生和 GPU。调 maximumSizeBytes 而不修 key,只是在用错误的上限补错误的身份。
另一类钉住是 StreamSubscription、ChangeNotifier.addListener、Timer 没有在 dispose 里摘掉。闭包捕获 State,Element 已经 unmount,对象图仍从全局的 notifier 活着。DevTools Memory 的「leak」往往是这条边,不是 VM 没回收。leak_tracker / debugIsWidgetCreated 能在测试里把这类边变成失败,比在生产里看 RSS 更早。
大对象分配还有一条捷径:Uint8List(n) 超过一定阈值会直接进老年代或成为「大对象区」,跳过新生代的廉价 scavenge。所以「先 new 大缓冲再立刻不用」不是新生代问题,是直接污染老年代。图片、音频、文件应流式处理或在 worker 里消化成小结构再回传。
字符串是隐藏的大对象来源。jsonEncode 一整棵树、TextSpan 里拼接日志、toString() 打到每帧的 overlay,都会在 UI Isolate 产生长 String。它们不可变,拼接即分配。热路径用 StringBuffer 仍是分配,只是少中间对象;真正的办法是不要在 60Hz 路径上做字符串。debugPrint 在 Profile 里也不是免费的。
finalizer 不是析构函数。它在对象不可达之后的某个 GC 时机跑,可能晚于你需要释放 GPU 纹理的那一帧,也可能在 isolate 退出时成批跑。及时 dispose 是协议;finalizer 是防漏的网。把资源释放只挂在 finalizer 上,RSS 会呈锯齿:GC 才降,帧中间一直顶着。
Isolate group 共享 AOT 代码和部分元数据,这是 FlutterEngineGroup 省内存的真正原因之一:第二份 Engine 不再映射一份完整机器码。堆仍然隔离。看到「group 共享」就往另一份 Engine 塞对象,会在发送端口上拷贝,甚至直接失败。共享的是代码,不是你的 ImageCache。每份 Engine 一份缓存,这是混合栈 RSS 的常见倍数。
compute 的闭包不能捕获 UI 对象。能发过去的是可拷贝消息。你把 BuildContext、RenderBox、ui.Image 塞进 worker,不是「GC 会处理」,是发送直接失败或把一份巨大的拷贝打进子 isolate 再打回来。Worker 的正确输入是纯数据:文件路径、字节、数字窗口;输出是更小的纯数据或 transferable。GC 优化从这条边界开始:减少跨堆拷贝,比微调新生代大小有用。
Timeline 里 CollectNewGeneration 和 CollectOldGeneration 要分开看。前者密而短,说明热路径分配多,去复用;后者疏而长,说明老年代体积或碎片,去找晋升和长命图。把两者都叫「GC 卡顿」然后全局关动画,是在用产品功能给分配器赔罪。
WeakReference 和 Expando 不会把对象钉在老年代,适合「缓存想握但不阻止回收」的边。ImageCache 不是弱引用,它是有上限的强缓存。把业务对象图做成无上限 Map 当缓存,GC 永远看不见垃圾,这是泄漏协议,不是分代算法失败。上限 + 按代淘汰(LRU)是你必须在 Dart 里写的,VM 不会替你猜哪张图还可以扔。
7. FFI 与 External:GC 的盲区
Pointer<T>、malloc、第三方 so 里的缓冲,不在 Dart 堆。GC 不会因为 Dart 侧包装对象死了就 free 指针——除非你注册了 NativeFinalizer / Finalizer,并且包装对象真的不可达。
把 FFI 指针存进一个被全局单例钉住的对象,等于手动泄漏。把 Uint8List 的 asExternalTypedData 在原生释放后还在 Dart 里读,是 use-after-free,GC 帮不上。
Engine 自己的图片、路径、Picture 也走类似「Dart 句柄 + 原生对象」模型。Picture.toImage 再产一张 ui.Image,两套资源。用完不 dispose,堆记账会骗过你。
8. 设计取舍(为什么是现在这样)
- 分代 + 短停顿新生代:契合「每帧大量短命小对象」的 UI 框架。代价是大对象和长命图一旦晋升,老年代 GC 成为偶发长卡。
- Isolate 不共享堆:没有 UI 数据竞争。代价是并行必须拷贝,大结果回传本身是分配事件。
- 句柄式原生资源:GPU/解码可以在 C++ 里管理。代价是
dispose成为协议,泄漏不在 GC 曲线上。 - ImageCache 全局钉住:滚动列表不反复解码。代价是默认上限在大图场景下等于合法地占 100MB+。
这些取舍决定了什么优化有意义。把所有 Widget 改成对象池,通常赢不回一毫秒;把每帧的粒子数组改成复用的 Float32List,可能直接拿掉一次 scavenge。把图片 cacheWidth 设对,省下的是 GC 根本看不见的 48MB。
9. 怎么把原理用到排障上
RSS 涨或偶发掉帧,按堆提问:
- DevTools 里 Dart 堆是否在涨? 涨则找长命根(缓存、闭包、KeepAlive、未 dispose 的 controller)。不涨则去原生/显存。
- GC 事件是否压在 Vsync 上? 是则看新生代还是老年代,对症减分配或减晋升。
- 热路径有没有每帧新的大 List / Path / 字符串? 复用或移出 UI Isolate。
- ImageCache 字节、
ui.Image是否 dispose? 列表页优先查这个。 - 有没有 FFI / 多 Engine? 前者 GC 盲区,后者是整份堆 × N。
能回答这五问,GC 就不再是敌人,而是一张「短命保持短命、长命保持稳定」的时间表。
10. 小结
分代 GC 为「这一帧用完就扔」的小对象而快,为「误晋升的大对象」和「钉在原生/GPU 上的像素」而慢。让短命对象保持短命,让长命对象保持稳定,帧时间才稳。下一篇回到 Framework:Element 树怎样用 runtimeType + key 决定哪些对象该继续活着、哪些该在这一帧结束时 unmount。