西红柿带你看Flutter的更新机制

1,822 阅读14分钟

前言

Flutter Widget是一个现代的响应式框架,中心思想是用Widget构建UI。Weiget描述了UI配置信息。当widget的状态发送变化的时候,widget会重绘UI,Flutter会对比前后变化的不同,以确定底层渲染树从一个状态转换到下一个状态所需的最小更改。 这个最小的更改就是我们今天要说的更新机制

Element关系网

Flutter的三棵树是framework层的重要组成部分,Widget树用来描述UI的配置信息,Element树连接 配置信息和UI渲染的,而RenderObject负责根据配置信息去真正的进行UI渲染。
知道了Element的作用,我们👇看一下Element的结构体系: 从上图我们可以知道:

  • Element持有Widget的引用
Element就可以对比持有的Widget的引用是否有变化,就可以拿到和Widget相关的信息
  • Element实现了BuildContext接口
只要拿到Element就可以拿到Size信息
  • Element可以分为两种类型:组合型和渲染型
组合型的特点:只有一个子节点 ,并且子节点的内容就是Widget的build方法的内容。 

StatelessElement的子节点的内容,比如就是StatelessWidget 的build方法返回的Widget。

渲染型的特点:持有渲染对象RenderObject,直接参与了渲染树的生成

现在了解了Element的基本点,我们了解一下Element的生命周期。

Element生命周期

Element的声明周期如下: 我们来详细看每一个节点的作用。

inflateWidget方法

父节点会调用此方法来初始化子Widget,并创建子Widget对应的子Element对象。该方法的调用时机是:父节点初始化 或者 父节点更新的时候。

createElement方法

创建Widget对应的Element对象,并将Widget作为构造的Element的UI配置,这个时候Element就已经构造出来了,调用的时机是在inflateWidget方法中。

mount方法

framework调用Element的mount方法,将构造的Element添加到Element树上,并且会调用子节点的inflateWidget方法,去构造和挂载子节点。渲染型的Element还会在此时,去构造和绑定RenderObject。mount方法之后,构造的Element的状态是active,其就会显示在屏幕上。

更新

已经显示在屏幕上的Element,当父节点需要更新的时候,也就是触发了父节点的update方法之后,framework会比较更新父节点的Widget的key和runtimeType,如果相等,就会调用Element的update方法去更新自己。

销毁

Element的销毁分为主动销毁和被动销毁。主动销毁就是祖先节点可以主动的调用deactivateChild方法,去销毁子节点。被动销毁就是 父节点更新的时候发现Widget的key和runtimeType不相等,就会销毁父节点,同时Element也就被销毁了。

通过上面的生命周期分析,我们可以知道Element更新的操作,就是调用自己的update方法。不同类型的Element有不同的更新处理, 👇我们先解析一下多节点和单节点的具体的更新机制。

MultiChildRenderObjectElement更新

MultiChildRenderObjectElement是多节点类型的Element,它的更新就是 更新自己的所有子节点,所有的处理都在updateChildren方法中,处理流程如下:

  • 自上而下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引用就可以了。执行过程如下:

自上而下diff并更新子节点

  • 自下而上的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将子节点的列表分为三部分:头部、中间、底部,步骤一完成了头部的更新,本步骤就可以完成了三部分的划分。 执行过程如下:

自下而上的diff

  • 存储可复用的Element 那么什么样的Element可以被复用呢?就是Widget.canUpdate结果为true
Widget.canUpdate比较的是key和runtimeType。

key是作为标识存在的,key不一样framework 就不会去复用。
runtimeType不同 也没有 复用的必要,Text、Padding就是runtimeType。

步骤一,已经自上而下的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保存下来。执行流程如下:

存储可复用的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,说明不存在可复用的。

执行流程如下:

更新中间部分的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、逐步顺序更新中间部分、逐步顺序更新底部。

单节点的更新

上面我们看了多节点的更新:更新每一个子节点。那么子节点是如何更新的呢。下面来讲一下子节点的更新处理。子节点更新依旧是update方法。

我们想一个问题,什么时候Element可以复用呢?

如果新旧Widget是一个Widget,那肯定可以复用了
如果新旧Widget的Widget.canUpdate返回为true,肯定可以复用。
以上的的两种情况,可以复用Element,其余的情况就没有复用的必要了。👇我们看一下update方法。

update

方法入参

Element child: 想要更新的Element,可以为null,为null的时候就是不需要更新,直接构造。
Widget newWidget:想要显示的新的UI
dynamic newSlot:Slot信息,如果父节点是单节点,则改值为null。如果父节点不是单节点,改值就是前一个节点。

方法返回值

三种情况:
   null:不需要显示内容了
   child:复用child,只是修改child的信息(槽点、ui配置等)
   new:不可复用,直接新构造

