【动画图解】是怎样的方法,能被称作是 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 类型。

相关推荐
mmsx16 分钟前
android 登录界面编写
android·登录界面
姜毛毛-JYM16 分钟前
【JetPack】Navigation知识点总结
android
花生糖@1 小时前
Android XR 应用程序开发 | 从 Unity 6 开发准备到应用程序构建的步骤
android·unity·xr·android xr
是程序喵呀1 小时前
MySQL备份
android·mysql·adb
casual_clover1 小时前
Android 之 List 简述
android·list
wakangda2 小时前
React Native 集成 iOS 原生功能
react native·ios·cocoa
锋风Fengfeng3 小时前
安卓15预置第三方apk时签名报错问题解决
android
User_undefined3 小时前
uniapp Native.js原生arr插件服务发送广播到uniapp页面中
android·javascript·uni-app
AiFlutter3 小时前
Flutter-底部分享弹窗(showModalBottomSheet)
java·前端·flutter
程序员厉飞雨4 小时前
Android R8 耗时优化
android·java·前端