【动画图解】是怎样的方法,能被称作是 Flutter Widget 系统的核心?

上一篇文章我们探讨了 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 对象及其子级并挂载到树上。

问题解答

分析过了所有场景之后,现在,我们就可以来回顾一下上一篇文章开头提出的另外两个关键问题:

  1. Widget树的每一次变更都会导致Element树和Render树重建吗?

并不一定,取决于 Widget 的变更内容。Flutter框架采用了一种高效的差异化更新机制:

    1. 当 Widget 被移除时,会将关联的 Element 和 RenderObject 也从树中移除;
    2. 当 Widget 实例不变时,会直接重用原来的 Element 。
    3. 当 Widget 类型不变,只更新属性时,会重用原来的 Element ,并更新其持有的 Widget 引用。对于 RenderObjectElement,还有更新其持有的 RenderObject 的属性。
    4. 当 Widget 类型或 Key 发生改变时,会将关联的 Element 和 RenderObject 从树中移除,然后创建新的对象集合挂载到树上。
    5. 当 Widget 不为空但 Elemet 子项为空时,会创建新的对象集合挂载到树上。
  1. Widget树被销毁重建后,它是如何与原先的Element树重新建立关联的?

准确来说,「Widget树被销毁重建」和「与原先的Element树重新建立关联」并不是两个独立的过程,而是从被标记为 dirty 的 Element 节点开始,递归地进行差异化比对和更新的。

对于 Element 树中的每个节点,都会先调用 build() 方法来获取新的 Widget,随后调用 updateChild() 方法来比较新 Widget 与现有 Element 节点持有的旧 Widget的差异:

    1. 当新的 Widget 为空时,将 Element 节点也从树中移除;
    2. 当新旧 Widget 为同一个实例时,与 Element 节点的关联关系不变。
    3. 当新旧 Widget 类型相同,仅属性不同时,Element 节点会与新的 Widget 重新建立关联。
    4. 当新旧 Widget 的类型或 Key 不同时,将旧的 Element 节点从树中移除,然后使用新的 Widget 创建新的 Element 并挂载到树上。
    5. 当新的 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 类型。

相关推荐
健了个平_243 小时前
iOS 26 适配笔记
ios·swift·wwdc
小镇学者6 小时前
【PHP】导入excel 报错Trying to access array offset on value of type int
android·php·excel
Digitally8 小时前
如何将数据从 iPhone 传输到笔记本电脑
ios·电脑·iphone
一笑的小酒馆9 小时前
Android11 Launcher3去掉抽屉改为单层
android
白玉cfc9 小时前
【iOS】cell的复用以及自定义cell
ios·cocoa·xcode
AD钙奶-lalala11 小时前
在 macOS 上搭建 Flutter 开发环境
flutter·macos
louisgeek11 小时前
Git 根据不同目录设置不同账号
android
星释11 小时前
使用Appium在iOS上实现自动化
ios·appium·自动化
qq_3909347412 小时前
MySQL中的系统库(简介、performance_schema)
android·数据库·mysql
whysqwhw13 小时前
Kotlin Flow 实现响应式编程指南
android