Flutter 渲染机制及衍生问题

151 阅读8分钟

再说 Flutter 的渲染机制之前还是先说一下 Flutter 的三棵树。

一. 三棵树

三棵树分别是 Widget 树Element 树 和 RenderObject 树。

1. Widget 树
  • Widget声明式的UI配置。它们是非常轻量级的、不可变的类,只包含最终的UI样子的信息(如颜色、字体、尺寸、布局方向等)。

  • 核心特点

    • 不可变 :一旦创建就不能修改。要改变UI,你必须创建一个新的 Widget 实例。

    • 轻量级 :创建和销毁的成本很低。

    • 组合性 :简单的 Widget 可以组合成复杂的 Widget。

  • 职责描述UI元素的配置

2. Element 树
  • ElementWidget实例化对象,它代表了屏幕上某个特定位置的 Widget。它是 WidgetRenderObject 之间的 “粘合剂”  或管理者

  • 核心特点

    • 可变Element 持有状态,并且会更新。

    • 生命周期:它负责管理 Widget 的生命周期(initStatedispose 等)。

    • 高效的关键:它通过比较新旧 Widget 来决定是否需要更新底层的 RenderObjectDiff 算法)。

  • 职责管理UI元素的生命周期持有对 RenderObjectWidget的引用

工作流程

Flutter 构建 UI 时,它会遍历 Widget 树,并为每个 Widget 创建一个对应的 ElementElement 会检查是否已经有一个 RenderObject 与之关联:

  • 如果没有,它就调用 Widget.createRenderObject() 来创建一个。

  • 如果已经有 RenderObject,它会比较新旧 Widget(Widget.canUpdate),如果配置变了,就调用 RenderObject.update() 来更新它, 如果 key 或者 runtimeType 变了就重新创建一个新的RenderObject

3. RenderObject 树
  • RenderObject 是真正负责布局、绘制和命中测试的“实干家”。它们是非常重量级的对象,包含了所有的布局逻辑和几何信息。

  • 核心特点

    • 重量级 :布局和绘制计算非常昂贵。

    • 复用性 :除非绝对必要( Key或者runtimeType 发生改变),否则会被复用,而不是重新创建。

  • 职责

    • 布局 :根据父级给的 约束 ,决定自己的 大小  和 位置

    • 绘制 :将自己绘制到屏幕上(通过 Canvas API)。

    • 合成:将自己放入不同的图层以供高效渲染。

4. 三棵树如何协同工作?(以 setState 为例)
  1. 触发变化:你调用 setState(() { _counter++; }),标记state为“脏”(dirty)。

  2. 重建 Widget 树Framework 调用 build() 方法,生成一棵新的 Widget 树

  3. Diff 过程Flutter 不会推倒重来。它会将新的 Widget旧的 Element进行对比。

    • Flutter 遍历 Element 树,对于每个位置的 Element,用与之对应的新 Widget 和旧 Widget 进行比较。

    • 如果新旧 Widget 的 runtimeType 和 key 相同Flutter 就认为这是同一个逻辑组件,只是配置更新了。它会复用现有的 ElementRenderObject,并让 Element 用新的 Widget 配置去更新(update)RenderObject

    • 如果 runtimeType 或 key 不同,Flutter 就会销毁旧的 ElementRenderObject,然后根据新的 Widget 创建新的 ElementRenderObject

  4. 更新 RenderObject:被标记为需要更新的 RenderObject 会执行布局绘制计算。

  5. 光栅化:更新后的渲染信息被发送到 Skia 图形引擎,最终绘制到屏幕上。

5. 为什么需要三棵树?—— 为了性能

这种设计的核心优势在于:将轻量级的、不可变的配置(Widget)与重量级的、可变的渲染对象(RenderObject)分离开。

  • 高效更新:通过 Diff 算法和 Element 树的协调,可以最小化对 RenderObject 树的操作。只有真正需要变化的部分才会触发昂贵的布局和绘制计算。

  • 声明式UI的性能:它让 Flutter 在享受声明式UI开发便利的同时,获得了接近原生命令式UI的高性能。

总结:Widget 描述配置,Element 管理生命周期协调 WidgetRenderObjectRenderObject 负责实际的渲染工作。三棵树各司其职,共同构成了 Flutter 高效渲染的基石。

如图所示:

二. 渲染过程
1. Build(构建)阶段
  • 输入:声明式代码(Widget 树)。

  • 输出:创建或更新 Element Tree

  • 过程

    • 当 build() 方法被调用时,Flutter 会根据你的代码生成新的 Widget 树。

    • Flutter 会将新的 Widget 树与旧的 Widget Tree 进行对比(Diff)

    • Element 是 Widget 的实例化对象,是连接“善变”的 Widget 和“稳定”的 RenderObject 的“粘合剂”。

2. Layout(布局)阶段
  • 输入:来自父级的约束 (例如,“你的最大宽度是屏幕宽度”)。

  • 输出:每个 RenderObject 的大小(Size)和位置(Position)

  • 过程

    • 这是一个自上而下的传递约束、自下而上的确定大小的过程。
3. Paint(绘制)阶段
  • 输入:已经确定好大小和位置的 RenderObject

  • 输出:一系列的绘制指令 ,最终由 Skia 图形引擎转化为像素。

  • 过程

    • 每个 RenderObject 都有一个 paint 方法,它接收一个 Canvas 对象。

    • RenderObject 通过在 Canvas 上调用 API(如 drawRectdrawTextdrawImage)来记录如何绘制自己。

    • 这些指令被优化后,交给底层的 Skia 图形引擎进行光栅化 ,即转化为实际的像素点,显示在屏幕上。

