前言
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
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将子节点的列表分为三部分:头部、中间、底部,步骤一完成了头部的更新,本步骤就可以完成了三部分的划分。 执行过程如下:
- 存储可复用的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进行了保存。那么就可以顺序进行更新操作了,根据新传入的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 关于底部的处理和中间的处理类似,也会在先去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类中。但我们点击按钮的时候,色块发生了互换。
从代码来看,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,大家知道会出现什么样的表现吗?欢迎留言~~~