Appearance
Flutter 三棵树与 Layer Tree:Widget / Element / RenderObject / Layer
回到总览:性能、测试、排障相关模块:Flutter Impeller 渲染引擎架构与 Shader 编译卡顿消除原理 · Flutter 产物瘦身、AOT 拆包与符号化堆栈还原 (Deobfuscation) · SurfaceFlinger、BufferQueue 与三重缓冲:帧如何合成上屏
一句话定义
Flutter 用 Widget 树(不可变配置)、Element 树(持久化、持有状态与生命周期)、RenderObject 树(真正 layout/paint) 三棵树分离「声明」「实例」「渲染」职责;在 paint 阶段还会生成 Layer 树,由 Engine 交给 GPU(Impeller/Skia)并最终合成上屏——理解这四层(含 Layer)才能解释「为什么加 const 能省重建」「为什么 RepaintBoundary 能止住卡顿」。
代码索引
对应 Lab:flutter-three-trees ——
debugDumpApp/debugDumpRenderTree对照const与RepaintBoundary对 Layer 的影响
为什么需要
- 为什么 Flutter 要分「三棵树」而不是像 Android View 那样一棵树搞定?
- 一句话答:Widget 是廉价不可变的「配置描述」,每帧可大量重建;Element 才是昂贵的「持久实例」并持有 State,只有它决定要不要真的重建;RenderObject 负责 layout/paint。分离后,廉价重建 Widget 不会连累昂贵的 Element/RenderObject,这正是 Flutter 高性能的关键。
- 为什么「没加
const」会让列表滑动掉帧?- 一句话答:不加
const的子 Widget 每帧都生成新实例,Element 走updateChild时因「新 Widget 不能canUpdate复用旧 Element」而触发整段子树重建,build 范围扩大 → 主 isolate 耗时 → jank(详见 §底层机制.1)。
- 一句话答:不加
- 为什么
RepaintBoundary能解决「一个动画带动整页重绘」?- 一句话答:paint 阶段每个 RenderObject 默认把绘制指令压进同一 Layer;用
RepaintBoundary把子树切成独立 Layer 后,子树的重绘只在自己的 Layer 上发生,不会污染父 Layer、不会触发整页 raster(详见 §底层机制.3)。
- 一句话答:paint 阶段每个 RenderObject 默认把绘制指令压进同一 Layer;用
底层机制
1. 三棵树的关系与 Diff 复用
Widget 不可变、每帧重建;Element 在两次 build 之间被复用,靠 canUpdate(runtimeType + key 相同)判断能否复用旧 Element,从而避免昂贵的重建。
- 可能输出(
debugDumpApp片段示意)MyPage └─ ConstTitle(const) // 标记 const,Element 复用 └─ ListView └─ ItemWidget(state: #3) // 未 const,滚动时频繁 rebuild - 预期现象:
constWidget 在两次 build 间生成「相等」的 Widget,Element 直接复用、跳过子树重建;未const的同级 Widget 一旦父级 rebuild 就跟着重建。
2. RenderObject 的 layout / paint
RenderObject 是唯一真正参与布局与绘制的节点:layout() 递归确定尺寸,paint() 把绘制指令写入 PaintContext(默认落到父 Layer)。
- 预期现象:布局是自上而下约束传播(parent 给约束、child 回报尺寸),绘制是把指令录制进 Layer;只有「几何/内容真的变了」才需重 layout/paint,否则标记为脏等待下一帧。
3. Layer 树与 RepaintBoundary
paint 阶段会生成 Layer 树。普通情况下一个 Widget 子树绘制进同一个 Layer;RepaintBoundary 让它拥有独立 Layer,子树重绘只在自己的 Layer 局部发生。
- 可能输出(无
RepaintBoundary时 vs 有)// 无边界:动画每帧重绘 -> 整个页面 Layer 失效重 raster // 有边界:动画 Layer B 独立重绘,父 Layer 复用上一帧 - 预期现象:独立 Layer 让「局部重绘」不向上传播,是治理 Flutter 重绘卡顿的核心手段;但 Layer 不是越多越好——过多独立 Layer 也会增加合成开销,需权衡。
Android / Flutter / Web / Backend 对照
| 维度 | Android View | Flutter | iOS(Core Animation) | Web |
|---|---|---|---|---|
| 树模型 | View 树(单树,含状态) | Widget/Element/RenderObject 三树 | UIView 树 + CALayer 树 | DOM 树 + Render 树 + Layer 树 |
| 复用判定 | 无(对象即状态) | canUpdate(type+key) | Layer 隐式复用 | 虚拟 DOM diff |
| 重绘隔离 | invalidate 区域 | RepaintBoundary 独立 Layer | shouldRasterize / 独立 Layer | will-change / 合成层 |
| 合成出口 | SurfaceFlinger | Impeller/Skia → Surface | Render Server(CATransaction) | Compositor |
常见场景
- 长列表卡顿:列表项 Widget 未
const、或构建过重,滚动时主 isolate build 耗时超 16ms → jank;解法:抽const构造函数、AnimatedBuilder收窄监听范围。 - 局部动画带崩整页:一个旋转图标放在无边界的复杂页面里,每帧整页 raster;解法:给动画子树包
RepaintBoundary,把重绘关进独立 Layer。 - 误用 Opacity 导致离屏:
Opacity包裹大子树会强制离屏渲染再合成;解法:对简单图形用Color透明替代,或把Opacity下推到叶子、配RepaintBoundary。
常见误配、事故后果与排障
1. 误配:滥用 GlobalKey 导致 Element 无法复用、频繁整树重建
- 现象:为「拿到 State」到处用
GlobalKey,结果子树每次都判为不可复用而重建,列表明显掉帧。 - 排障与修法:
GlobalKey只在该 Widget 需跨子树保身份时使用;常规复用交给const+ 局部ValueKey/ObjectKey,避免全局唯一 key 拖垮 diff。
2. 误配:在 build 里做重计算,放大 rebuild 代价
- 现象:
build里同步解析 JSON / 构造大对象,父级一 rebuild 就重算,主 isolate 卡住。 - 排障与修法:重计算移出
build(放initState/缓存/ isolate),build只做廉价的 UI 描述;配合const让不必要的 rebuild 直接短路。
3. 事故:滥用 RepaintBoundary 反而合成变卡
- 后果:给过多子树套边界,Layer 数量暴涨,Engine 合成(而非 raster)成为新瓶颈。
- 排障与修法:只对「确实频繁局部重绘」的子树加边界;用 DevTools 的 Layer / Performance 视图确认瓶颈在 raster 还是 composite,再决定加不加边界。
相近概念对比
| 概念 | 它是什么 | 易混点 |
|---|---|---|
| Widget 树 | 不可变配置,每帧可重建 | 不是「UI 实例」,重建它很廉价 |
| Element 树 | 持久实例,持有 State | 决定要不要真重建,靠 canUpdate 复用 |
| RenderObject 树 | layout/paint 的执行体 | 唯一真正占渲染成本的树 |
| Layer 树 | paint 产物,交给 Engine 合成 | 独立 Layer = 重绘隔离,但非越多越好 |
对应实验
| 实验 | 说明 | 关键源码 |
|---|---|---|
| flutter-three-trees | debugDumpApp/debugDumpRenderTree 对照 const 前后 rebuild 范围,观察 RepaintBoundary 对 Layer 的影响 | 代码内联于 README |
复习检查题
为什么 Flutter 把 Widget 设计成不可变、每帧可大量重建,却还能高性能? 答:因为真正昂贵的是 Element 与 RenderObject,而 Widget 只是廉价配置。每帧重建 Widget 后,Element 树用
canUpdate(runtimeType + key 相同)判断能否复用旧 Element;能复用就跳过子树重建,只更新必要部分。所以「重建 Widget」不等于「重建 UI 实例」,廉价配置 + 持久实例的分离正是高性能来源。const构造函数为什么能减少 rebuild?它的作用边界在哪? 答:const让同一处创建的 Widget 在多次 build 间是「同一个常量实例」,Element 比较新旧 Widget 直接相等、判定可复用,从而短路子树重建。边界是:它只对「不依赖运行时变量、纯静态」的子 Widget 有效;一旦子 Widget 依赖变化数据,就不能const,否则不会更新。RepaintBoundary解决什么问题?为什么不能无脑到处加? 答:它把子树 paint 切成独立 Layer,使子树的局部重绘只在自己 Layer 发生,不污染父 Layer、不触发整页 raster,从而止住「一个动画带崩整页」的卡顿。但不能无脑加:每个独立 Layer 都增加 Engine 的合成负担,过多边界会让瓶颈从 raster 转移到 composite,需按 DevTools 实测定位再加。
速记
- 三棵树:Widget 配置(廉价、每帧重建)/ Element 实例(持久、canUpdate 复用)/ RenderObject 真渲染。
- const 省 rebuild:新旧 Widget 相等 → Element 复用 → 子树短路。
- RepaintBoundary:独立 Layer = 重绘隔离,但 Layer 多 = 合成贵。
- 出口:Layer 树 → Impeller/Skia → Surface → 上屏(接 02 的 SurfaceFlinger)。