过程如下图所示:

三. 在构建 UI 时,三棵树是怎么变化的,谁是变化的,谁是不变的?
  1. Widget 不变

    • Widget 本身是不可变的 。它们只是配置信息。当状态改变时,不是修改旧的 Widget,而是创建一个新的 Widget 对象

    • 原因:  因为创建轻量级的、不可变的 Widget 对象开销非常小。这使得上图“核心差异化过程”中的 Diff 对比过程非常快。

  2. Element 可变可不变

    • 如果 Widgetkey 或者 runtimeType没有变化,Element 复用之前的 Element 只是更新配置即可。

    • 如果 Widgetkey 或者 runtimeType发生变化,旧的 Element 会被卸载,会创建一个新的 Element 树。

    • 比如以下代码 Element会被重新创建

// 初始状态
Container(width: 100, height: 100) -> 创建 RenderDecoratedBox
// 状态改变后
Text('Hello') -> 创建 RenderParagraph
// 初始状态
Container(key:key1,width: 100, height: 100)
// 状态改变后
Container(key:key2,width: 100, height: 100)
  1. RenderObject 可变可不变

    • RenderObject 是稳定且可复用的。除非 Widget 的 runtimeType 发生变化,否则 Flutter 在 Diff 后会复用现有的 RenderObject,只是用新 Widget 的配置去更新它(例如,更新颜色、大小等属性)。

    • Widget 的 key 发生变化对 RenderObject带来的影响又分为两种

        1. 发生根本变化 由key1->key2,RenderObject会被重建。
        1. 当 Widget 类型相同但 key 不同时(常见于列表),Flutter 会匹配 key 来对 Element 和 RenderObject 进行重新排序,而不是销毁和重建。(列表复用)
    • 原因:  RenderObject 负责的布局和绘制计算是昂贵的。复用它们意味着避免了昂贵的重新计算。布局和绘制结果也经常被缓存。

  2. 状态(State)是变的

    • State 对象是 (可变的)setState() 方法的作用就是标记这个 State 对象为“脏”(dirty),从而在下一帧触发 build() 方法,用新的状态数据来创建新的 Widget状态是变化的源泉。

整体过程图解

Element 和 RenderObject 变化详细图解

四. Element的职责详解

如果说 Widget 是“设计图纸”,那么 Element 就是根据设计图纸施工并负责维护的“项目经理”。它确保了 Widget 所描述的UI组件能够正确地被创建、更新和销毁。

1. 创建与挂载

当一个新的 Widget 第一次被插入到树中时,Framework 会为其创建一个对应的 Element,并调用其 mount方法。

  • mount 方法:这是生命周期的起点。在这个方法中,Element 会:

    1. 将自身添加到 Element 树中的正确位置。

    2. 调用 Widget.createRenderObject() 来创建并关联对应的 RenderObject(如果这个 Widget 是一个 RenderObjectWidget,比如 Text 或 Image)。

    3. 如果这个 Widget 是 StatefulWidget,Element 会负责创建并关联其 State 对象,这是最关键的一步!

    4. 然后,它会调用 State 的 initState()  方法。这是执行一次性初始化操作的理想位置(例如,订阅一个 Stream,初始化复杂的变量)。

dart

// 伪代码示意
void mount() {
  super.mount();
  _renderObject = widget.createRenderObject(this); // 创建RenderObject
  attachRenderObject(_renderObject); // 将RenderObject加入渲染树
  if (isStatefulWidget) {
    _state = widget.createState(); // 创建State对象
    _state._element = this; // 将Element赋值给State
    _state._widget = widget; // 将Widget赋值给State
    _state.initState(); // 调用initState生命周期
  }
}

2. 更新

当父组件重建并提供了一个新的 Widget(与旧 Widget 的 runtimeType 和 key 相同)时,Element 需要更新。

  • update 方法Element 会持有对新的 Widget 的引用,并调用:

    1. 如果 Widget 是 StatefulWidget,它会调用 State 的 didUpdateWidget(oldWidget)  方法。你可以在这里比较新旧 Widget 的配置,并决定是否需要做一些响应操作(例如,如果某个配置变了,就取消旧的订阅并开启新的订阅)。

    2. 调用 RenderObject.update(newWidget) 或用新的配置去更新关联的 RenderObject(例如,更新文本颜色或大小)。

dart

// 伪代码示意
void update(newWidget) {
  super.update(newWidget);
  if (isStatefulWidget) {
    _state.didUpdateWidget(oldWidget); // 通知State Widget更新了
  }
  _renderObject.update(newWidget); // 更新RenderObject
  _widget = newWidget; // 更新持有的Widget引用
}

3. 销毁与卸载

Widget 被从树中永久移除时,Element 负责清理工作。

  • unmount 方法:这是生命周期的终点。在这个方法中,Element 会:

    1. 调用 State 的 dispose()  方法(如果存在)。这是执行清理操作的唯一机会,例如取消所有订阅、停止动画控制器、释放资源。一旦 dispose 被调用,State 对象就永远不会再被重建。

    2. 销毁关联的 RenderObject。调用 RenderObject.dispose() 来释放渲染相关的资源。

    3. 将自身从 Element 树中分离。

dart

// 伪代码示意
void unmount() {
  if (isStatefulWidget) {
    _state.dispose(); // 调用dispose进行清理
  }
  _renderObject.dispose(); // 销毁RenderObject
  super.unmount(); // 从树中脱离
}

具体过程如下图所示: