爆裂吧现实

不想死就早点睡

0%

谈谈 Flutter 的 build

一、前言

flutter 中的 build(context) 算是我们开发 flutter 接触最多的一个函数,不管是大到页面,还是小到一个文本,都需要通过 build(context) 来构建,所以这个 build 函数的调用将会非常频繁。

所以对我们开发者来说,熟悉 build 函数的调用时机,了解某个操作会让哪些 widget 执行 build 是非常重要的。当然这不是一个立马可以学会的东西,因为不同组件的行为可能不一样,不过底层的原理都是相同的。

读完本文后,你将对 flutter 的 build、diff 复用机制 有个全局性的掌握,文章有点长,可以先收藏了慢慢看。

二、几个问题

在开始了解 Flutter 的 build 之前,我先提几个问题:

  1. 当父 widget 被 rebuild 的时候,请问子 widget 是否会被 rebuild?
  2. 当你执行了一次 setState(..),请问它的父 widget 是否会被 rebuild?
  3. 用了 StatefulWidget 就一定可以保存状态吗?如果不是,什么情况下会丢失状态?
  4. 当 widget 的 build 函数执行完成的时候,请问它的子 widget 的 build 函数是否执行了?
  5. flutter 中的三棵树(widget tree、element tree、render tree)是不是都是一一对应的?比如一个 widget 对应一个 element,一个 element 对应一个 render object?

如果你对于上述问题的答案了如指掌,理解其中的细节,那么本文不是为你准备的,可以关掉页面走了。如果你都不知道,那么说明你对 flutter 里面的 build 和复用机制不了解,需要补习了。

三、知识点:

1. widget 的 build 函数是谁调用的?

关于这个问题,我们先来看先平常用的 StatelessWidgetStatefulWidget 这两个组件,这两个 widgetbuild 函数分别在 widgetstate 内,build 函数的调用者都是各自的 elementStatelessElementStatefulElement

那么 elementbuild 是谁调用的呢?继续看下 StatelessElementStatefulElement 的父类 ComponentElement

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/// An [Element] that composes other [Element]s.
abstract class ComponentElement extends Element {
@override
void performRebuild() {
Widget built;
try {
// 这里调用 build 函数
built = build();
...
} catch (e, stack) {
// 这段代码我没省略,这里可以看到在 build 函数出现异常的时候
// 会构建一个 error widget,这个其实就是显示红屏错误的 widget
built = ErrorWidget.builder(
_debugReportException(
ErrorDescription('building $this'),
e,
stack,
informationCollector: () sync* {
yield DiagnosticsDebugCreator(DebugCreator(this));
},
),
);
} finally {
...
}
...
}

@protected
Widget build();
}

abstract class Element {
/// Called by the [BuildOwner] when [BuildOwner.scheduleBuildFor] has been
/// called to mark this element dirty, by [mount] when the element is first
/// built, and by [update] when the widget has changed.
void rebuild() {
...
performRebuild();
...
}
}

可以看到调用顺序是 Element.rebuild() -> ComponentElement.performRebuild() -> ComponentElement.build(),那么 Element.build() 是谁调用的?

这个在它的注释中有写: Called by the [BuildOwner] when [BuildOwner.scheduleBuildFor],也就是由 BuildOwner 调用的(BuildOwner 在 element tree 中是唯一的,它保存在 WidgetsBinding.instance.buildOwner 中)。

那么这个 BuildOwner.scheduleBuildFor(..) 是如何触发的呢?答案在 Element.markNeedsBuild(..) 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
abstract class Element {

void markNeedsBuild() {
//...
if (dirty)
return;
// 标记脏
_dirty = true;
// 可以可以看到把 element 自身加入到了 [BuildOwner] 中
owner.scheduleBuildFor(this);
}
}

