likes
comments
collection
share

Flutter 必知必会系列—— Element 的更新复用机制

作者站长头像
站长
· 阅读数 11

你肯定遇见过这种情况,同一个代码加上 Key 就好使,不加 Key 就不显示最新内容,这篇文章就告诉你为啥!!!!

往期精彩


如果对三棵树还了解的话,可以先看上面的文章。官方文档有句话 Reusing elements is important for performance,Flutter 高性能的关键就是 Element 的更新复用机制,更新机制总结下来就一句话:当页面需要变化时,父 Element 怎么处理子 Element。 结论就是 Flutter 会尽可能使用已经存在的 Element 来显示新 Widget,万般无奈的情况下,才用新的 Widget 重新构建一个 Element。这一篇就详细来讲这一过程。

再看 Element

再介绍更新机制之前,我们先补一下前面没有讲到的点。

Element 分类

Element 也是 Flutter 的概念,对应到代码中就是 Element 抽象类,和 Widget 相似,它也有直接子类对应着不同类型的 Element。不同的时,Element 仅仅分为两类:

  • ComponentElement, 组合类型的 Element,是其他 Element 的宿主

  • RenderObjectElement,参与布局绘制的 Element

整个体系如下:

Flutter 必知必会系列—— Element 的更新复用机制

并且 Widget 和 Element 的对应关系,基本就是名字换词:

Flutter 必知必会系列—— Element 的更新复用机制

所以,我们后面在看更新机制的时候,需要分类的去看。

Element 生命周期

Flutter 必知必会系列—— Element 的更新复用机制

Element 挂载(mount)之后,UI 就显示到了屏幕上。随着程序的运行,Element 绑定的 Widget 可能会发生变化,那么 Element 怎么响应变化呢?

  • 第一种方式:直接重新构造一个 Element 出来,构造一颗新的子树
  • 第二种方式:复用 Element,只替换 Widget

Flutter 将这两种进行结合,能复用就复用,不能复用就暴力替换。但是一个节点的更新是有父亲节点发起的。完整更新的逻辑就在:updateChildupdate 方法中。

updateChild 是概念方法,所有的逻辑都在 Element 类中定义好了。 update 是实际方法,不同种类的 Element 有不同的实现。

更新子节点 —— updateChild

方法概念

什么时候会发起这个动作呢? 举个例子:

class _MyHomePageState extends State<MyHomePage> {
  void _onTap() {
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(onTap: _onTap, child: const Text('data'));
  }
}

例子形成的两棵树如下:

Flutter 必知必会系列—— Element 的更新复用机制

点击按钮之后,会触发刷新,对应到 Element 层,A 刷新 B,B 刷新 C。

对应到方法调用就是:

Flutter 必知必会系列—— Element 的更新复用机制

方法讲解

方法的目的是:使用参数中给定的 Widget 来更新子节点,结果有三个 null、旧 Element、新Element

方法的返回值:

null ------------ 不需要显示新内容

旧 Element ------------ 引用不变,更新 Widget 即可

新 Element ------------ 重新构建 Element

方法参数:

  • newSlot,Element 在父节点中的新位置

  • child,要更新的子节点是谁,如果是 null,那么就是旧子节点不存在。

  • newWidget,要更新的内容是什么,如果是 null,那么就是不显示。

方法的执行流程:

Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
  if (newWidget == null) {
    if (child != null)
      deactivateChild(child);
    return null;
  }
  final Element newChild;
  if (child != null) {
    bool hasSameSuperclass = true;
    if (hasSameSuperclass && child.widget == newWidget) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      newChild = child;
    } else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      child.update(newWidget); //第一处
      newChild = child;
    } else {
      deactivateChild(child);
      newChild = inflateWidget(newWidget, newSlot);
    }
  } else {
    newChild = inflateWidget(newWidget, newSlot);
  }
  return newChild;
}

先判断要显示的新 Widget(newWidget)是否为 null ,如果是 null ,说明不需要显示内容,直接返回 null 即可。如果不为 null ,走到下一个判断。

判断是否旧 child 是否为 null ,如果是 null ,说明 Widget 还没显示过,直接使用给定的 Widget(newWidget),实例化出 Element 返回。实例化的过程就是 inflateWidget 方法。这个后面我们讲 GlobalKey 的时候在详细讲,这里只需要知道它内部调用了 createElement 即可。

如果 child 不是 null ,说明已经存在 Element 了,要判断能不能复用了。 判断的依据如下,只要满足一个就复用 Element:

  • Element 持有的旧 Widget 和 新 Widget 是否是同一个
  • 旧 Widget 和 新 Widget 的 Widget.canUpdate 是否为true,Widget.canUpdate 判断的就是 Key 和 类型

只要确定了 复用 Element,子 Element 就会用 新 Widget 更新自己。就是第一处的代码。

总结下来就是:

Flutter 必知必会系列—— Element 的更新复用机制

这个时候就知道 为啥加上 Key 就可以显示了吧,因为强制刷新了。

更新自己 —— update

概念层的 Element 只定义了最基本的行为,就是:

void update(covariant Widget newWidget) {
  _widget = newWidget;
}

只重新赋值自己的 Widget,下面我们就看不同种类的 Element 的处理。

StatelessElement 更新自己

处理逻辑很简单:

@override
void update(StatelessWidget newWidget) {
  super.update(newWidget);
  _dirty = true;
  rebuild();
}

将自己标记为 dirty,然后执行 rebuild 。 经过层层的方法传递会传递到 performRebuild 方法中。

void performRebuild() {
  //... 代码省略
  Widget? built;
  try {
    built = build(); // 第一处
  } catch (e, stack) {
  } finally {
    _dirty = false;
  }
  try {
    _child = updateChild(_child, built, slot); // 第二处
  } catch (e, stack) {
    _child = updateChild(null, built, slot);
  }
}

第一处:执行 build 方法,构建其子节点的 Widget

第二处:更新子节点 Element,层层递归

对于 StatelessElement 来说,就是执行其绑定的 Widget 的 build 方法。

@override
Widget build() => widget.build(this);

总结来说,StatelessElement 的更新流程如下:

Flutter 必知必会系列—— Element 的更新复用机制

StatefulElement 更新自己

处理的逻辑也很简单:

@override
void update(StatefulWidget newWidget) {
  super.update(newWidget);
  final StatefulWidget oldWidget = state._widget!;
  _dirty = true; // 第一处
  state._widget = widget as StatefulWidget;
  try {
    final Object? debugCheckForReturnedFuture = state.didUpdateWidget(oldWidget) as dynamic; // 第二处
  } finally {
  }
  rebuild(); // 第三处
}

第一处:将自己标记为 dirty

第二处:调用 State 生命周期方法 didUpdateWidget

第三处:执行 rebuild 方法

同样的道理,rebuild 层层传递也会执行到 performRebuild 方法中。

不同的是,对于 StatefulElement 来说,就是执行其绑定的 State 方法中的 build 方法。

@override
Widget build() => state.build(this);

总结来说,StatefulElement 的更新流程如下:

Flutter 必知必会系列—— Element 的更新复用机制

所以说,只要有所改动,StatelessWidget 和 State 的 build 方法都会执行。

RenderObjectElement 更新自己

RenderObjectElement 定义了基本的更新方法,分为 单节点的SingleChildRenderObjectElement多节点的 MultiChildRenderObjectElement。这三个类就完成了所有的更新逻辑。

RenderObjectElement 更新逻辑

代码如下:

@override
void update(covariant RenderObjectWidget newWidget) {
  super.update(newWidget);
  widget.updateRenderObject(this, renderObject); // 第一处
  _dirty = false; 
}

第一处: 就是调用了 RenderObjectWidget 的更新渲染节点的方法,一般就是把和渲染相关的属性重新设置一下。

这就是通用的渲染 Element 的更新逻辑。下面看单节点和多节点的更新。

单节点更新 SingleChildRenderObjectElement

@override
void update(SingleChildRenderObjectWidget newWidget) {
  super.update(newWidget);
  _child = updateChild(_child, widget.child, null);
}

单节点的更新 就是 更新 child 的逻辑,前面已经讲过了。

总结下来就是:

Flutter 必知必会系列—— Element 的更新复用机制

多节点更新 MultiChildRenderObjectElement

代码如下:

@override
void update(MultiChildRenderObjectWidget newWidget) {
  super.update(newWidget);
  _children = updateChildren(_children, widget.children, forgottenChildren: _forgottenChildren);
  _forgottenChildren.clear();
}

我们看到就是干了一件事:updateChildren。_children 旧的子节点列表,widget.children 是新的要显示的 Widget 列表。

我们先想一个算法题,怎么以最小的耗费来 diff 两个数组列表。

如果以一个列表为基准,遍历另外一个列表,这样的时间复杂度是 O(N²) 的。

Flutter 提供了另一种视角。

在介绍之前。这里提一点,如果 Widget.updateWidget 两个 Widget 返回true,则两个 Widget 是一样的,否则是不一样的。比较的是类型和 Key。

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

为啥这么比较呢?我们可以想象一下,想要复用 Element 那肯定得能复用啊。

原来 Element 承载的是 Text 文本,新一帧要显示 Image 图片,那旧 Element 肯定无法胜任了。 如果 只要类型就可以,那可能会出现我们的 Image 永远不会刷新了,所以有了 Key 属性。

所以只有 类型和 Key 都一样,那 Element 就是可以复用的。

首先 从头到尾遍历两个数组,直到发现两个元素不一样。记录索引 i,j。

然后 从尾到头遍历两个数组,直到发现两个元素不一样。记录 i1,j1。

然后 从 i 到 i1 记录旧列表中有可能复用(带有Key)的元素

最后 从 i 开始执行更新流程

详细的代码流程如下:

自上而下 diff 并更新子节点

    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
      final Widget newWidget = newWidgets[newChildrenTop];
      /// diff的依据
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      /// 更新的操作
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      newChildren[newChildrenTop] = newChild;
      //这就是Slot
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }

