几乎所有讲 Flutter 入门开发的文章和书籍,都会给你灌输 Flutter 三棵树的概念。然而在实际的业务开发中,我们绝大部分时间都只是在和 Widget 树打交道,使用 Widget 来构建用户界面,很少需要直接接触到另外两棵树。
而随着对 Flutter 架构的逐渐深入,你必定会对这三棵树的工作机制产生一系列疑问,例如:
- Flutter框架为什么要将渲染机制剥离成三个树?这背后的设计理念是什么?
- Widget树、Element树和Render树三者之间是如何建立关联与转换的?
- Widget树的每一次变更都会导致Element树和Render树重建吗?
- Widget树被销毁重建后,它是如何与原先的Element树重新建立关联的?
- 拥有多个子项的Widget是如何对比前后差异,实现UI的高效更新的?
- 我们应该如何正确构建Widget树,以确保Flutter应用保持快速、高效的渲染?
...
理清这些问题不仅能帮助我们更好地理解 Flutter 的渲染机制,也直接关系到往后我们如何开发出高性能的 Flutter 应用。在这个系列文章中,我们将深入Flutter的核心,结合官方文档、源码分析以及动画演示,逐一地解答这些问题。准备好了吗?
Widget树:代码表示的 VS 最终生成的
让我们从 Flutter 开发者最为熟悉的 build() 方法开始探讨。在这个方法中,我们通过多个 Widget 对象的嵌套组合,形成了一种层次结构关系,用以描述当前应用状态下 UI 的整体布局和外观。
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
child: Row(
children: [
Image.network('https://www.example.com/1.png'),
const Text('A'),
],
),
);
}
在每个渲染帧的回调中,Flutter 框架都有可能会根据变化的状态,调用 build() 方法来重建部分 UI。需要注意的是,在这个过程中,build() 方法可能会动态地引入新的 Widget,导致最终生成的 Widget 树结构比代码中所表示的层级更深。
如上述例子中,当 Container 的 color 属性不为空时,build() 方法就会为其自动引入一个 ColoredBox ,用以处理颜色渲染。
class Container extends StatelessWidget {
@override
Widget build(BuildContext context) {
/// ...
Widget? current = child;
if (color != null) {
current = ColoredBox(color: color!, child: current);
}
/// ...
}
}
类似地,Image 也会在构建过程中引入 RawImage, Text 则会引入 RichText。
这种处理机制源于 Flutter 对 Widget 类的"浅而广"的设计原则,也即追求每个 Widget 尽可能小巧、功能单一,并通过最大限度地增加可能的组合数量,来为 Widget 提供更为丰富、强大的功能。
组合性是 Flutter 最为出众的特性之一。一个 Widget 可以通过组合其他 Widget 的方式进行构建,而这些 Widget 自身也是由更基础的 Widget 组合而成。
这就解释了为什么像边距(Padding)和对齐(Align)这样的基础功能被设计为单独的组件,而不是像传统 UI 框架那样作为每个组件的公共属性,在内部重新实现。
要了解 Flutter 在实际构建过程中都引入了哪些新的 Widget,我们可以使用 Flutter Inspect 工具中的 Widget Tree Detail 面板中进行查看:
这种"隐式" Widget 的引入,虽然简化了开发者的工作,但也潜在地改变了 Widget 树的层次结构,而这种结构上的变化,可能会对 Flutter 的渲染性能产生显著影响,我们将在后续章节中讨论这一点。
Widget 本质:不可变的 UI 声明
尽管 Flutter 对 Widget 的定义是:Widgets 是构建 Flutter 应用界面的基础块。 然而, Widget 并非真正的视图对象,也不会直接绘制任何内容。每个 Widget 实际上都只是一部分不可变的 UI 声明。
什么意思呢?我们都知道 Flutter 采用的是声明式 UI,开发者只需要提供应用状态与 UI 之间的映射关系,而 Flutter 框架则负责在运行时将状态的更改更新到 UI 上。
而在这个过程中,我们所熟知的 build() 方法就是将状态转化为 UI 的关键。Widget 通过重写该方法来声明 UI 的配置,而 Flutter 框架则会根据配置优雅地创建和更新用户界面。
这样的设计可以让开发者把重点放在对 UI 的声明上,而无需关注底层真正的视图对象的实现过程。
实际上,与真正的视图对象相比,Widget 很轻量,并且有着更为短暂的生命周期—— Widget及其与上下节点的关系都是不可变的,一旦需要变化,其生命周期就会终止。
这一点从 Widget 类的定义上就可以看得出来:
@immutable
abstract class Widget extends DiagnosticableTree {}
@immutable 注解指示 Widget 及其所有子类型都必须是不可变的,其本身也不包含可变状态,所有字段都必须是 final 的。
这种不可变性就导致了,对 Widget 树做的任何操作(例如简单地将 Text('A') 替换成 Text('B')),都只能通过返回一个全新的 Widget 对象集合来实现。
将 Widget 树与 Element 树分离的必要性
尽管 Widget 树每次更新时都会被销毁重建,但这并不意味着底层呈现的内容必须完全重构。因为当 Widget 构建完成后,它们会被保留在持有 UI 逻辑结构的 Element 树中。
abstract class Element extends DiagnosticableTree implements BuildContext {
@override
Widget get widget => _widget!;
Widget? _widget;
@mustCallSuper
void update(covariant Widget newWidget) {
_widget = newWidget;
}
}
Element 树在每一帧之间都是持久化的,因此在性能优化上起着至关重要的作用,Flutter 利用这一特性,实现了一种巧妙的机制:表面上看 Widget 树被完全抛弃,实际上却缓存了其底层表示。
Flutter 应用会根据事件交互, 通知 Flutter 框架将层级中的旧 Widget 替换为新 Widget ,然后只重建 Element 树中需要更新的部分,从而高效地更新用户界面。
重用 Element 能够极大程度地提升性能,不仅可以保留用户界面的状态信息,还能重用之前计算的布局信息,避免遍历整棵子树。
这一点对性能提升尤为重要,因为布局计算通常比对象渲染更加耗时。特别对于 GridView 和 ListView 等复杂的 Widget,布局过程的性能开销尤其大,因此我们应尽可能避免重复的布局计算。
从 Widget 树到 Element 树的转换过程
Flutter 的核心就是一套高效的遍历树的变动的机制,它会将一棵对象树转换为更底层的对象树,并在树与树之间传递更改。
Widget 树和 Element 树是同构的,Widget 是 Element 的配置, 而Element 是 Widget 的实例化,因此在 Widget 树中每一个指定位置上的 Widget, 在 Element 树中都有一个对应的 Element,然而,它们在类型上并非一一对应的,Element 主要有两种基本类型:
- ComponentElement(组合 Element) :作为其他 Element 的宿主。
- RenderObjectElement(渲染对象 Element) :实际参与布局或绘制的 Element。
要了解不同 Widget 所对应的 Element 类型,我们可以通过其对抽象方法 createElement() 的重写来获知。
abstract class Widget extends DiagnosticableTree {
@protected
@factory
Element createElement();
}
例如,通过对 Container 源码的追踪,可以获知其对应的 Element 类型是 ComponentElement:
class Container extends StatelessWidget {}
abstract class StatelessWidget extends Widget {
@override
StatelessElement createElement() => StatelessElement(this);
}
class StatelessElement extends ComponentElement {}
createElement() 方法同时也是 Widget 树与 Element 树建立起关联的关键方法,该方法在 Element 的 inflateWidget() 方法中被调用:
@protected
Element inflateWidget(Widget newWidget, Object? newSlot) {
try {
/// ...
/// 1.为给定的 Widget 创建一个 Element
final Element newChild = newWidget.createElement();
/// 2.将其作为此 Element 的子 Element 添加到给定的插槽中
newChild.mount(this, newSlot);
return newChild;
} finally {
/// ...
}
}
我们暂时不需要往上探寻 inflateWidget() 方法是由谁调用的,只需顺着源码的线索往下分析,答案自然而然会浮现出来。
可以看到,新的 Element 对象被创建出来后,紧接着的下一步操作就是调用其自身的 mount() 方法。
mount 有“挂载”的意思,这一步是将自身挂载到父 Element 中由 Slot 参数指定的位置上,并确定了在 Element 树结构中的深度。
Slot 意为 “槽位”,某些父 Element(例如 MultiChildRenderObjectElement )可能有多个子 Element,这个时候就需要 Slot 参数来定义其在子项列表中的挂载位置。
void mount(Element? parent, Object? newSlot) {
/// ...
/// 1.确立了自己的父 Element 节点
_parent = parent;
_slot = newSlot;
_lifecycleState = _ElementLifecycle.active;
/// 2.确定了在 Element 树结构中的深度,在父 Element 的下一层
_depth = _parent != null ? _parent!.depth + 1 : 1;
/// ...
}
接下来,在 ComponentElement 类自身对 mount() 方法的实现中,其依次调用多个方法,最终执行了 performRebuild() 。
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
_firstBuild();
}
void _firstBuild() {
rebuild();
}
void rebuild({bool force = false}) {
/// ...
try {
performRebuild();
} finally {
/// ...
}
}
performRebuild() 方法先是调用了 Container 的 build() 方法,返回了 Container 的 Widget 子树,并将其赋值给局部变量 built。随后,调用 updateChild 方法更新指定的子项:
abstract class ComponentElement extends Element {
@override
void performRebuild() {
Widget? built;
try {
/// 1. 获取新的对用户界面部分的描述(即UI配置)
built = build();
} catch (e, stack) {
built = ErrorWidget.builder(
...
);
} finally {
super.performRebuild();
}
/// ...
try {
/// 2.使用新的UI配置更新指定的子项
_child = updateChild(_child, built, slot);
} catch (e, stack) {
built = ErrorWidget.builder(
/// ...
);
_child = updateChild(null, built, slot);
}
}
}
updateChild 方法是 Widget 系统的核心。每次我们根据更新的配置添加、更新或删除子项时,都会调用它。 根据 'newWidget' 参数和 'child' 参数是否为空,会有不同的处理。newWidget 已经在上一步的 build() 方法中构建出来了,而 child 也即 Element 子项由于待创建,因此暂时为空,所以我们走到了以下这一步的判断中:
abstract class Element extends DiagnosticableTree implements BuildContext {
@protected
@pragma('vm:prefer-inline')
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
if (newWidget == null) {
/// ...
return null;
}
final Element newChild;
if (child != null) {
/// ...
} else {
newChild = inflateWidget(newWidget, newSlot);
}
}
}
如上所示,我们又看到了 inflateWidget 方法,说明又开始了下一级的递归构建,接下来将通过调用 newWidget 的 createElement 方法创建子 Element,就这样逐级进行,最终完成了 Widget 树对 Element 树的转换。
整个转换过程的流程图如下:
将 Element 树与 Render 树分离的必要性
同样是在构建阶段,Flutter 会为 Element 树中的每个 RenderObjectElement 类型的节点,创建或更新一个从 RenderObject 类继承的对象。
RenderObject 类是渲染树中每个节点的 基类,该基类为布局和绘制定义了一个抽象模型,负责渲染文字的 RenderParagraph 和负责渲染图片的 RenderImage 则是更为上层的实现。
虽然我们都知道“在 Flutter 中,一切皆 Widget”,但我们在屏幕上所看到的实际是 RenderObject,除了布局和绘制外,RenderObject 还要处理额外的命中测试、可访问性等事务。
RenderObject 树是 Element 树的子集,因为只有 RenderObjectElement 声明了 createRenderObject 方法,所以 RenderObject 树的层级相较于 Element 树要浅得多。
这也正是要将 Element 树与 Render 树进行分离的原因之一,从性能方面考虑,当布局发生变化时,Flutter 只需遍历与布局相关的 RenderObject 树,而不需要遍历整个 Element 树。由于前面我们提到的 Widget 组合性的存在,Element 树通常包含许多与实际布局无关的节点,遍历它们只会浪费时间。
除了性能方面的考量,分离出 RenderObject 树后还可以充分利用其「类型安全」的特性,在运行时确保子节点拥有合适的类型。RenderObject 拥有多种实现类型,其中最常见的两种分别是:
- RenderBox:为大多数的 Widget 所使用,其采用了基于二维笛卡尔坐标系的布局方式,其工作流程如下:
-
- 父节点向子节点传递约束条件(包括最小/最大宽高)
- 子节点根据这些约束条件确定自身尺寸并返回
- 最后由父节点确定子节点在坐标系中的精确位置
- RenderSliver,主要用于可滚动的 Widget。它采用了基于滚动视窗的布局方式:
-
- 每个子节点都会接收到关于可视区域的信息(主要是剩余可见空间)
- 每个子节点按顺序排列,直到所有子节点都被布局或可视区域空间耗尽
Widget 并不关心它所处的布局类型,它可以被用在不同的布局中。举例来说:
- 当 Text Widget 被用在 Row 中时,它会创建 RenderParagraph 类型的渲染对象,负责处理文本段落的渲染。
- 而当 Text Widget 被用在 ListView 中时,它会被包装在一个 RenderSliver 中,由后者真正负责滚动列表内容的渲染。
RenderObject 树的类型安全机制就体现在 Flutter 渲染过程中的安全校验和中间层转换。这确保了不同类型的 RenderObject 能够相互兼容,并最终构成一个完整的渲染树。这种机制有效防止了渲染过程中的类型错误,从而提升了渲染的稳定性和可靠性。
如果将 Element 树与 Render 树合并成一棵树的话,需要遍历 Element 树才能确定每个 RenderObject 的类型。而将 RenderObject 树分离出来后,每个 RenderObject 的类型信息是预先确定的,不需要遍历就能直接获取,因此可以提高效率。
从 Element 树到 RenderObject 树的转换过程
前面我们提到过,RenderObjectElement 类型的 Element 才是实际参与布局或绘制的 Element,而负责创建此 Element 类型的 Widget 则是 RenderObjectWidget,RenderObjectWidget 提供了createRenderObject() 方法用于创建 RenderObject,创建的 RenderObject 将用于应用程序的实际渲染。这几个相关类之间的关系如下:
createRenderObject() 方法同样是在 mount 方法内被调用:
abstract class RenderObjectElement extends Element {
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
/// ...
/// 1. 使用 RenderObjectWidget 描述的 UI 配置创建 RenderObject 类的实例
_renderObject = (widget as RenderObjectWidget).createRenderObject(this);
/// ...
/// 2. 将 RenderObject 添加到渲染树中由 “newSlot” 指定的位置。
attachRenderObject(newSlot);
super.performRebuild(); // clears the "dirty" flag
}
}
在创建了 RenderObject 类的实例后,下一步就是调用 attachRenderObject 方法,将 RenderObject 添加到渲染树中由“newSlot”指定的位置。
@override
void attachRenderObject(Object? newSlot) {
_slot = newSlot;
/// 1.向上查找 Element 树,找到其祖先 RenderObjectElement
_ancestorRenderObjectElement = _findAncestorRenderObjectElement();
/// ...
/// 2.将创建好的子 RenderObject 插入到指定的祖先RenderObject中
_ancestorRenderObjectElement?.insertRenderObjectChild(renderObject, newSlot);
/// ...
}
attachRenderObject 方法同样分为两步,第一步,向上查找 Element 树,找到其在树中距离最近的祖先RenderObjectElement:
RenderObjectElement? _findAncestorRenderObjectElement() {
Element? ancestor = _parent;
/// 父 Element 不为空,但不是RenderObjectElement,继续向上查找
while (ancestor != null && ancestor is! RenderObjectElement) {
ancestor = ancestor?._parent;
}
return ancestor as RenderObjectElement?;
}
第二步,将创建好的子 RenderObject 插入到指定的祖先 RenderObject 中。
class SingleChildRenderObjectElement extends RenderObjectElement {
@override
void insertRenderObjectChild(RenderObject child, Object? slot) {
final RenderObjectWithChildMixin<RenderObject> renderObject = this.renderObject as RenderObjectWithChildMixin<RenderObject>;
/// 父对象仅拥有一个子项时,直接赋值给child
renderObject.child = child;
}
}
class MultiChildRenderObjectElement extends RenderObjectElement {
@override
void insertRenderObjectChild(RenderObject child, IndexedSlot<Element?> slot) {
final ContainerRenderObjectMixin<RenderObject, ContainerParentDataMixin<RenderObject>> renderObject = this.renderObject;
/// 父对象拥有多个子项时,插入到指定槽位的RenderObject之后
renderObject.insert(child, after: slot.value?.renderObject);
}
}
以渲染文字的 RenderParagraph 为例,其向上查找Element树,插入到指定的祖先RenderObject中的动画如下:
以上,就是今天这篇文章的所有内容,主要讲的是 Flutter 布局初始化的工作流程,下一篇我们将讲到 Flutter 是如何更新现有布局的,敬请期待。
问题解答
现在,让我们回顾一下文章开头提出的两个关键问题:
- Flutter框架为什么要将渲染机制剥离成三个树?这背后的设计理念是什么?
Flutter采用三棵树的设计,主要基于以下设计理念:
-
- 关注点分离:
-
-
- Widget 树——声明 UI 配置
- Element 树——管理 UI 结构
- RenderObject 树——实际 UI 渲染
-
这种分离使得 Flutter 渲染机制的各个部分都能高效地完成其特定任务。
-
- 性能优化:
-
-
- Element 树缓存了底层表示:只重建树中需要更新的部分,高效更新界面。
- RenderObject 树节点与布局相关:布局变化时,避免遍历与布局无关的节点。
-
-
- 类型安全:
-
-
- RenderObject 树的安全校验和中间层转换:防止了渲染过程中的类型错误,提升了渲染的稳定性和可靠性。
-
- Widget树、Element树和Render树三者之间是如何建立关联与转换的?
这三棵树的关联和转换过程如下:
-
- Widget 到 Element:
-
-
- 创建 Element:调用 createElement() 方法为给定的 Widget 创建一个 Element 对象。
- 挂载 Element:通过 mount() 方法挂载到 Element树中,确立了其父节点,同时确定了其在 Element 树中的位置和深度。
-
-
- Element 到 RenderObject:
-
-
- 创建 RenderObject:对于RenderObjectElement,调用 createRenderObject() 方法来创建对应的RenderObject对象。
- 附加RenderObject:通过 attachRenderObject() 方法,向上查找 Element 树,找到RenderObjectElement 类型的父节点,然后将其插入到 RenderObject 树中。
-
这个过程是递归进行的,从根 Widget 开始,逐级构建整个树结构,并将一棵对象树转换为更底层的另一棵对象树。