class BuildOwner {
/// Adds an element to the dirty elements list so that it will be rebuilt
/// when [WidgetsBinding.drawFrame] calls [buildScope].
void scheduleBuildFor(Element element) {
// ... 略去非核心逻辑
if (element._inDirtyList) {
// 表明 element 已经在 dirty 列表了
_dirtyElementsNeedsResorting = true;
return;
}
// 加入到 dirty 列表
_dirtyElements.add(element);
element._inDirtyList = true;
}

void buildScope(Element context, [ VoidCallback callback ]) {
// ...
// 会先排序 _dirtyElements,排序规则是先判断深度
// 深度浅的在前,深度深的在后。
// 深度相同的判断是否有脏标记,有脏标记的在前,没有的在后
_dirtyElements.sort(Element._sort);
_dirtyElementsNeedsResorting = false;
int dirtyCount = _dirtyElements.length;
int index = 0;
// 依次遍历 _dirtyElements,调用 rebuild()
while (index < dirtyCount) {
// 这里调用 rebuild
_dirtyElements[index].rebuild();
index += 1;
// ...
}
}
}

markNeedsBuild(..) 会让 element 自身加入到 BuildOwner_dirtyElements 列表中。

根据 scheduleBuildFor 的注释,这个 _dirtyElement 是在 WidgetsBinding.drawFrame 触发 BuildOwner.buildScope(..)的时候消费的。

接下来我们来看下 buildScope(..) 函数,这个函数会先把 _dirtyElements 排下序,排序好之后依次调用 element.rebuild()

这里为什么要排序?关于这个原因我会在后面分析,这里你只需要知道在 buildScope 内会调用 elementrebuild()

现在我们已经知道 Element.markNeedsBuild 会让 element 变脏,然后在下一帧 drawFrame 的时候 rebuild

那么 Element.markNeedsBuild 什么时候会触发呢?它触发的地方比较多,但是最常见的地方就是我们 StatefulWidget 中的 setState((){}):

1
2
3
4
5
6
7
8
abstract class State<T extends StatefulWidget> {
@protected
void setState(VoidCallback fn) {
//...
// 这里最后调用了 state 对应的 element,然后 markNeedsBuild
_element.markNeedsBuild();
}
}

build 流程梳理

到这里为止,你应该对于 flutter 的 build 调用逻辑有了一个大概的流程模型,我们来整理下 setState((){}) 的流程:

  1. 首先我们在业务层调用了 setState((){}),根据上面的分析,这里会触发 element.markNeedsBuild()

  2. element 标记了 dirty 之后通过 buildOwner.scheduleBuildFor(element),将自己插入到 BuildOwner_dirtyElements

  3. BuildOwner 会等待下一次 WidgetsBindingdrawFrame()*,在 *drawFrame() 中会调用到 buildOwner.buildScope(..)

  4. buildScope() 中会先排序所有的 _dirtyElements,然后依次执行 *element.rebuild()*,最终会走到 state.build(context)

这样梳理下来,相信你对 setState((){})state.build(context) 已经有个大概的了解了。

到这里为止,我再问你一开始的第四个问题:当 widget 的 build 函数执行完成的时候,请问它的子 widget 的 build 函数是否执行了?

可以得到明确的答案就是:不会执行。这个问题其实算个陷阱,很容易让人以为在 parent widget build() 函数里面会执行 child widget build() ,其实 widgetbuild() 是由对应的 element 调用的,和父 widgetbuild() 函数无关。

现在我们还不知道其它问题的答案,这需要我们来了解另一块知识点:Flutter 中的复用 / diff 机制,看累的同学可以中场休息一会儿。


2. Flutter 中的复用机制

这里先想个问题,何为复用?比如 Android 里面的 RecycleView 中的复用就是 View 的复用,当需要显示一个新的列表元素的时候,会先判断这个列表元素对应的 View 是否有缓存,有缓存的话就直接拿来用,然后绑定新数据( Android /中创建 View 是很耗费资源的)

