2026-05-15 | Flutter · 大前端跨端技术
三天内连撞两个白屏 bug,表象一模一样------进入页面整片纯白,没有红屏,没有报错,build() 正常跑了,数据也拿到了,调试工具能探测到完整的 widget tree,但屏幕上就是没有像素。
更让人抓狂的是:第一次排查了大半天才定位;两天后第二次出现,以为是同一个坑,结果根因完全不同,又走了一大圈弯路。
这篇文章把两个 bug 的根因、定位过程、以及"为什么 AI 协作时反复栽倒在这类问题上"一次讲清楚。
案例一:批量导入页白屏------Silent Layout Failure
现象
页面叫「批量导入内容」,打开后 AppBar 在、底部按钮也在,但整个 Scaffold.body 一片纯白。打开自研的「调试小球」(全局 layout overlay 工具)戳白色区域------widget tree 完整存在,坐标和尺寸都算出来了,但没有任何像素被画上去。
这是一个典型的 silent layout failure :Flutter 的三个 pass(build → layout → paint)在 layout 阶段出了问题,但没有抛任何异常。
排查路径
第一轮:怀疑数据态 --- 打日志发现 build() 正常执行、Provider 数据正常。排除。
第二轮:怀疑 Scaffold --- AppBar 和 BottomNav 都在,说明 Scaffold 自己没问题。排除外层。
第三轮:色块隔离法(命中) --- 这是这次复盘最值钱的方法论。当 widget 树存在但屏幕没像素时**,给可疑容器的每段 child 包上不同颜色的半透明 ColoredBox**:
less
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ColoredBox(color: Color(0x6600BCD4), child: 段1_顶部卡片),
ColoredBox(color: Color(0x669C27B0), child: 段2_已添加标题),
ColoredBox(color: Color(0x66FFEB3B), child: 段3_空态提示),
ColoredBox(color: Color(0x662196F3), child: 段4_末尾留白),
],
),
);
判定规则很简洁:
• 某段颜色不可见、其它正常 → 那段内部有问题
• 所有段颜色都不可见 → 罪魁在包住所有段的外层容器
• 某段颜色可见但内容不见 → 那段的某个 child 自身 layout 失败
跑一下:4 个色块全部不可见 。罪魁锁定在外层:SingleChildScrollView + Column(stretch)。
根因:SingleChildScrollView + Column(stretch) 的隐式不兼容
要讲清楚为什么会"静默失败",得先复习 Flutter 的 layout 协议。Flutter 的 layout 是双向 的:父亲给孩子下传 BoxConstraints(约束),孩子返回自己的 Size。
SingleChildScrollView(默认 vertical)下发给 child 的约束:
• 横向:min = max = 精确宽度(bounded)
• 纵向 :min = 0, max = infinity(unbounded,让 child 自然撑高再用 viewport 滚动)
Column(crossAxisAlignment: stretch) 的语义是让每个 child 在交叉轴方向拉满。当 Column 的子节点中有嵌套的 Row(stretch) + Expanded 且没有内禀高度时,layout 进入一种结果未定义但 framework 不抛异常 的状态------RenderObject.hasSize 保持 false,paint 阶段直接跳过 → 白屏。
修复
less
// 之前
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [...],
),
)
// 之后:换 ListView
ListView(
physics: ClampingScrollPhysics(),
padding: EdgeInsets.zero,
children: [...],
)
为什么 ListView 能修:它自带 viewport,子节点之间 layout 完全隔离,不会共享那条"反向推断 + 交叉轴拉伸"的脆弱链路。横向给每个 child 的约束是精确宽度,行为等价于 stretch。
双保险:顶部的 Row(crossAxisAlignment.stretch) 用 IntrinsicHeight 包一层,让它在垂直方向有一个有界的内禀高度。
案例二:知识库详情页白屏------Delegate-Level Fail-Shut
现象
两天后,进入详情页又见整片纯白 ------这次连 AppBar 都没了。转场动画正常播放,落幕后全白。调试小球能探测到完整 widget tree,业务日志正常显示数据拿到了。
跟上次的表象几乎完全一致。
误判:以为又是同一个坑
复用色块隔离法,在 Scaffold、body、header、content、actionBar 五个层各包一个颜色------5 个全部不可见。结论和上次一样"罪魁在外层"。
于是撤掉了一堆无关改动......没用。
更奇怪的是 layout inspector dump 出来的画面:
ini
RenderCustomMultiChildLayoutBox 411 x 914 ← Scaffold 自己有 size
├─ _ScaffoldSlot.body nosize=true ← 但 child 没 size
└─ _ScaffoldSlot.bottomNav nosize=true
父亲 Scaffold layout 完了拿到了 411×914,但它的两个直接 child hasSize=false。这意味着 _ScaffoldLayout.performLayout 跑了一半就停了,且异常被 framework catch 吞掉了。
转折点:去看 flutter run 终端的红色 stack trace
业务日志再细,也只能看到"build 跑了"。Layout 阶段抛的异常走的是 FlutterError.onError → print 到 stdout,根本不在业务日志里。
重新用命令行启动 App:
bash
flutter run -d <device-id> 2>&1 | tee /tmp/flutter_run.log
复现白屏,控制台立刻给出金矿:
scss
══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞══════
The following assertion was thrown during performLayout():
BoxConstraints forces an infinite width.
The relevant error-causing widget was:
OutlinedButton
item_detail_screen.dart:990:28
根因:Row + 裸 OutlinedButton,叠加 Scaffold 的 fail-shut 行为
出问题的代码:
less
// 作为 Scaffold.bottomNavigationBar 渲染
Row(
children: [
Expanded(flex: 2, child: ElevatedButton.icon(...)),
SizedBox(width: 8),
OutlinedButton.icon(label: Text('笔记')), // 裸的,没 Expanded
if (item.sourceUrl != null) ...[
SizedBox(width: 8),
OutlinedButton.icon(label: Text('原文')), // 也是裸的
],
],
)
Scaffold.bottomNavigationBar 给到的是一个横向 unbounded 的容器。Row 在 unbounded 宽度下,会把 unbounded 直接透传给非 flex 的 child。OutlinedButton.icon 内部的 ConstrainedBox 收到 w=Infinity → 断言爆炸。
关键问题:为什么是"整页白",不是"按钮缺一块"?
这是这两次复盘里含金量最高的一条认知。
Scaffold 内部用的是 MultiChildLayoutDelegate,performLayout 是顺序执行的。它按顺序布局 appBar → body → bottomNavigationBar → FAB**,任何一步抛异常就提前 return**。bottomNav 里抛断言导致:
• positionChild(...) 没机会调,所有 slot 都没被定位
• Framework FlutterError.onError catch 住断言、打到控制台、继续 paint
• paint 时发现 body 和 bottomNav 都 hasSize=false → 跳过 → 整页白
我们把这种行为命名为 "delegate-level fail-shut":
在 MultiChildLayoutDelegate 这种"按顺序布局多个 slot"的容器里,任何一个 slot 抛异常都会让所有 slot 集体不可见。真凶在 bottomNav 里一个最不起眼的按钮,但表象是连 AppBar 都没了。
为什么以前没炸?
之前 _DetailActionBar 只有 2 个按钮,裸 OutlinedButton 在某些屏幕尺寸下凑合走完------临界稳定状态 。后来加了第三个按钮,断言稳定触发。"加了一个按钮就全屏白"听上去离奇,但完全符合分析。
修复
less
// 之前
Row(children: [
Expanded(flex: 2, child: ElevatedButton.icon(...)),
SizedBox(width: 8),
OutlinedButton.icon(label: Text('笔记')), // 裸的
])
// 之后:所有按钮包 Expanded
Row(children: [
Expanded(flex: 2, child: ElevatedButton.icon(...)),
SizedBox(width: 8),
Expanded(flex: 1, child: OutlinedButton.icon(label: Text('笔记'))),
SizedBox(width: 8),
Expanded(flex: 1, child: OutlinedButton.icon(label: Text('原文'))),
])
两次 bug 的对照
| 维度 | 案例一:批量导入 | 案例二:知识库详情 |
|---|---|---|
| 现象 | Scaffold body 白屏 | 整页全白(连 AppBar 都没) |
| 类型 | silent layout failure | delegate-level fail-shut |
| 是否抛异常 | 否(hasSize 静默 false) | 是(被 Scaffold 吞掉) |
| 根因 | ScrollView + Column(stretch) | Row 里裸 Button 收到 w=∞ |
| 修复 | 换 ListView + IntrinsicHeight | Button 包 Expanded |
| 关键证据 | 色块隔离法 | flutter run stdout stack trace |
两条方法论是互补的 :色块隔离法在 silent case 下定位罪魁层;flutter run stdout 在 delegate fail-shut case 下给出精确文件:行号**。未来遇到白屏,两条要并行做。**
为什么 AI 协作时会反复栽倒在这类问题上
两份代码都是 AI 写的(Claude pair-programming),而且两次 AI 都没在第一轮诊断时定位出来。比具体技术坑更值得回答的是------AI 协作模式有什么系统性盲区?
盲区一:AI 写 UI 是"模式拟合",不是"协议推演"
Flutter 的 layout 是一个双向约束传播协议,正确性依赖通盘推演整条父子链才能判定。但 AI 生成 UI 的实际行为是:
• 看到"标题 + 卡片 + 空态" → 套 SingleChildScrollView + Column 这个最常见 idiom
• 看到"两个按钮并列" → 写 Row(children: [Button, Button])
• 看到"按钮要等宽" → 加 crossAxisAlignment: stretch
每一步在局部都看着合理、教科书式,但组合起来就触发了 Flutter 的 unbounded constraint 未定义行为。AI 不会真的在脑子里跑一遍 layout pass,它做的是"这种 widget 通常这样写"的模式拟合。
盲区二:默认只看"业务日志",不看 framework stdout
两次 bug 第一时间 AI 都在让用户"重新打日志发我",要的全是 app 内业务日志。但真正的金矿------flutter run 终端的红色 EXCEPTION CAUGHT BY RENDERING LIBRARY------根本没意识到要看。
根本原因:AI 习惯把 debug = "读结构化日志",但 Flutter layout 错误根本不在结构化日志里。它走的是 print 到 stdout。
盲区三:"build 日志正常 = 没问题"的错误推论
Flutter 的生命周期是 build → layout → paint,这是三个独立 pass 。build 跑完不代表 layout 跑完,layout 跑完不代表 paint 出像素。AI 在 React/Vue 这类"render = output"的范式上训练得太多,默认了"组件函数跑了就有界面"。Flutter 不是这样。
盲区四:"上次的解法 → 这次第一个尝试"偏见
案例二里 AI 第一反应是"以为又是上次同一个坑",完全没意识到同样的表象可以有完全不同的根因。这是典型的 pattern matching 替代根因分析。
盲区五:不知道 Scaffold 是 fail-shut 容器
AI 对 Scaffold 的理解停留在"它是一个布局容器",不知道它是个 fail-shut delegate。这种知识缺口让排查时永远朝错的方向找------直觉指向"页面顶层错",实际罪魁在最不起眼的底部 child。
可带走的方法论
写 UI 前必检的 4 个问题
写任何 Row / Column / SingleChildScrollView / ListView 前,先回答:
• 父亲是 unbounded 哪个轴? SingleChildScrollView → 纵向 unbounded;Scaffold.bottomNav → 横向 unbounded;Row → 横向 unbounded
• children 中有没有 stretch / Expanded 反求宽高? 有的话父亲必须在那个轴上 bounded
• 有没有 Intrinsic 兜底? Row(stretch) 在 unbounded 轴里必须 IntrinsicHeight
• 是否在 Scaffold 的 delegate slot 内? 是的话任何 child 抛异常都会让所有 slot 集体白
强制规则
• 严禁 SingleChildScrollView 直接套 Column(stretch) --- 用 ListView
• 严禁 Row 内放裸的 Button 且 Row 在 unbounded width 容器里 --- 必须 Expanded
• 严禁 Row(stretch) 在 unbounded height 上下文里没 IntrinsicHeight
排查白屏的三步必走顺序
Step 1(30秒) :看 flutter run stdout 是否有红块(EXCEPTION CAUGHT BY RENDERING)
→ 有红块:直接读 stack trace,文件:行号 = 罪魁
→ 无红块:silent failure,进 Step 2
Step 2(3分钟):业务日志 + layout inspector + 色块隔离法
Step 3(10分钟):修复 + 加防御
防御代码:把 framework error 接进业务日志
在 main.dart 启动流程加一段,让 layout 异常也能被业务日志捕获:
ini
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LoggingService.instance.init();
// 接管同步 framework 错误(layout/paint/build assertion)
final originalOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
LoggingService.instance.error(
'flutter_framework',
details.exceptionAsString(),
data: {
'library': details.library,
'context': details.context?.toString(),
},
err: details.exception,
stack: details.stack,
);
originalOnError?.call(details);
};
// 接管未捕获的 async 错误
PlatformDispatcher.instance.onError = (error, stack) {
LoggingService.instance.error(
'flutter_async', error.toString(),
err: error, stack: stack,
);
return false;
};
runApp(MyApp());
}
与 AI 结对时的反偏见清单
• "上次是 X,这次表象一样所以也是 X" --- 表象相似不等于根因相同
• "build 日志正常 = 一定是数据问题" --- Flutter 的 build/layout/paint 是三个独立 pass
• "用户报错日志没东西" --- 先问"你的 flutter run 终端有没有红色 stack?"
• 每次"加一个按钮/加一行 widget"前,意识到这可能把临界稳定的 layout 推向崩溃
总结:从两次白屏中带走什么
如果只能带走三件事:
1. Flutter 的 build/layout/paint 是三个独立 pass,build 跑完不代表能看到像素。 排查白屏时必须分清哪个 pass 出问题。
2. layout 异常的唯一可靠现场是 flutter run 终端 stdout 。 不接 FlutterError.onError 的项目,layout 异常永远不会进业务日志。
3. Scaffold 是 fail-shut 容器,bottomNav 里一个按钮的断言能让整页白。 遇到大面积白屏,罪魁可能在最不起眼的角落。
如果正在和 AI 结对写 Flutter:
4. AI 写 UI 是模式拟合,不是协议推演。 必须人工用上面 4 个问题做 checklist 兜底。
5. 遇到 bug,不要让"上次的解法"绑架"这次的诊断"。 每次从 Step 1 开始,AI 比人更容易掉进这个偏见。
延伸阅读:Understanding constraints | MultiChildLayoutDelegate | FlutterError.onError