diff 的就是我们上面讲的 新旧 widget 的 key 和 runtimeType ,如果两者不相等,就没有更新的必要了,就直接跳出循环。比如 原来显示的是 Image ,现在要显示 Text ,那么显然没必要去复用 Element,所以直接跳出循环。如果两者相等,那说明有可能进行复用,就尝试更新 Element 。比如原来实现的是 Text("a"),现在要显示 Text("s"),那么只更新 Element 的 widget 引用就可以了。执行过程如下:

Flutter 必知必会系列—— Element 的更新复用机制

自下而上的 diff

    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenBottom]);
      final Widget newWidget = newWidgets[newChildrenBottom];
      /// diff
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      oldChildrenBottom -= 1;
      newChildrenBottom -= 1;
    }

diff 的是新旧 widget 的 key 和 runtimeType,如果两者不相等,就直接跳出循环。这么做的目的是为了尽可能的复用。framework 将子节点的列表分为三部分:头部、中间、底部,步骤一完成了头部的更新,本步骤就可以完成三部分的划分。 执行过程如下:

Flutter 必知必会系列—— Element 的更新复用机制

存储可复用的 Element

那么什么样的 Element 可以被复用呢?就是Widget.canUpdate结果为true

步骤一,已经自上而下的Widget.canUpdate了 新旧两个列表,并对可复用的Element进行了更新。 步骤二,已经自下而上的Widget.canUpdate了 新旧两个列表。

本步骤的目的就是 在旧Element列表的中间部分找到可以复用的 Element,并把他们存储下来。然后在新 Widget 列表生成 Element 的时候,就可以看看是否存在可以复用的了。 当发生更新的时候,Flutter 的第一选择是尽可能复用 Element,而不是去创建 Element。\

代码如下:

    /// 保存Key的Element
    final bool haveOldChildren = oldChildrenTop <= oldChildrenBottom;
    /// 存的方式:key:Widget的Key  value:Element
    /// 取的时候就可以从map中通过key 直接拿到Element
    Map<Key, Element> oldKeyedChildren;
    /// 如果存在旧列表
    if (haveOldChildren) {
      oldKeyedChildren = <Key, Element>{};
      while (oldChildrenTop <= oldChildrenBottom) {
        final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
        if (oldChild != null) {
          if (oldChild.widget.key != null)
            /// 在这里保存了带有Key的Element (1)
            oldKeyedChildren[oldChild.widget.key] = oldChild;
          else
            deactivateChild(oldChild);
        }
        /// 更新索引
        oldChildrenTop += 1;
      }
    }

核心就是(1)处的处理,oldChild 是旧的 Element,Element 持有 Widget 的引用,因此我们可以拿到 Element 的 Widget 的 Key 是什么。如果存在 Key 就把 Element 保存下来。执行流程如下:

Flutter 必知必会系列—— Element 的更新复用机制

更新中间部分的 Element

现在旧 Element 的列表已经扫描完毕了,并且将可以复用的 Element 进行了保存。那么就可以顺序进行更新操作了,根据新传入的 Widget 列表,去生成或者复用 Element。\

为什么在扫描底部的时候,不进行更新呢?

因为 Slot 信息拿不到。Slot 是多节点和单节点不一样的地方。多节点的 Element 会为每一个子节点,分配一个 Slot,我们可以认为是位置信息,就是他在多节点的什么位置。每一个 Element 的Slot是前一个节点的引用。 updateChild(oldChild, newWidget, previousChild)方法中的的previousChild就是 本节点的Slot信息。只有自上而下的执行的时候,才会记录前一个节点是什么。比如 previousChild = newChild

核心代码如下:

    // 更新中间.
    while (newChildrenTop <= newChildrenBottom) {
      Element oldChild;
      final Widget newWidget = newWidgets[newChildrenTop];
      final Key key = newWidget.key;
      ///取出保存的Element,如果没有那么就是null
      oldChild = oldKeyedChildren[key];
      ///如果是null,会根据newWidget生成一个出来
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
    }
复制代码

注意这里⚠️: 锚点 如果可以取出来 Element,oldChild 就是保存过的。如果没有取出来,oldChild就是null,说明不存在可复用的。

执行流程如下:

Flutter 必知必会系列—— Element 的更新复用机制

现在生成或更新了头部和中间,那么下面就是最后一部分了,关于底部的处理。

更新底部部分的 Element

关于底部的处理和中间的处理类似,也会在先去 Map 中去取,如果没有再去生成新的。这里就不详细介绍了。

    // 更新底部.
    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = oldChildren[oldChildrenTop];
      final Widget newWidget = newWidgets[newChildrenTop];
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }

多节点更新小结

多节点的更新就是对自己的孩子节点更新。framework 更新的方式有两种:重新构造 Element 和复用 Element。判断的依据是 Widget.canUpdate 方法。

多节点的更新分为:自上而下的diff和更新、自下而上的diff扫描、保存旧列表中可以复用的Element、逐步顺序更新中间部分、逐步顺序更新底部。

总结

至此,常规的更新逻辑基本完事了。不得不说 Flutter 设计的真好~~。 这一篇偏向于理论,下一篇我们从实际的例子入手。