我们知道 Flutter 渲染树中具有持久化的节点是 Element,保存了视图信息的节点是 Widget。也就是复用就是将新的 Widget(信息)绑定到旧的 Element 上。

根据前面的知识,当我们调用了 setState((){})之后,会触发 element.rebuild(),接着触发 element.performRebuild(),之前值分析了 performRebuild() 函数的前半段逻辑,后半段逻辑没分析,我们来看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/// An [Element] that composes other [Element]s.
abstract class ComponentElement extends Element {
@override
void performRebuild() {
Widget built;
// ...
try {
// 这里调用 build 函数
built = build();
} catch (e, stack) {
// ... 这里省略红屏逻辑
}
// ...
// -------- 后半段逻辑 -------
try {
// 将新的 child widget (built) 更新到 child element (_child) 上
_child = updateChild(_child, built, slot);
assert(_child != null);
} catch (e, stack) {
// 这里同样有红屏逻辑,也就是除了前面 build 函数异常会触发红屏逻辑,这里也会
built = ErrorWidget.builder(
_debugReportException(
ErrorDescription('building $this'),
e,
stack,
informationCollector: () sync* {
yield DiagnosticsDebugCreator(DebugCreator(this));
},
),
);
// 把红屏 widget 当做新的 widget 更新到 child element 上。
_child = updateChild(null, built, slot);
}
}

@protected
Widget build();
}

这里可以看到复用的关键点就在 element.updateChild(Element child, Widget newWidget, dynamic newSolt) 函数,我们来看下代码(这段代码很重要!这段代码很重要!这段代码很重要!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
abstract class Element extends DiagnosticableTree implements BuildContext {

/// 这个函数就是复用的逻辑,当 element 的 child widget 更新的时候,是否复用原先旧的 element
///
/// [child] 复用的 element,旧的 child element
/// [newWidget] child element 对应的新的 widget
/// [newSlot] 数据插槽
@protected
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
if (newWidget == null) {
if (child != null)
// 如果新的 child widget 是 null,前一次的 child widget 不是 null
// 反激活前一次的 child element,注意这里是反激活,并不会立即销毁
// 这个函数会把 child element 放到 build owner 的 _inactiveElements 数组中、
// 等到到了 finalizeTree() 的时候,如果 element 还没有从 _inactiveElements 中移走
// child element 才会被销毁
deactivateChild(child);
return null;
}
// 旧的 child element 不为 null,可能可以复用
if (child != null) {
// 如果旧的 element 的 widget 和新的 chid widget 相等
// 注意这里是相等判断,一般来说就是两个 widget 是否是同一个引用
if (child.widget == newWidget) {
// 插槽不一样的时候更新插槽,这个不会触发 child rebuild
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
// 这里函数就结束了,说明这种情况下不会触发 child 的 build
return child;
}
// 判断当前 child element 是否可以复用在 new child widget 上
if (Widget.canUpdate(child.widget, newWidget)) {
// 更新插槽,没啥特别的逻辑
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
// 调用 child element 的 update,如果这里的 child 是 ComponentElement
// 则会触发 child element 的 rebuild
child.update(newWidget);
// .. 省略断言,结束函数
return child;
}
// 说明不可以复用原先旧的 element,反激活 child element,注意这里是反激活
deactivateChild(child);
assert(child._parent == null);
}
// 根据 new widget 重新生成一个新的 child element
// 这里不一定是使用新的 child element,如果 newWidget 的 key 是个 GlobalKey,而且 key 里面保存着有
// 有效的 child element,那么就会复用 key 中的 child element
return inflateWidget(newWidget, newSlot);
}
}

上面的方法解析我给每一行都加上了解释 (请仔细阅读)。读完了之后应该就可以知道第一个问题和第三个问题的答案了:

1. 当父 widget rebuild 的时候,子 widget 是否会 rebuild?

