背景

事情是这样的,最近在开发一个 Flutter 应用(AI 悬浮剪贴板 ClipDash),发现一个非常诡异的问题:

  1. debug 版运行后,打开编辑页面,从标题到正文这一整块区域全是空白的,只有 AppBar 图标、底部的标签栏和按钮正常显示;
  2. 盲点一下标题区域,所有 UI 又都出来了,像什么都没发生过;
  3. 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
2
3
adb logcat -d | grep "Dart VM service"
# The Dart VM service is listening on http://127.0.0.1:36067/o0HjThCdw-w=/
adb forward tcp:36067 tcp:36067

很多人不知道的是,VM service 除了 WebSocket 还支持 HTTP GET 直接调 RPC,连 DevTools 都不用开:

1
2
3
4
# 拿 isolate id
curl "http://127.0.0.1:36067/TOKEN/getVM"
# dump 渲染树
curl "http://127.0.0.1:36067/TOKEN/ext.flutter.debugDumpRenderTree?isolateId=isolates/xxx"

在空白状态下 dump 渲染树,一眼看到不对劲的地方——布局数据全部正常(尺寸、偏移都对),但从 sliver 往下整棵子树都挂着 NEEDS-PAINT

1
2
3
4
└─child: RenderViewport#2409b
│ ...
├─center child: RenderSliverToBoxAdapter#26ecb relayoutBoundary=up1 NEEDS-PAINT
└─child 1: RenderSliverFillRemaining#55eef relayoutBoundary=up1 NEEDS-PAINT

注意:viewport 自己是干净的(没有 NEEDS-PAINT),只有它的孩子们全是脏的。再 dump layer 树(ext.flutter.debugDumpLayerTree),实锤了:

1
2
3
4
├─child 2: OffsetLayer#8fbbe
│ creator: Viewport ← IgnorePointer ← ... ← CustomPaint ← ImageFiltered ← ⋯
│ offset: Offset(0.0, 80.0)
│ (没有任何子 layer!)

viewport 这个 repaint boundary 的 OffsetLayer 位置正确(AppBar 下方 80 逻辑像素),但里面是空的。也就是说:某一帧 viewport「绘制」了一次,但一个孩子都没画进去,然后它把自己的脏标记清掉了。

为什么点一下就能恢复:markNeedsPaint 的早退陷阱

看一下框架源码 RenderObject.markNeedsPaint 的开头:

1
2
3
4
5
6
7
8
9
10
void markNeedsPaint() {
if (_needsPaint) return; // ← 已经脏了就直接返回,不会再往上传播
_needsPaint = true;
if (isRepaintBoundary && _wasRepaintBoundary) {
owner!._nodesNeedingPaint.add(this); // 只有 boundary 会进脏列表
...
} else if (parent is RenderObject) {
parent.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
2
3
4
<!-- android/app/src/debug/AndroidManifest.xml -->
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />

照样空白。 排除渲染后端,这是框架层的问题。

弯路二:怀疑 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
2
adb root
adb shell debuggerd -b <pid> # raster/UI 线程都在 epoll_pwait 空转,没有死锁

定音之锤:per-RenderObject 绘制时序

VM service 还有两个少有人用的扩展:ext.flutter.profileRenderObjectPaintsext.flutter.profileRenderObjectLayouts,开启后每个 RenderObject 的布局/绘制都会打进 timeline。配合 getVMTimeline 把打开页面全过程录下来,看到了决定性的一幕:

1
2
3
4
5
6
7
8
9
10
408081  B  RenderViewport            ← 布局阶段:viewport 开始布局
408112 B RenderSliverToBoxAdapter
409503 E RenderSliverToBoxAdapter
409517 B RenderSliverFillRemaining ← 两个 sliver 都完整布局了(21ms,JIT 很慢)
431035 E RenderSliverFillRemaining
431037 E RenderViewport
...
431435 B PAINT (root) ← 同一帧的绘制阶段
432453 B RenderViewport ← viewport 绘制,但是——
438501 E RenderViewport ← 中间没有任何 sliver 的绘制事件!

viewport 明明刚布局完两个 sliver,绘制时却一个孩子都没画。答案在 RenderObject._paintWithContext 的开头:

1
2
3
4
5
// If we still need layout, then that means that we were skipped in the
// layout phase and therefore don't need painting. ...
if (_needsLayout) {
return; // ← 静默跳过,连 profiling 事件都不发
}

也就是说:在同一帧内,sliver 布局完成之后、绘制开始之前,有什么东西把它们重新标记了 needs-layout。 绘制阶段 viewport 尽职地遍历孩子,孩子却因为「欠布局」被静默跳过;而 viewport 自己的脏标记在 _repaintCompositedChild 里已经被清掉、layer 也被 removeAllChildren() 清空——「干净的空 boundary + 全脏的子树」这个死锁状态就这样铸成了。框架注释里那句 “the parent will schedule another paint” 的乐观假设,在这个时序下并不成立。

真凶:LayoutBuilder

什么东西会在布局阶段中途搞事情?——LayoutBuilder,它的 builder 就是在布局阶段执行的。我的编辑页正文阅读态里有一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Widget _buildReadContent() {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth; // 只是为了拿文本换行宽度
return GestureDetector(
onTapUp: (details) {
// 用 maxWidth 把点击坐标换算成光标位置(点哪儿光标落哪儿)
final offset = _offsetForTap(details.localPosition, maxWidth);
_enterEditMode(contentOffset: offset);
},
child: Linkify(...),
);
},
);
}

