type
status
date
slug
summary
tags
category
icon

一、前言

flutter 中的 build(context) 算是我们开发 flutter 接触最多的一个函数,不管是大到页面,还是小到一个文本,都需要通过 build(context) 来构建,所以这个 build 函数的调用将会非常频繁。
所以对我们开发者来说,熟悉 build 函数的调用时机,了解某个操作会让哪些 widget 执行 build 是非常重要的。当然这不是一个立马可以学会的东西,因为不同组件的行为可能不一样,不过底层的原理都是相同的。
读完本文后,你将对 flutter 的 build、diff 复用机制 有个全局性的掌握,文章有点长,可以先收藏了慢慢看。
<!-- more -->

二、几个问题

在开始了解 Flutter 的 build 之前,我先提几个问题:
  1. 当父 widget 被 rebuild 的时候,请问子 widget 是否会被 rebuild?
  1. 当你执行了一次 setState(..),请问它的父 widget 是否会被 rebuild?
  1. 用了 StatefulWidget 就一定可以保存状态吗?如果不是,什么情况下会丢失状态?
  1. 当 widget 的 build 函数执行完成的时候,请问它的子 widget 的 build 函数是否执行了?
  1. 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
可以看到调用顺序是 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(..) 中:
markNeedsBuild(..) 会让 element 自身加入到 BuildOwner_dirtyElements 列表中。
根据 scheduleBuildFor 的注释,这个 _dirtyElement 是在 WidgetsBinding.drawFrame 触发 BuildOwner.buildScope(..)的时候消费的。
接下来我们来看下 buildScope(..) 函数,这个函数会先把 _dirtyElements 排下序,排序好之后依次调用 element.rebuild()
这里为什么要排序?关于这个原因我会在后面分析,这里你只需要知道在 buildScope 内会调用 element 的 rebuild()
现在我们已经知道 Element.markNeedsBuild 会让 element 变脏,然后在下一帧 drawFrame 的时候 rebuild
那么 Element.markNeedsBuild 什么时候会触发呢?它触发的地方比较多,但是最常见的地方就是我们 StatefulWidget 中的 setState((){}):

build 流程梳理

到这里为止,你应该对于 flutter 的 build 调用逻辑有了一个大概的流程模型,我们来整理下 setState((){}) 的流程:
notion image
  1. 首先我们在业务层调用了 setState((){}),根据上面的分析,这里会触发 element.markNeedsBuild()
  1. element 标记了 dirty 之后通过 buildOwner.scheduleBuildFor(element),将自己插入到 BuildOwner_dirtyElements
  1. BuildOwner 会等待下一次 WidgetsBindingdrawFrame(),在 drawFrame() 中会调用到 buildOwner.buildScope(..)
  1. 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() 函数的前半段逻辑,后半段逻辑没分析,我们来看下:
这里可以看到复用的关键点就在 element.updateChild(Element child, Widget newWidget, dynamic newSolt) 函数,我们来看下代码(这段代码很重要!这段代码很重要!这段代码很重要!):
上面的方法解析我给每一行都加上了解释 (请仔细阅读)。读完了之后应该就可以知道第一个问题和第三个问题的答案了:

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:
Navigator 中有两处使用了上述方法来减少 page 的 build。

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

StatefulWidget 中的 State 之所以可以持久化,是因为它被 StatefulElement 持有了,只要 StatefulElement 没有被重新创建,那么这个 State 就可以持久化:
根据 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<Element> oldChildren:旧的 child element list
  • List<Widget> newWidgets:新的 child widget list
  • Set<Element> 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) 函数会被调用,然后在这个基础上去优化
比如我在很多地方看到这样的代码:
小提示:你可知道只要 Navigator.push(..) 时候,push 的页面是 StatefulWidget,那么每次 push 新页面的时候,之前旧的页面都会被 rebuild 一次!

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

widget 只是一个 UI 配置,它在设计上内部的属性都应该是 final 的,所以千万别犯这种不符合 flutter 设计规范的错误,比如:
如果你这样写了,那么恭喜,你会疑惑为啥我的 _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 原则的代码了。
谈谈 Flutter 的页面Flutter 手势事件解析(二)
Loading...