核心代码如下:

 Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
    // ①
    if (newWidget == null) {
      return null;
    }
    if (child != null) {
      //新旧Widget是一个widget,就可以复用child了 ③
      if (child.widget == newWidget) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        return child;
      }
      
      if (Widget.canUpdate(child.widget, newWidget)) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        //key和runtimeType一样 就可以复用 ④
        child.update(newWidget);
        return child;
      }
      deactivateChild(child);
    }
    //重新构造  ② ⑤
    return inflateWidget(newWidget, newSlot);
  }

第一处代码:如果要显示的widget是null,说明没有要显示的内容了,就可以直接返回null

第二处代码:如果child为null,那说明没有可以复用的,就可以直接构造Element,比如初始化的时候,比如多节点更新的时候

第三处代码:新旧Widdget是一个Widget,那就可以复用Element了,但是我们看到,这里比较了槽点信息。举个例子,原来Element显示的UI是Text("xihongshi"),想要显示的UI同样是Text("xihongshi"),但是原来的时候Element显示在第一个位置,现在Element要显示在第二个位置了,这就是Slot槽点不一样。这种情况就是Element显示的内容不变,只是把槽点更新。

第四处代码:这就是新旧Widget的canUpdate值为true,那说明可以复用,直接更新显示的内容就可以了。

第五处代码:说明新旧Widget的canUpdate值为false,不可以复用,同样是构造Element

更新判断表格 更新判断

小结

  • 更新child的时候的复用标准
新旧Widget的值是否相等
新旧Widget的Key和RuntimeType是否相等
  • 对我们开发有什么帮助吗
当出现数据和UI不一致的时候,我们就要排除是不是复用了Element

了解了单节点和多节点的更新,我们下面来通过几个小案例来加上对更新机制的认识。

案例

再将具体案例之前,我们要明确一个概念: Widget是要显示的UI,Element是根据Widget去真正的显示UI 。以下案例的代码均在Flutter更新中。

案例一

代码案例在SwapColorDemo1类中。但我们点击按钮的时候,色块发生了互换。

StatelessWidget

从代码来看,Widget进行了交换。从现象来看,图块进行了交换

👆我们介绍了: Widget是要显示的UI,Element是根据Widget去真正的显示UI。 由于我们的颜色存在Widget中,那能做到图块交换就有以下几种情况:

Element的widget指向了 新Widget,比如原来Element指向的Widget是红色Widget,现在指向的是黄色Widget。
根据交换后的Widget,重新生成了Element。比如把原来承载红色Widget的Element,销毁掉并根据黄色Widget的生成Element。
直接交换Element。比如把红色和黄色的Element位置进行交换

下面我们就看一下,我们的代码属于上面的那一种情况。

当我们点击的时候,会触发build方法,就会触发Row的控件更新,Row对应的Element是MultiChildRenderObjectElement,它的更新就会执行到上面的我们讲的updateChildren方法。

我们的Widget长这个样子

因此在updateChildren方法中,我们只会停留在第一步的循环-----自上而下diff并更新子节点

因为Widget.canUpdate返回的是true。
因为新旧的Widget的runtimeType都是StatelessColorfulTile,并且由于我们没有手动设置Key,所以新旧Widget的key属性是null。所以canUpdate返回true。
那么framework就认为要复用了,所以执行到了更新child的操作。

👇我们看是如何更新child的。
入参情况:
    child是旧Element
    newWidgets是要显示的widget,这里就是我们自定义的Widget。
    solt没有发生变化 通过上面的更新判断表格,执行的复用更新逻辑。

 Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
        ...
        //key和runtimeType一样 就可以复用 ④
        child.update(newWidget);
        ...
  }

执行的是第四处代码的逻辑。 child是承载StatelessColorfulTile的Element,而StatelessColorfulTile就是StatelessWidget,所以child就是StatelessElement

调用流程如下:

显示的UI就是Element持有的Widget对象的build方法返回的UI,我们将颜色的信息保存在Widget中,因此就会正常的互换颜色。效果如下:

更新原因

图中的底块代表的是Element,文字代表Widget。Element没有改变,只是将指向的Widget改变了,这就是颜色互换现象的原因。

小结

StatelessWidget颜色替换的原因:

  • 复用了Element,并更新了Element指向的Widget
  • Widget是StatelessWidget,颜色存储在Widget中。

案例二

代码案例在SwapColorDemo2类中。但我们点击按钮的时候,色块竟然没有发生变化。

从代码来看,Widget确实进行了交换。从现象来看,图块进行了交换
那我们看一下,为啥从StatelessWidget替换为StatefulWidget之后,颜色咋就不变化了。

当我们点击的时候,会触发build方法,就会触发Row的控件更新,Row对应的Element是MultiChildRenderObjectElement,它的更新就会执行到上面的我们讲的updateChildren方法。

我们的Widget长这个样子

因此在updateChildren方法中,我们同样只会停留在第一步的循环-----自上而下diff并更新子节点