把它临时干掉再跑——冷启动首开,页面完美渲染。反复验证若干轮(冷启动、热复开、FAB 新建、OpenContainer 转场恢复原样),全部正常。

修复也很简单:这个 LayoutBuilder 本来只是为了拿换行宽度给点击换算光标用,而点击发生时布局早就完成了,直接从 RenderBox 现取就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Widget _buildReadContent() {
return Builder(
builder: (context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
final box = context.findRenderObject() as RenderBox?;
final maxWidth = box?.size.width ?? double.infinity;
final offset = _offsetForTap(details.localPosition, maxWidth);
_enterEditMode(contentOffset: offset);
},
child: Linkify(...),
);
},
);
}

功能完全等价(两者布局位置相同、宽度一致),绘制路径干净。

至于为什么 release 没事:这个竞态需要「路由转场 + 掉大帧」的时序窗口,debug 的 JIT 首开一帧要编译大量代码(上面 timeline 里一个 sliver 布局就花了 21ms),窗口被撑得很大,几乎必现;release 是 AOT,帧时序紧凑,基本碰不上——但理论上极端卡顿时也可能中招,所以这不是一个「debug 专属」的假问题。

环境:Flutter 3.44.2 stable。搜遍了 flutter/flutter 的 issue(_skippedPaintingOnLayerNEEDS-PAINT stuck、blank until tap 等关键词)和 3.44.x 的 hotfix 记录,没有找到已知报告,应该是个还没被上报的框架 bug,后面打算整理个最小复现提给官方。

总结

  1. 「debug 空白、点击恢复、release 正常」这类症状,先查 sliver 里的 LayoutBuilder,别急着怀疑 Impeller 或者转场动画库;
  2. markNeedsPaint 的早退 + _paintWithContext 对欠布局节点的静默跳过,组合起来能造出「永不重绘」的死锁,且 logcat 一片祥和——没有异常不代表没有问题
  3. 渲染树里「boundary 干净但子树全 NEEDS-PAINT + layer 树里对应 OffsetLayer 为空」是这个死锁的标准签名,遇到可以直接对号入座;
  4. 直装的 debug APK 也能用 VM service 做深度排查:HTTP GET 就能调 ext.flutter.* 扩展(dump 渲染树/layer 树、强制重绘、per-RenderObject profiling),不依赖 flutter run
  5. 排查渲染问题的证据链条:logcat → 渲染树/layer 树 dump → timeline 帧事件 → per-RenderObject 布局/绘制时序 → 框架源码,一层比一层贵,但一层比一层接近真相。