Flutter页面莫名空白:一次渲染死锁的硬核排查
背景
事情是这样的,最近在开发一个 Flutter 应用(AI 悬浮剪贴板 ClipDash),发现一个非常诡异的问题:
- debug 版运行后,打开编辑页面,从标题到正文这一整块区域全是空白的,只有 AppBar 图标、底部的标签栏和按钮正常显示;
- 盲点一下标题区域,所有 UI 又都出来了,像什么都没发生过;
- release 版完全没有这个问题。
「debug 坏、release 好」这个组合就很烦,因为它反直觉——通常 debug 有 assert 兜底应该更容易暴露问题才对。一开始我以为是哪个 assert 抛了异常把子树打断了(debug 有 assert、release 没有,症状对得上),结果 logcat 里干干净净,一条 Flutter 异常都没有。
那就只能上硬手段了。这篇文章记录整个排查过程,中间反转不少,最后定位到的是一个 Flutter 框架层的绘制死锁,根因和你想象的大概率不一样。
先说结论,省得有人划到一半划走:
不要在 sliver 内容里使用 LayoutBuilder。 它在布局阶段执行 build,在特定时序下(路由转场 + debug JIT 掉帧)会让 sliver 子树在「布局完成后、绘制开始前」被重新标脏,从而触发框架的静默跳绘死锁——滚动区域永远不再绘制,页面空白。
复现与第一份证据:VM service
debug 包直装(不经 flutter run)也带 Dart VM service,logcat 里能找到端口:
1 | adb logcat -d | grep "Dart VM service" |
很多人不知道的是,VM service 除了 WebSocket 还支持 HTTP GET 直接调 RPC,连 DevTools 都不用开:
1 | 拿 isolate id |
在空白状态下 dump 渲染树,一眼看到不对劲的地方——布局数据全部正常(尺寸、偏移都对),但从 sliver 往下整棵子树都挂着 NEEDS-PAINT:
1 | └─child: RenderViewport#2409b |
注意:viewport 自己是干净的(没有 NEEDS-PAINT),只有它的孩子们全是脏的。再 dump layer 树(ext.flutter.debugDumpLayerTree),实锤了:
1 | ├─child 2: OffsetLayer#8fbbe |
viewport 这个 repaint boundary 的 OffsetLayer 位置正确(AppBar 下方 80 逻辑像素),但里面是空的。也就是说:某一帧 viewport「绘制」了一次,但一个孩子都没画进去,然后它把自己的脏标记清掉了。
为什么点一下就能恢复:markNeedsPaint 的早退陷阱
看一下框架源码 RenderObject.markNeedsPaint 的开头:
1 | void markNeedsPaint() { |
这里有个隐蔽的坑:一个节点如果处于「_needsPaint == true 但不在脏列表里」的状态,那么之后所有对它(以及它下面任何节点)的 markNeedsPaint 调用都会被第一行早退吞掉——它自认为已经安排上了,实际上永远没人来画它。
我们的场景正是如此:sliver 子树全员脏但不在列表里,viewport 干净所以也不会被重绘。光标闪烁、setState、随便什么重绘请求,全都石沉大海。
而「点一下标题恢复」的原理是:点击把标题从 Text 换成了 TextField,这是结构性重建——新 RenderObject 挂载时走的是 adoptChild → markNeedsLayout 这条布局通道(布局标记恰好是干净的,能正常传播),布局请求一路传到 relayout boundary(正好是 viewport),viewport 重新布局后末尾会调一次 markNeedsPaint()——此时它自己是干净的,成功进入脏列表 → 重绘 → 把整棵脏子树全画出来。破局的钥匙从来不是「重绘」,而是「重布局」。
中间的弯路(也很有价值)
定位到卡死状态只是开始,「谁制造了这个状态」才是重头戏。这中间我走了不少弯路,捡几个值得记的:
弯路一:怀疑 Impeller。 模拟器上跑的是 Impeller OpenGLES 后端,而且排查中一度出现「强制全树重绘也没反应、退后台回前台都不恢复」的更深卡死(疑似帧管线也停摆了,surface 重建后才消失,没有深究)。于是给 debug 包单独关掉 Impeller 换 Skia 验证:
1 | <!-- android/app/src/debug/AndroidManifest.xml --> |
照样空白。 排除渲染后端,这是框架层的问题。
弯路二:怀疑 OpenContainer。 编辑页是用 animations 包的 OpenContainer(容器变换动画)打开的,它的转场机制确实很可疑:前 1/5 时间开页内容 opacity 恒为 0(opacity 为 0 时整棵子树跳过绘制),动画结束帧再通过 GlobalKey 把整个页面子树重挂载到最终位置。「从未绘制过的子树被重挂载」看起来简直是完美嫌疑人。我把 OpenContainer 换成普通的 MaterialPageRoute——照样空白。排除。
弯路三:延迟挂载主体。 尝试让滚动区域等路由转场结束后再挂载(避开转场期间的绘制跳过),甚至再多延一帧——照样空白,新挂载的 viewport 呈现出一模一样的卡死签名。毒源在页面自己身上。
顺带记两个好用的排查工具。一个是给本地 Flutter SDK 直接加日志桩(改 packages/flutter/lib/src/rendering/layer.dart 再重编 APK 即可生效,排查完 git checkout 还原),我用它证明了 root layer 从未被 detach;另一个是线程原生堆栈:
1 | adb root |
定音之锤:per-RenderObject 绘制时序
VM service 还有两个少有人用的扩展:ext.flutter.profileRenderObjectPaints 和 ext.flutter.profileRenderObjectLayouts,开启后每个 RenderObject 的布局/绘制都会打进 timeline。配合 getVMTimeline 把打开页面全过程录下来,看到了决定性的一幕:
1 | 408081 B RenderViewport ← 布局阶段:viewport 开始布局 |
viewport 明明刚布局完两个 sliver,绘制时却一个孩子都没画。答案在 RenderObject._paintWithContext 的开头:
1 | // If we still need layout, then that means that we were skipped in the |
也就是说:在同一帧内,sliver 布局完成之后、绘制开始之前,有什么东西把它们重新标记了 needs-layout。 绘制阶段 viewport 尽职地遍历孩子,孩子却因为「欠布局」被静默跳过;而 viewport 自己的脏标记在 _repaintCompositedChild 里已经被清掉、layer 也被 removeAllChildren() 清空——「干净的空 boundary + 全脏的子树」这个死锁状态就这样铸成了。框架注释里那句 “the parent will schedule another paint” 的乐观假设,在这个时序下并不成立。
真凶:LayoutBuilder
什么东西会在布局阶段中途搞事情?——LayoutBuilder,它的 builder 就是在布局阶段执行的。我的编辑页正文阅读态里有一个:
1 | Widget _buildReadContent() { |
把它临时干掉再跑——冷启动首开,页面完美渲染。反复验证若干轮(冷启动、热复开、FAB 新建、OpenContainer 转场恢复原样),全部正常。
修复也很简单:这个 LayoutBuilder 本来只是为了拿换行宽度给点击换算光标用,而点击发生时布局早就完成了,直接从 RenderBox 现取就行:
1 | Widget _buildReadContent() { |
功能完全等价(两者布局位置相同、宽度一致),绘制路径干净。
至于为什么 release 没事:这个竞态需要「路由转场 + 掉大帧」的时序窗口,debug 的 JIT 首开一帧要编译大量代码(上面 timeline 里一个 sliver 布局就花了 21ms),窗口被撑得很大,几乎必现;release 是 AOT,帧时序紧凑,基本碰不上——但理论上极端卡顿时也可能中招,所以这不是一个「debug 专属」的假问题。
环境:Flutter 3.44.2 stable。搜遍了 flutter/flutter 的 issue(_skippedPaintingOnLayer、NEEDS-PAINT stuck、blank until tap 等关键词)和 3.44.x 的 hotfix 记录,没有找到已知报告,应该是个还没被上报的框架 bug,后面打算整理个最小复现提给官方。
总结
- 「debug 空白、点击恢复、release 正常」这类症状,先查 sliver 里的 LayoutBuilder,别急着怀疑 Impeller 或者转场动画库;
markNeedsPaint的早退 +_paintWithContext对欠布局节点的静默跳过,组合起来能造出「永不重绘」的死锁,且 logcat 一片祥和——没有异常不代表没有问题;- 渲染树里「boundary 干净但子树全 NEEDS-PAINT + layer 树里对应 OffsetLayer 为空」是这个死锁的标准签名,遇到可以直接对号入座;
- 直装的 debug APK 也能用 VM service 做深度排查:HTTP GET 就能调
ext.flutter.*扩展(dump 渲染树/layer 树、强制重绘、per-RenderObject profiling),不依赖flutter run; - 排查渲染问题的证据链条:logcat → 渲染树/layer 树 dump → timeline 帧事件 → per-RenderObject 布局/绘制时序 → 框架源码,一层比一层贵,但一层比一层接近真相。





