cubegao

Flutter中的三棵树是什么?

2021-09-29

一、从一个常见的困惑说起

在 Flutter 开发中,你大概率遇到过这样的场景:

聊天页面收到一条新消息,setState 之后整个消息列表都在 build,但明明我只想更新最后一条。

或者:

ListView 中给某一行加了 Key 和不加 Key,性能差异巨大,为什么?

这些问题的答案,都指向 Flutter 框架中最核心的设计——三棵树

理解 Widget、Element、RenderObject 三棵树各自的分工以及它们之间的协作方式,是从「能用 Flutter」到「用好 Flutter」的关键一步。尤其在企业级 IM 这种消息密集、列表频繁刷新的场景下,三棵树的设计直接决定了应用的流畅度。

本文将结合我们企业 IM + OA 超级 App 的实际场景,把三棵树的概念和运作机制讲清楚。

二、先看整体:三棵树长什么样

Flutter 的 UI 系统由三层结构组成,每一层都是「一棵树」:

1
2
3
4
5
Widget 树      →  轻量级配置,描述 UI「应该长什么样」
↓ createElement()
Element 树 → 中间桥梁,决定「复用还是重建」
↓ createRenderObject()
RenderObject 树 → 真正干活,执行 layout 和 paint

用建筑行业的比喻最容易理解:

  • Widget 是「设计图纸」——随时可以重画,成本极低。
  • Element 是「项目经理」——拿着图纸,知道该找哪个施工队,能复用就绝不重建。
  • RenderObject 是「施工队」——真正砌墙刷漆的人,关心尺寸、位置、绘制。

三棵树的建立顺序是:应用启动时,Flutter 先遍历创建所有 Widget 形成 Widget 树,再调用 createElement() 创建对应的 Element 树,最后调用 createRenderObject() 生成 RenderObject 树。

下面逐层展开。

三、Widget 树:轻量级配置

Widget 是开发者最熟悉的 Flutter 概念。它的核心特点是不可变——Widget 的所有字段都是 final 的,创建好之后就不能再改了。

正因为不可变,Widget 的创建代价极低。在 build() 方法里用 new 创建几百个 Widget,对性能几乎没有影响。

但这带来一个问题:UI 状态总是要变的。Flutter 的解法是「状态变了就重建 Widget」。所以 StatelessWidgetStatefulWidgetbuild 方法会在数据变更时被重新调用,生成一棵全新的 Widget 树。

按照职责,Widget 可以分为三类:

类型 代表 职责
组合类 StatelessWidget / StatefulWidget 组合封装子 Widget,不直接参与绘制
代理类 InheritedWidget 向子树高效传播数据,支持局部刷新
绘制类 RenderObjectWidget 创建 RenderObject,真正参与布局和绘制

其中,InheritedWidget 在复杂业务场景下价值巨大。以我们的企业 IM 为例,聊天页面需要感知「当前会话未读数」「用户在线状态」「主题皮肤」等数据。如果每次这些数据变化都让整棵树 rebuild,在有几百条消息的列表页面中,性能是不可接受的。借助 InheritedWidget,只有真正依赖该数据的子孙 Widget 才会 rebuild,未依赖的部分保持不变。

四、Element 树:决定性能的关键

4.1 Element 是什么

如果 Widget 每次重建都导致 RenderObject 也重建,那 Flutter 的性能早就崩了。

Element 就是解决这个问题的关键。它是 Widget 和 RenderObject 之间的桥梁:

  • 持有 Widget:知道当前 UI 应该长什么样。
  • 持有 RenderObject:知道谁来画。
  • 维护父子关系:遍历树结构、传递上下文。

Element 根据对应的 Widget 类型分为两类:

  • ComponentElement(对应 StatelessWidget / StatefulWidget):不直接参与渲染,负责组合子 Element。
  • RenderObjectElement(对应 RenderObjectWidget):直接持有 RenderObject,驱动布局和绘制。

4.2 Element 的复用策略

当 Widget 树重建时,Element 并不会盲目跟着重建,而是执行一个聪明的判断流程。核心方法是 Widget.canUpdate

1
2
3
4
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}

只有当 runtimeTypekey 完全一致时,Element 才会复用自身,仅调用 child.update(newWidget) 把新配置灌入。如果 canUpdate 返回 false,旧 Element 会被 deactivate,新 Element 被 inflate 出来。

这个机制解释了本文开头的问题:

为什么加 Key 能提升列表性能?

在聊天消息列表中,每条消息 Widget 的 runtimeType 相同(都是 MessageBubble)。如果不加 KeycanUpdate 判断的类型匹配永远为 true,Element 会「错误地」把消息 A 的 Element 复用到消息 B 上——虽然快,但可能导致 UI 状态串乱。

加了 Key 之后,canUpdate 要求 key 也必须匹配,Element 就能精准地将每条消息的旧状态保留在新位置,避免不必要的重建。而对于真正新增或删除的消息,Flutter 也能高效地 deactivateinflate

在企业 IM 的消息列表、通讯录列表等高频滚动场景中,合理使用 Key 配合 ListView.builder,能将滑动帧率稳定在 60fps 以上。

