上一篇文章我们探讨了 Flutter 布局初始化的流程,本文我们将继续深入,分析 Flutter 是如何更新现有布局的。
相较于初始化布局,根据变化的状态更新现有布局是一个更为频繁的操作。在这个过程中,如何最大限度地重用 Element 对象,对于性能的优化至关重要。这不仅可以更好地重用与状态和布局相关的信息,还能避免重新遍历整棵子树。
而这其中涉及的一个最关键的方法就是 Element 的 updateChild() 方法,updateChild() 方法是 Widgets 系统的核心。每次我们要根据更新的配置添加、更新或删除子项时,都会调用它。
但在深入探讨这个方法之前,让我们先从 Flutter 开发者更为熟悉的 setState() 方法开始切入。
setState() 方法背后做了什么?
setState() 方法用于通知 Flutter 框架 State 对象的内部状态已更改,随后,框架会调用该对象的 build 方法,以使用最新的状态更新用户界面。
scss
Future<void> _incrementCounter() {
setState(() {
_counter++;
});
}
setState() 方法内部会将 Element 标记为需要重建,标记的方式是将 Element 设置为"脏"(dirty):
typescript
@protected
void setState(VoidCallback fn) {
/// 将 Element 标记为需要重建
_element!.markNeedsBuild();
}
csharp
void markNeedsBuild() {
if (dirty) {
return;
}
/// 将 Element 设置为脏元素
_dirty = true;
owner!.scheduleBuildFor(this);
}
随后,将该 Element 添加到脏元素列表,然后等待在下一帧中重新构建:
csharp
void scheduleBuildFor(Element element) {
/// 将 Element 添加到脏元素列表
_dirtyElements.add(element);
element._inDirtyList = true;
}
到了下一帧时,WidgetsBinding.drawFrame() 方法内部会调用 buildScope() 方法,按深度优先的顺序重新构建被标记为 dirty 的所有 Element:
java
void buildScope(Element context, [ VoidCallback? callback ]) {
while (index < dirtyCount) {
/// 按深度优先的顺序重新构建标记为 dirty 的所有 Element。
final Element element = _dirtyElements[index];
element.rebuild();
}
}
javascript
void rebuild({bool force = false}) {
performRebuild();
}
performRebuild() 方法在 Element 类自身的基本实现中,仅仅是清除了 dirty 标记:
less
@protected
@mustCallSuper
void performRebuild() {
_dirty = false;
}
而在 ComponentElement 的实现中,则是调用 build() 方法重新构建 Widget 子树,获取新的 UI 配置,并使用新的配置来更新子项:
scss
void performRebuild() {
/// 重新构建 Widget 子树,获取新的 UI 配置
Widget? built;
try {
built = build();
} catch (e, stack) {
...
} finally {
super.performRebuild(); // 清除 dirty 标记
}
/// 使用新的配置来更新子项
_child = updateChild(_child, built, slot);
}
这也解释了为什么当需要创建可重用的 UI 时,Flutter 更建议我们将其提取为单独的 Widget 而不是辅助方法(Helper Method)。因为当我们在 State 对象上调用 setState()时,所有后代 Widget 都将重建。
将可重用的 UI 提取为单独的 Widget 后,就可以将 setState() 的调用转移到实际需要更改 UI 的 Widget 中,从而减少 Widget 重建的范围。
另外在 RenderObjectElement 的实现中,performRebuild() 方法则是更新了 RenderObject 对象:
typescript
@override
void performRebuild() {
_performRebuild();
}
@pragma('vm:prefer-inline')
void _performRebuild() {
(widget as RenderObjectWidget).updateRenderObject(this, renderObject);
super.performRebuild();
}
好了,现在我们知道调用了 setState() 方法后是如何走到 updateChild() 方法的了。其实在上一篇的初始化布局中我们已经快速过了 updateChild() 方法的其中一种场景,这次我们索性把所有场景都分析一下。
updateChild() 方法的所有场景
场景1:Widget 被移除
javascript
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
/// Widget 树中某个指定位置的 Widget 被移除
if (newWidget == null) {
if (child != null) {
deactivateChild(child);
}
return null;
}
/// ...
}
当重建的 Widget 树中某个指定位置的 Widget 被移除后,Flutter 框架首先会将对应位置的 Element 对象从 Element 树中分离:
csharp
void deactivateChild(Element child) {
/// 将对应位置的 Element 对象从 Element 树中分离
child._parent = null;
/// 依次访问其子项,将 RenderObjectElement 类型的节点
/// 关联的 RenderObject 与 RenderObject 树分离
child.detachRenderObject();
/// 将给定 Element 移动到非活动的 Element 列表中,
/// 最终会依次将其子节点的Key和Widget清空
owner!._inactiveElements.add(child);
}
随后,会依次访问该 Element 对象的子项,将 RenderObjectElement 类型的节点关联的 RenderObject 对象也从 RenderObject 树中分离:
ini
void detachRenderObject() {
visitChildren((Element child) {
child.detachRenderObject();
});
_slot = null;
}
ini
@override
void detachRenderObject() {
if (_ancestorRenderObjectElement != null) {
_ancestorRenderObjectElement!.removeRenderObjectChild(renderObject, slot);
_ancestorRenderObjectElement = null;
}
_slot = null;
}
这一场景的动画演示如下:
场景2:Widget 实例不变
scss
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
final Element newChild;
if (child != null) {
if (hasSameSuperclass && child.widget == newWidget) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
/// Element 保持不变,直接重用
newChild = child;
}
}
}
这种场景一般出现在使用 const 构造函数构建 Widget 实例的情况,这相当于将 Widget 实例缓存起来,然后在每次构建的时候直接重用。
Widget 实例不变,其关联的 Element 对象及其子级自然也是同样保持不变,因此可以直接重用。
这一场景的动画演示如下:
场景3:Widget 类型不变,只更新属性
dart
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
final Element newChild;
if (child != null) {
/// 同一个超类且 Widget 的类型和 Key 相同
if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
/// 更新 Element 所持有的 Widget 引用
child.update(newWidget);
/// Element 保持不变,直接重用
newChild = child;
}
}
}
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
这种场景适用于绝大部分的情况。只要 Widget 的类型和 Key 保持不变(Key 默认为空),就会使用新的 Widget 实例来更新 Element 所持有的 Widget 引用,也即更新为新的 UI 配置。
typescript
@mustCallSuper
void update(covariant Widget newWidget) {
_widget = newWidget;
}
而在其子类 StatefulElement 和 StatelessElement 的 update() 方法实现中,会再次调用 rebuild() 方法,继续执行对下一级的递归构建,就这样逐级进行,最终完成了整棵 Widget 树的重新构建和 Element 树中每个节点对 Widget 的引用更新。
scala
class StatelessElement extends ComponentElement {
@override
void update(StatelessWidget newWidget) {
super.update(newWidget);
rebuild(force: true);
}
}
另外,对于 RenderObjectElement 类型,update() 方法还会更新对应的 RenderObject:
scala
abstract class RenderObjectElement extends Element {
@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
_performRebuild(); // calls widget.updateRenderObject()
}
void performRebuild() {
(widget as RenderObjectWidget).updateRenderObject(this, renderObject);
super.performRebuild();
}
}
以 Text Widget 更新文本的过程为例,作为 RenderObjectWidget 类型的 RichText 会使用传入的最新文本来更新 RenderParagraph,使其能够重新渲染为最新文本:
ini
void updateRenderObject(BuildContext context, RenderParagraph renderObject) {
assert(textDirection != null || debugCheckHasDirectionality(context));
renderObject
..text = text
..textAlign = textAlign
..textDirection = textDirection ?? Directionality.of(context)
..softWrap = softWrap
..overflow = overflow
..textScaler = textScaler
..maxLines = maxLines
..strutStyle = strutStyle
..textWidthBasis = textWidthBasis
..textHeightBehavior = textHeightBehavior
..locale = locale ?? Localizations.maybeLocaleOf(context)
..registrar = selectionRegistrar
..selectionColor = selectionColor;
}
场景4:Widget 类型或 Key 发生改变
scss
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
final Element newChild;
if (child != null) {
if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
} else {
/// 将原有的 Element 对象和 RenderObject 对象从树中分离
deactivateChild(child);
/// 创建新的 Element 对象和 RenderObject 对象并挂载到树上
newChild = inflateWidget(newWidget, newSlot);
}
}
}
当 Widget 类型或 Key 发生改变时,Flutter 框架首先会将原有的 Element 对象和 RenderObject 对象及其子级从树中分离,
随后递归创建新的 Element 对象和 RenderObject 对象及其子级并挂载到树上。
场景5:Widget 不为空但 Elemet 子项为空
dart
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
final Element newChild;
if (child != null) {
} else {
newChild = inflateWidget(newWidget, newSlot);
}
}
这种场景一般出现在初始化布局或往布局中插入新的节点的情况,这个时候就会直接递归创建新的 Element 对象和 RenderObject 对象及其子级并挂载到树上。
问题解答
分析过了所有场景之后,现在,我们就可以来回顾一下上一篇文章开头提出的另外两个关键问题:
- Widget树的每一次变更都会导致Element树和Render树重建吗?
并不一定,取决于 Widget 的变更内容。Flutter框架采用了一种高效的差异化更新机制:
-
- 当 Widget 被移除时,会将关联的 Element 和 RenderObject 也从树中移除;
- 当 Widget 实例不变时,会直接重用原来的 Element 。
- 当 Widget 类型不变,只更新属性时,会重用原来的 Element ,并更新其持有的 Widget 引用。对于 RenderObjectElement,还有更新其持有的 RenderObject 的属性。
- 当 Widget 类型或 Key 发生改变时,会将关联的 Element 和 RenderObject 从树中移除,然后创建新的对象集合挂载到树上。
- 当 Widget 不为空但 Elemet 子项为空时,会创建新的对象集合挂载到树上。
- Widget树被销毁重建后,它是如何与原先的Element树重新建立关联的?
准确来说,「Widget树被销毁重建」和「与原先的Element树重新建立关联」并不是两个独立的过程,而是从被标记为 dirty 的 Element 节点开始,递归地进行差异化比对和更新的。
对于 Element 树中的每个节点,都会先调用 build() 方法来获取新的 Widget,随后调用 updateChild() 方法来比较新 Widget 与现有 Element 节点持有的旧 Widget的差异:
-
- 当新的 Widget 为空时,将 Element 节点也从树中移除;
- 当新旧 Widget 为同一个实例时,与 Element 节点的关联关系不变。
- 当新旧 Widget 类型相同,仅属性不同时,Element 节点会与新的 Widget 重新建立关联。
- 当新旧 Widget 的类型或 Key 不同时,将旧的 Element 节点从树中移除,然后使用新的 Widget 创建新的 Element 并挂载到树上。
- 当新的 Widget 在 Element 树上找不到关联的节点时,会创建新的 Element 并挂载到树上。
"隐式" Widget 的真正影响
综合了以上所有内容后,我们再来回顾一下上一篇文章中遗留的一个讨论点,也就是:
"隐式" Widget 的引入改变了 Widget 树的层次结构,可能会对 Flutter 的渲染性能产生显著影响。
这个显著影响具体是什么呢?当 Container 的 color 属性引入了一个 ColoredBox 用以处理颜色渲染后,相当于原本对应位置的 Widget 类型发生了改变,原有的 Element 对象和 RenderObject 对象就无法再复用了,只能从 ColoredBox 对应的节点往下开始创建新的对象并挂载到树上,这也就意味着需要重新进行布局计算,会造成不必要性能开销。
我们可以通过在 Flutter Inspect 工具中的 Widget Tree Detail 面板中对比前后的 RenderObject 实例是否一致来验证:
原始布局 | Container增加了alignment属性 |
---|---|
由此得到的启示是,在根据不同的应用状态进行 UI 声明时,我们应该尽量避免更改 Widget 子树的深度或更改 Widget 子树中 的 Widget 类型。