因为Widget.canUpdate返回的是true。
因为新旧的Widget的runtimeType都是StatefulColorfulTile,并且由于我们没有手动设置Key,所以新旧Widget的key属性是null。所以canUpdate返回true。
那么framework就认为要复用了,所以执行到了更新child的操作。

有了案例一分析,updateChild方法也是执行到代码四,但是装在widget的Element确是StatefulElement,调用流程如下:

和案例一的StatelessElement不同,这里Element生成的UI是State对象的build方法,我们的颜色也是存储在State中的,而State的初始化时机是在Element初始化的时候。如下代码:

 StatefulElement(StatefulWidget widget)
      : _state = widget.createState(),
        ...
        super(widget) 
  }

也就是说:Element和State对象是同生共死的,Element是复用的,因此Element的State引用是不变的,而我们颜色存储在State对象中,从而导致:Element只是替换了Widget,颜色没有变化。
更新效果如下:

小结

StatefulWidget颜色不替换的原因:

  • 复用了Element,并更新了Element指向的Widget
  • Element持有的State应用没有变化,颜色存储在State对象中

案例三

StatefulWidget并设置了Key,代码案例在SwapColorDemo3类中。但我们点击按钮的时候,色块竟然就可以交换了。

从代码来看,Widget确实进行了交换。从现象来看,图块进行了交换。和案例二相比的话,只是增加了Key
那我们看一下,为啥StatefulWidget增加了key之后,颜色咋就正常的交换变化了。

我们的Widget长这个样子

和案例一和二不一样,在updateChildren方法中,我们会先走到存储可复用的Element

因为每次diff的时候,key是不同的,所以跳过了步骤一 和 步骤二
比如原来第一个位置的Element的Widget的key是key1,要显示的Widget的key是key2,所以diff都会跳过
这里走完第三步骤之后Map的情况如下:

key1:Element1(Widget是1)
key2:Element2(Widget是2)

处理完第三步,就开始逐步的更新。现在原来显示Widget1地方,现在要显示Widget2。按照处理的流程:Widget2的key是key2,发现Map中有key2,那就取出来Element2。 但是原来Element2的前一个节点是Element1,现在Element2要显示在第一个位置,所以它的slot变为了null
所以:

 final Element newChild = updateChild(oldChild, newWidget,previousChild);
 入参情况:
    oldChild:是Element2,并且Element2的widget是Widget2
    newWidget:是widget2
    previousChild: null
-----------------------------------------------------------------------------
    oldChild:是Element1,并且Element1的widget是Widget1
    newWidget:是widget1
    previousChild: Element2
    

根据入参数的情况,updateChild方法也是执行到了 updateChild的代码第三处,但是装在新旧Widget相同,但是槽点信息不一样,从而将Element的位置进行了交换,所以颜色进行了交换。因为State进行了交换。 更新效果如下:

交换效果

小结

设置Key之后,StatefulWidget颜色进行了替换的原因:

  • 复用了Element,Element的Widget、State信息都进行了保留
  • 更新了Element的位置信息

案例四

我们给StatefulWidget包裹一个Padding,代码案例在SwapColorDemo4类中。但我们点击按钮的时候,色块竟然就可以随机出现了

随机出现

从代码来看,Widget确实进行了交换。从现象来看,图块进行了随机交换。和案例三相比的话,只是包裹了一层
经过了前面的分析,我们先考虑一个问题,什么情况下会随机出现颜色?

我们知道颜色存在State中,那就是承载State的Element重新创建了。那什么时候会重新创建呢,无法复用的时候。那么接下来,我们就看StatefulWidget的Element的存活情况。

在处理Padding的这一层的时候,处理的流程和案例一相似。只会停留在第一步的循环-----自上而下diff并更新子节点

因为Widget.canUpdate返回的是true。Padding没有Key
因为新旧的Widget的runtimeType都是Padding,并且由于我们没有手动设置Key,所以新旧Widget的key属性是null。所以canUpdate返回true。
那么framework就认为要复用了,所以执行到了更新child的操作。

  final Element newChild = updateChild(oldChild, newWidget, previousChild);
  入参情况:
   oldChild是旧Padding的Element
   newWidget是新Padding

根据入参的情况,会执行到updateChild的第四处代码。就是会执行Padding的update方法,接下来的就不用介绍了吧。在显示Padding的child的时候,发现两个child的Key不一样,也就是执行到了updateChild的第五处代码,所以Padding的Element的子Element进行了重新构造,所以保存颜色的State,也进行了重新构造。

更新效果如下:

交换效果

小结

包裹Padding之后,StatefulWidget颜色随机出现的原因:

  • 复用了Padding的Element,但是由于Padding的child带有Key,因此不会复用Padding的子节点不会复用

结语

我们基本了解了Flutter的更新机制,并通过具体的案例,详细介绍了它的判断和执行流程。西红柿相信大家对Flutter的更新有了一个较为深入的认识。
如果给Padding也增加key,大家知道会出现什么样的表现吗?欢迎留言~~~