4.3 updateChildren:列表节点的批量更新

对于拥有多个子节点的 RenderObjectElement(比如 ColumnListView),更新走的是 updateChildren 方法。它的核心策略名为 「头部扫描 + 底部扫描 + 中间 Key 匹配」

  1. 从顶部向下扫描:逐个比较新旧 Widget,canUpdatetrue 的就原地复用。
  2. 从底部向上扫描:同样逻辑,但不更新,只记录位置。
  3. 中间部分按 Key 匹配:将剩余旧 Element 中带 Key 的存入 Map,然后遍历新 Widget 按 Key 查找复用。没 Key 也没匹配到的旧 Element 直接 deactivate
  4. 清理残留:匹配完成后,Key 在新 Widget 列表中不存在的旧 Element 统一销毁。

这套算法的巧妙之处在于:大部分列表操作只改动头尾(如顶部插入新消息、底部加载更多),头尾扫描能高效命中,中间不动就不重建。 只有列表中间发生插入或删除时才会走到 Key 匹配的路径。

五、RenderObject 树:真正的渲染执行者

RenderObject 是 Flutter 渲染管线的最终一环。它负责两件事:

  • layout:自顶向下传递约束,自底向上确定尺寸。
  • paint:根据布局结果,在屏幕上绘制像素。

RenderObject 与 Widget / Element 最大的不同在于:它不关心「配置变化」。Widget 可以换了一茬又一茬,但 RenderObject 只要布局参数没变,就不会重新 layout

来看创建链接的关键代码:

1
2
3
4
5
// RenderObjectWidget 同时创建 Element 和 RenderObject
abstract class RenderObjectWidget extends Widget {
RenderObjectElement createElement(); // 创建关联的 Element
RenderObject createRenderObject(BuildContext context); // 创建真正的渲染对象
}

然后在 RenderObjectElement.mount 中,将 RenderObject 挂载到 Element 上:

1
2
3
4
5
void mount(Element parent, Object? newSlot) {
super.mount(parent, newSlot);
_renderObject = widget.createRenderObject(this);
// ... attach 到 RenderObject 树
}

最终的数据关系清晰表现为:Element 一手牵着 Widget(配置),一手牵着 RenderObject(渲染),是连接二者的桥梁。

六、三棵树协同工作的完整流程

把前面的内容串起来,一个典型的 UI 更新流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户点击发送一条消息

setState(() { messages.add(newMsg); })

build() 重新执行,生成新的 Widget 树

Element.updateChild / updateChildren 被调用

canUpdate 判断每个 Element 是否可复用

├── 可复用 → Element 更新内部 Widget 引用,RenderObject 不动
└── 不可复用 → 旧 Element deactivate,新 Element inflate,
继而创建新 RenderObject

RenderObject 执行 layout() → paint() → 屏幕显示新内容

在这个流程中,真正的性能核心在于:Flutter 默认「复用一切能复用的」。Widget 重建是常态,但 Element 和 RenderObject 尽可能地保持不变。新消息插入到底部时,列表顶部 99% 的 Element 都不会动。

七、业务场景中的实践意义

理解了上述机制,在企业 IM 开发中可以做出更精准的优化决策:

场景一:消息列表的增量更新

错误做法:每次收到新消息,全量 setState 刷新整个 ListView,不加 Key,导致滑到一半的用户被弹回顶部。

正确做法:消息模型带唯一 id 作为 KeyListView.builder 配合 itemBuilder 按需构建,新消息只重建新增的那几条。

场景二:通讯录的局部刷新

企业通讯录有大量的组织架构数据,一次性全量加载不现实。解决方案是虚拟列表 + 按需加载。同时利用 InheritedWidget 传递「当前选中部门 ID」,部门切换时只有列表区域 rebuild,搜索栏和导航栏不动。

场景三:全局搜索的输入联想

搜索框输入时,每敲一个字符都会触发联想列表刷新。如果联想列表的每个 Widget 都重建 RenderObject,即使数据量不大,输入过程中也会有明显的顿挫感。通过给联想项加稳定的 Key(如用户的 userId),Flutter 能最大限度地复用已有的 Element 和 RenderObject,输入流畅度明显提升。

八、总结

回到标题的问题:Flutter 中的三棵树是什么?

  • Widget 树:轻量级、不可变的 UI 配置描述,随时可重建,成本极低。
  • Element 树:Flutter 框架的骨架,负责决定「复用还是重建」,是性能优化的核心抓手。
  • RenderObject 树:真正执行 layoutpaint 的渲染层,在 Element 的驱动下工作。

三棵树的分层设计,本质上是一种**「用空间换时间」+「最大化复用」**的策略。Widget 承担了「频繁变化」的成本,Element 提供了「决策复用」的智能,RenderObject 保证了「执行渲染」的稳定。

在复杂的企业级 IM 场景中,这套机制让我们能够同时应对「海量消息渲染」「频繁数据更新」「复杂 UI 层级」三重挑战。理解它,不是为了炫技,而是为了在写出正确代码的同时,写出高性能的代码。

扫描二维码,分享此文章