文章

口述:Flutter 三棵树渲染流程深度解析

口述题:把 Flutter 从 setState 到屏幕像素的完整渲染流程讲顺——三棵树如何构建、diff、布局、绘制。 掌握后能流畅口述渲染管线,应对原理级追问。

口述:Flutter 三棵树渲染流程深度解析

一句话概括

Flutter 用三棵树分工协作:Widget 是配置(轻量、随时重建)→ Element 是管家(持有 State、做 diff)→ RenderObject 是画家(布局+绘制),从 runApp() 到屏幕像素一共六步:Widget 声明 → Element 挂载 → RenderObject 创建 → Layout → Paint → GPU 合成。

核心知识点

1. 三棵树的职责分工

1
2
3
4
5
6
7
8
// Widget = 配置文件(immutable,一次性的)
// Element = 实例管理器(mutable,长生命周期)
// RenderObject = 布局和绘制引擎

// 类比:盖房子
// Widget  = 设计图纸(便宜,随时换)
// Element = 项目经理(拿着图纸指挥施工)
// RenderObject = 施工队(实际干活,计算尺寸、画到墙上)

三棵树不是物理上隔离的三份独立数据——它们通过指针连在一起:Element 持有 widget 和 renderObject 引用,从 Element 出发可以走到另外两棵树的任意节点。

2. runApp 启动时的深度优先构建

1
2
3
4
5
6
7
8
9
10
// runApp(MyApp()) 启动后的调用链:
// ① WidgetsBinding 创建 RenderView(RenderObject 根)
// ② buildOwner 创建 RootRenderObjectElement
// ③ Element.mount() → 调用 Widget.build()
// ④ build 返回子 Widget → 递归 createElement → mount → build...
// 深度优先,一路向下直到叶子节点

// MaterialApp → Scaffold → Center → Text
// 每层:Widget.build() → createElement → mount → build → 下一层
// 到 Text:createRenderObject() → 创建 RenderParagraph

3. Element.updateChild——diff 的核心入口

1
2
3
4
5
6
// 每次 build() 返回新 Widget 后,Element.updateChild 判断:
// ① 新 Widget 和旧 Widget 同类型 + 同 key?→ Element.update(newWidget)
//    只更新配置引用,不重建 Element — 复用!
// ② 类型不同或 key 不同?→ 旧 Element.unmount() + 新.createElement()
// ③ 新 Widget == null?→ 旧 Element.unmount()(删除)
// ④ 旧 Element == null?→ 新.createElement()(新增)

4. Layout 阶段——自底向上

1
2
3
4
5
6
7
8
9
// 父 RenderObject 调用 child.layout(constraints)
// 子拿到 Constraints(min/max Width × Height)→ 计算自己的 Size
// 子把 Size 返回给父 → 父根据子的尺寸决定摆放位置
// 类比:父说"你最多 300 宽"→ 子算出自己需要 200 → 父把它放在中间

// 三个关键约束规则:
// ① 父传约束给子(自上而下)
// ② 子决定自己的尺寸(自下而上)
// ③ 父决定子的位置(自己算)

5. Paint 与合成——分层画布

1
2
3
4
5
// Paint 按 Layer 分层:先画背景,再画内容,最后画前景
// RepaintBoundary 是性能关键:
//   wrapping child in RepaintBoundary → 子有自己的 Layer
//   子重绘时不影响父/兄弟的已光栅化缓存
// 最终 Layer Tree → Engine 的 Compositor 合成 → GPU 上屏

其实你每天都在用

  • setState:setState(() => _count++) → Element 标脏 → 下一帧 build → updateChild diff → 只更新 Text 这个叶子
  • const 构造:const Text('hello') → 编译期常量,Widget 引用不变 → updateChild 秒判相等 → 跳过整棵子树
  • RepaintBoundary:RepaintBoundary(child: ComplexChart) → 图表重绘不触发外层滚动区域的重绘
  • key 的作用:列表重排时通过 key 找到对应的 Element 复用 → 状态不丢、动画不断
  • LayoutBuilder:LayoutBuilder(builder: (ctx, constraints) => ...) → constraints 就是从父级传下来的那个 BoxConstraints

常见误解(FAQ)

  • ❌ 误区:「Widget 树就是 UI 树」 准确说 Widget 是 UI 的声明,不是 UI 本身。真正的 UI 树是 RenderObject 树——它才有尺寸、位置、绘制指令。Widget 随时被丢弃重建(immutable),RenderObject 只有必要时才重建。

  • ❌ 误区:「三棵树内存开销巨大」 三棵树通过指针关联,不是三份独立拷贝。Widget 和 Element 都是轻量对象,重的是 RenderObject(持有 Layer、绘制指令缓存)。且 Widget 是短命的,build 后旧 Widget 就被 GC 了。

  • ❌ 误区:「build() 返回的 Widget 是同一棵树上修改」 build() 每次返回的是全新的 Widget 子树。框架通过 Element.updateChild 来把这个新 Widget 树”嫁接”到已有的 Element 树上,只更新变了的部分——这就是 diff 的本质。

  • ❌ 误区:「Layout 阶段是自顶向下的」 自顶向下传的是约束,自底向上传的是尺寸。子不能违反父给的约束,但可以在约束范围内自由决定自己的大小——比如 Expanded 会占满父给的剩余空间。

一句话总结

记住这个面试金句:Widget 说”我要什么样”,Element 说”现在有什么,该怎么变”,RenderObject 说”我画在哪、画多大”——三棵树分工明确,谁也别越界。

本文由作者按照 CC BY 4.0 进行授权