假定父 widgetParentWidget,子 widgetChildWidget。当父 widget 重建后,会调用到parentElement.updateChild(oldChildElement, newChildWidget, newSlot)。此时按照上述代码可以分为三种情况:

  • oldChildWidgetnewChildWidget 相等,这里基本上就是两者是同一个对象实例,此时不会触发 child 的 update,也不会触发 newChildWidget 的 build
  • newChildWidget 可以更新到 oldChildWidget 上(Widget.canUpdate(child.widget, newWidget) 返回 true),此复用原先的 child element,还会调用 newChildWidget 的 build
  • newChildWidget 不可以更新到 oldChildWidget 上,反激活原先的 child element,重新获取一个 child element

Widget.canUpdate(..) 可以更新的条件就是 widget 的 type 和 key 是否相等,这个比较简单,就不把源码列出来了

所以这里得出答案是,子 widget 不一定会 rebuild,比如这个就不会触发子 widget 的 build:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ParentWidget extends StatefulWidget {
@override
_ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {

ChildWidget childWidget;

@override
Widget build(BuildContext context) {
return RaisedButton(
// 关键代码
child: childWidget ??= ChildWidget(),
onPressed: () {
// 这里触发 ParentWidget 的 rebuild
// 但是并不会触发 child 的 build
setState(() {});
},
);
}
}

class ChildWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 100,
height: 100,
color: Colors.red,
);
}
}

Navigator 中有两处使用了上述方法来减少 page 的 build。

2. 用了 StatefulWidget 就一定可以保存状态吗?如果不是,什么情况下会丢失状态?

StatefulWidget 中的 State 之所以可以持久化,是因为它被 StatefulElement 持有了,只要 StatefulElement 没有被重新创建,那么这个 State 就可以持久化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class StatefulElement extends ComponentElement {
/// The [State] instance associated with this location in the tree.
///
/// There is a one-to-one relationship between [State] objects and the
/// [StatefulElement] objects that hold them. The [State] objects are created
/// by [StatefulElement] in [mount].
State<StatefulWidget> get state => _state;
/// 这里保存了 _state
State<StatefulWidget> _state;

@override
void unmount() {
super.unmount();
_state.dispose();
//... 省略断言
_state._element = null;
// 只有 element 被销毁了,_state 才会被销毁
_state = null;
}
}

根据 element.updateChild(..) 函数中的逻辑,child element 是不一定可以复用的,比如这个 demo 中的 _ChildWidgetState 就会被销毁重建。

如果你要改变某个 widget 在树种的层级位置,还想保留原先的 element,那么可以使用 GlobalKey,具体逻辑参阅 inflateWidget(newWidget, newSlot)

3. Flutter 中的 diff 机制 RenderObjectElement

其实上面的复用机制也可以叫做 diff 机制,只不过我这里 diff 特指一个 element 有多个 child element 的情况。Flutter framework 目前对这种情况作了一个 diff 算法,该算法在 RenderObjectElement.updateChildren(..) 里面。

这里先提下这个 RenderObjectElement,它和我们前面的 ComponentElement 有啥区别?

我们知道最后交给 engine 渲染的时候用到的并不是 element tree,而是需要 render tree,通过 render tree 计算出 layer tree 然后交给 engine。

但是 ComponentElement 其实并没有 RenderObject,它没有复写 Elementget renderObject 这方法,只有 RenderObjectElement 才有自己的 RenderObject,这个就是区别!

这里可以知道第五个问题的答案,widget 和 element 是一一对应的,但是 element 和 renderObject 不是

好了,扯远了,我们回到 RenderObjectElement.updateChildren(..) 这个函数,这个函数的输入是:

  • List oldChildren:旧的 child element list
  • List newWidgets:新的 child widget list
  • Set forgottenChildren(可选):如果 child 在这个里面,就忽略复用

函数会根据输入的 newWidgets 来更新 oldChildren,最后返回一个新的 child element list。这里在更新的时候就会有一个 diff 算法,这个算法逻辑有点复杂,我直接说结论,有兴趣的同学可以看下源码

  • 先从上往下依次匹配 oldChildElementnewChildWidget,如果可以更新(Widget.canUpdate(,)返回 true),则直接更新 updateChild(oldChildElement, newChildWidget),一直到匹配失败
  • 再从下往上依次匹配 oldChildElementnewChildWidget,直到不可以更新,保存遍历的 index(注意,这里没有 updateChild 为的是保证 updateChild 是从小到大的,所以只保存 index,最后更新)
  • 中间的根据 newChildWidgetkey 从剩余的 oldChildElement 中查找,找到就尝试匹配,找不到或者匹配失败就不走复用
  • 最后开始更新之前第二步 pending 的 children 们

四、总结

内容有点多,我们来归纳下得到的一些结论:

  • StalessWidgetStatefulWidgetbuild 函数基本随时都可能被触发
  • 当一个 widget 触发 rebuild 的时候,它的 child 可能触发 rebuild 也可能不触发
  • StatefulWidget 不一定能一直保存状态,这个取决于复用逻辑:newWidget 是否可以更新到 oldElement 上
  • 多 child 的 element 中,key 在复用方面有着很大的作用:diff 算法中,中间一段会根据 key 来匹配复用

基于上述几点结论,我这里再给出几个比较有意义的建议:

1. 随时考虑 build 函数会被调用

你在业务开发的时候,是保证不了整体的 build() 函数控制的,这样不符合 flutter 的设计。你要随时考虑 build(context) 函数会被调用,然后在这个基础上去优化

比如我在很多地方看到这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Page extends StatelessWidget {

@override
Widget build(BuildContext context) {
// 页面 show 事件,不应该被放到 build 中,一旦 Page rebuild 了,就会多次 show
Logger.instance.onPageShow();
return PhotoList(
// 一旦 _Page rebuild 了,这里的 bloc 就会重建,导致之前的数据全部丢失
bloc: PhotoBloc(),
);
}
}

小提示:你可知道只要 Navigator.push(..) 时候,push 的页面是 StatefulWidget,那么每次 push 新页面的时候,之前旧的页面都会被 rebuild 一次!

2. 千万不要在 widget 中保存数据

widget 只是一个 UI 配置,它在设计上内部的属性都应该是 final 的,所以千万别犯这种不符合 flutter 设计规范的错误,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
class WidgetA extends StatelessWidget {

Map<String, dynamic> _extra = {};

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
_extra["onTap"] = true;
}
);
}
}

如果你这样写了,那么恭喜,你会疑惑为啥我的 _extra 经常会被清空。

3. 局部刷新的技巧 setState((){}) 下沉

根据前面的只是,我们已经知道了 setState((){}) 就是个局部刷新。它就是把当前的 element 标记成脏,然后加入到 buildOwner 中的 _dirtyElements 列表中,等待下一帧的时候 rebuild

这里可以延伸出一种优化,setState 下沉,一般情况下我们是不会保存 widget 对象的,也就 setState触发后,child widget 基本都会 rebuild。如果我们只需要更新子 widget 中的信息,就不要尽量不要去调用父 widget 中的 setState,这一点在写动画的时候效果特别明显: sample demo

4. 使用 key 来保持复用

如果你想要在移动 StatefulWidget 层级的时候保存它的状态,根据之前的分析可以使用 GlobalKey

如果你想要重排序某个 MultiRenderObjectElement 下面的 children(例如在 Stack 里面重新排序 children 的上下顺序),那么给每个 child widget 加上一个唯一标志的 key(比如 ValueKey(id)),只要重排序后,widget.key 没有变化,那么根据之前分析的 diff 机制,它会复用之前的 element。


到这里位置关于 flutter build 的一些基础内容就讲完了,相信你读完本文后不会再写出一些违背 flutter 原则的代码了。