深度分析 · 不同版本中的 Flutter 生命周期差异
学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,欢迎关注,共同进步。
欢迎搜索公众号:进击的Flutter或者runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,探讨你的问题,获取我的最新文章~
导语
无论是原生还是 Flutter,组件的生命周期一定是面试中必问的一个知识点。根据面试官的水平,程度可深可浅。对于开发者而言,理解生命周期的回调过程,能让我们更深刻的理解 framework 的设计,写更少的 BUG。
在之前的文章《面试官问我State的生命周期,该怎么回答》中,我们细致地分析了单个页面中 widget 的生命周期。但当发生页面切换时,又会触发怎样的生命周期?本期我们基于 1.12.13
和 1.22.2
两个版本,深入对比分析 Flutter 的生命周期差异,以及源码的变化演进。
一、1.12.13 版本与 1.22.2 版本(包含之后)生命周期对比
我们分别在两个版本测试同样的场景:页面A -打开-> 页面B -打开-> 页面C -返回-> 页面B。
1.12.13 版本生命周期
页面A | 页面B | 页面C | |
---|---|---|---|
A 打开 B | deactivate didChangeDependencies build | (先执行)initState build didChangeDependencies | ——— |
B 打开 C | 同B | deactivate didChangeDependencies build | (先执行)initState build didChangeDependencies |
C 返回 B | deactivate didChangeDependencies build | (先执行)deactivate didChangeDependencies build | (最后执行)deactivate dispose |
1.12.13版本的生命周期总结:
- 每次打开一个新页面,会先执行新页面的相关构建周期(init - didChange - build),而后执行路由堆栈中所有的页面的(deact - didChange - build)。
- 退出一个页面(记作 C)显示其他页面(记作 B)时,会先执行 B 的(deact- didChange - build),而后执行路由堆栈中所有的页面的(deact - didChange - build),最后执行 C 的(deact - dispose)。
注:路由堆栈中的页面会重复进行 build 但不会被渲染,需要检查 build 中是否有不合适的逻辑。
1.22.2 及以后版本生命周期
- 在 1.22.2(准确来说是 1.17)版本之后,每次打开页面只有新页面进行构建(init - didChange -build),上一个页面不会再次构建。
- 退出一个页面时,只有退出的页面会被(deact - dispose)
为什么在 1.12.13 上出现了这样令人不解的生命周期,从 1.12.13 到 1.22.2 又经历了何种修改?我们一起分析两个版本中的差异。
二、回顾:路由的整体设计(基于 1.12.13 版本)
为什么会出现这样的情况,这一切得从 Flutter 的路由源码说起。在上期 庖丁解牛 · 如何理解 Flutter 路由源码设计? 我们通过独立设计路由功能,理解了 Flutter 的路由源码。总结下来有几点:
- 1、Navigator 作为路由容器被嵌套在 MaterialApp 中,同时其内部嵌套了 Overlay,提供了页面切换的基本功能。
- 2、Navigator.of 方法将路由功能(push,pop等)提供给子节点。
- 3、Route 通过层次封装为我们提供了页面切换时的必备设计(转场动画,阻断交互等)。
其中,与生命周期息息相关的便是 Navigator 中的 Overlay 对象。
三、Overlay 是什么?(基于 1.12.13 版本)
要想搞懂 Overlay ,我们自下而上的了解一下这几个类的作用:
1、Stack、_Theatre、OverlayEntry、Overlay 介绍
Stack:native 中的相对布局
Stack 类似于原生中的相对布局,他的每个子 widget 会依次叠加的显示在 Stack 内部。如果子节点是 Positioned widget,可以设置与 Stack 各个方向的间距。
_Theatre:舞台+观众席
_Theatre 被直接嵌套在 Overlay 中,直译为剧院。这个组件的设计很有意思,就像现实中的剧院设计一般。 它由舞台和观众席两部分组成组成:
- onstage(舞台):舞台只有一个 -> Stack,Stack 中的一个个的演员(widget)在舞台上表演,他们是绘制到屏幕上的内容。
- offstage(观众席):观众席由多个观众(widget)组成,他们只参与到剧院中,并不会绘制。
Overlay、OverlayEntry
终于到我们的主角 Overlay 了,他是一个 StatefulWidget,维护了一个 OverlayEntry
集合。
每个 OverlayEntry 对象里包含三个关键属性:
- WidgetBuilder builder:实际显示的 widget(可以理解我们的每一个页面)
- bool _opaque:该 widget 是否为不透明状态
- bool _maintainState:改 widget 是否需要保持状态
Overlay会根据每个 entry 的的属性,决定这个 entry 对应的 widget 是否显示在屏幕上以及是否维持状态。
2、自上而下梳理流程
记住上面几个概念之后,我们结合源码分析一下 OverlayState 中的 build
流程:
class OverlayState extends State<Overlay> with TickerProviderStateMixin {
/// OverlayEntry 集合
final List<OverlayEntry> _entries = <OverlayEntry>[];
Widget build(BuildContext context) {
/// 舞台上的 widget(会被绘制)
final List<Widget> onstageChildren = <Widget>[];
/// 观众席上的 widget (保持状态,不会被绘制)
final List<Widget> offstageChildren = <Widget>[];
bool onstage = true;
/// 倒序遍历!!!!!!!!
for (int i = _entries.length - 1; i >= 0; i -= 1) {
final OverlayEntry entry = _entries[i];
if (onstage) {
onstageChildren.add(_OverlayEntry(entry));
/// 如果 opaque(不透明)为 true,表示后面的 widget 都不需要绘制
if (entry.opaque)
onstage = false;
} else if (entry.maintainState) {
///不被绘制但是 maintainState 为 true 的对象,全部加入观众席。
offstageChildren.add(TickerMode(enabled: false, child: _OverlayEntry(entry)));
}
}
return _Theatre(
onstage: Stack(
fit: StackFit.expand,
/// 再次倒序 (变成正序)
children: onstageChildren.reversed.toList(growable: false),
),
offstage: offstageChildren,
);
}
}
Overlay 中维护了一个 OverlayEntry 的集合,build 方法会倒序遍历这个集合,根据每个 entry 的属性,放到两组集合中:
- 1、onstageChildren:在倒序遍历 OverlayEntry 集合过程中,添加每一个 entry 到集合中。直到 opaque(不透明)为 true,表示该 entry 不透明,所以后面的 entry 都不需要被绘制。这个集合被放置到舞台上(Stack)最终渲染到屏幕。
- 2、offstageChildren:opaque 为 true 之后的对象都不会被绘制到屏幕上,但如果 entry 的maintainState 为 true,系统不会将其销毁,而是维持它的状态。
注意两个集合中的对象都是:_OverlayEntry,这是一个 StatefulWidget
class _OverlayEntry extends StatefulWidget {
_OverlayEntry(this.entry):
/// 对当前 widget 指定entry 中的 key
super(key: entry._key);
final OverlayEntry entry;
@override
_OverlayEntryState createState() => _OverlayEntryState();
}
class _OverlayEntryState extends State<_OverlayEntry> {
@override
Widget build(BuildContext context) {
return widget.entry.builder(context);
}
}
它的 build 直接调用 OverlayEntry 的 builder 构建。关键点在在构造函数中,它会给自己加上一个从 entry 中获取的 GlobalKey,记住这点很重要。
回过头看看 Overlay 的整体设计:
如图,Overlay 中有五个 entry,在倒序遍历到 Entry4 的时候,其 opaque 为 true,则后续的 entry 无论 opaque 取何值都不会再添加到舞台上。对于 Entry2 其 maintainState 为 false,则 Entry2 对应的 widget 会在当前帧绘制的最后阶段进行 dispose。
四、1.12.13 版本生命周期分析
以 页面A -打开-> 页面B 为例,根据日志我们知道,页面B 执行「 init - didChange - build」之后 页面 A「deact - didChange - build」。页面B 进行初始化我们能理解,为什么页面A 以及堆栈中的其他页面会走这些多余的生命周期?
先看结论:
每一个页面(即_OverlayEntry)都带有 Gloablekey。在页面切换时,他们在原有位置上被 deactivated(对应 deactive 方法)。在新的位置 inflate,由于具有 Gloablekey 他们不会重新初始化,而是再次激活。激活过程执行了页面的 didchange - build
下面我们结合 页面 A 打开页面 B 这个过程 来具体分析
1、Overlay 进行 build
一开始,只有页面A 对应的 _OverlayEntry 对象在 onstage 集合中被绘制屏幕上。当我们 push 页面B 时,根据上期分析我们知道,页面B 会被转换为 _OverlayEntry 被添加到集合中,进行 setState。
/// overlay.dart
void insertAll(Iterable<OverlayEntry> entries, { OverlayEntry below, OverlayEntry above }) {
setState(() {
_entries.insertAll(_insertionIndex(below, above), entries);
});
}
所以 Overlay 对象被重新构建,在它的 build 方法,对他的 entry 进行分组并添加到 _Theatre中:
for (int i = _entries.length - 1; i >= 0; i -= 1) {
final OverlayEntry entry = _entries[i];
if (onstage) {
onstageChildren.add(_OverlayEntry(entry));
if (entry.opaque)
onstage = false;
} else if (entry.maintainState) {
/// 非 onstage 的对象 被包裹了 TickerMode
offstageChildren.add(TickerMode(enabled: false, child: _OverlayEntry(entry)));
}
}
return _Theatre(
onstage: Stack(
fit: StackFit.expand,
children: onstageChildren.reversed.toList(growable: false),
),
offstage: offstageChildren,
);
这时状态变成了下面的样子:
2、_TheatreElement 的更新
由于 Overlay 的进行了 setState,也会触发子节点 _Theatre 的更新,我们看看他对应的 element 是如何更新:
@override
void update(_Theatre newWidget) {
super.update(newWidget);
/// 先更新 _onstage
_onstage = updateChild(_onstage, widget.onstage, _onstageSlot);
_offstage = updateChildren(_offstage, widget.offstage, forgottenChildren: _forgottenOffstageChildren);
_forgottenOffstageChildren.clear();
}
过程很简单,先对 _onstage 中的内容进行更新,我们知道 onstage 中的内容由页面B 变成了 页面A。所以对于页面B 会执行他的 inflateWidget 流程,进行初始化的生命周期。而对于页面A 则被移除,执行 deactivate 流程。
而对 _offstage 更新的时候,由于新加入了 _OverlayEntryA 所以执行 inflateWidget 流程:
@protected
Element inflateWidget(Widget newWidget, dynamic newSlot) {
final Key key = newWidget.key;
if (key is GlobalKey) {
/// 根据 key 获取已经存在的的 element 对象
final Element newChild = _retakeInactiveElement(key, newWidget);
if (newChild != null) {
/// 直接激活获取的 element 对象
newChild._activateWithParent(this, newSlot);
final Element updatedChild = updateChild(newChild, newWidget, newSlot);
assert(newChild == updatedChild);
return updatedChild;
}
}
///*******省略无关代码*********///
}
关键流程有两步,首先由于 _OverlayEntryA 存在 GlobalKey(每个 _OverlayEntry 都会被赋予),会根据 key 取出已存在的 element 对象,之后直接激活这个对象。
@mustCallSuper
void activate() {
if (hadDependencies)
didChangeDependencies();
}
@override
void didChangeDependencies() {
super.didChangeDependencies(); /// ->markNeedsBuild
_state.didChangeDependencies();
}
里面首先调用了 markNeedsBuild 方法,将自己标记为脏。这样下一帧绘制的时候会重新 build,在当前帧内,调用对应 state 对象的 didChangeDependencies,于是出现了我们上面看到的现象。
需要注意的是,页面A 并不会进行 dispose。dispose 是在一帧绘制的最后阶段,由 buildOwner.finalizeTree()
方法触发,他会回收前面所有被 deactivate 掉的节点。由于我们在 offstage中重新激活了页面A,所以他不会被 dispose。但如果页面A 没有被添加到 offstage 中(即 maintainState 为 false),则会在最后阶段触发 dispose。
五、优化 PR 简易分析
在 1.17 之后,Michael Goderbauer 大佬对重复 build 的问题进行了优化,相关 PR 在这 Reland “Do not rebuild Routes when a new opaque Route is pushed on top”,我们简单分析一下他是如何优化。
前面我们提到,重复构建的原因是:
每一个页面(即_OverlayEntry)都带有 Gloablekey。在页面切换时,他们在原有位置上被 deactivated(对应 deactive 方法)。在新的位置 inflate,由于具有 Gloablekey 他们不会重新初始化,而是再次激活。激活过程执行了页面的 didchange - build
在这个 PR 中,把 _Theatre 改造成类似 Stack 一样可以层叠渲染的组件,接受一个 widget 集合,不再区分 onstage 和 offstage。组件内部通过 skipCount
,这个值会决定哪些 widget 需要渲染。而 Overlay 中,根据 opaque 的值计算 skipCount,达到选择渲染的效果。
/// OverlayState.build
@override
Widget build(BuildContext context) {
final List<Widget> children = <Widget>[];
bool onstage = true;
int onstageCount = 0;
for (int i = _entries.length - 1; i >= 0; i -= 1) {
final OverlayEntry entry = _entries[i];
if (onstage) {
onstageCount += 1;
children.add(_OverlayEntryWidget(key: entry._key,entry: entry));
if (entry.opaque)
onstage = false;
} else if (entry.maintainState) {
children.add(_OverlayEntryWidget(key: entry._key, entry: entry,tickerEnabled: false,));
}
}
return _Theatre(
skipCount: children.length - onstageCount,
children: children.reversed.toList(growable: false),
);
}
可以简单看看 _RenderTheatre 中是如何进行选择渲染的:
class _RenderTheatre extends RenderBox with ContainerRenderObjectMixin<RenderBox, StackParentData> {
/// 绘制的时候 会获取 _firstOnstageChild
RenderBox get _firstOnstageChild {
if (skipCount == super.childCount) {
return null;
}
RenderBox child = super.firstChild;
/// 根据 skipCount 跳过渲染对象
for (int toSkip = skipCount; toSkip > 0; toSkip--) {
final StackParentData childParentData = child.parentData as StackParentData;
child = childParentData.nextSibling;
}
return child;
}
}
在 _RenderTheatre 渲染的时候,会先获取第一个需要绘制的节点 _firstOnstageChild ,_firstOnstageChild 根据 skipCount 从集合中跳过不需要渲染对象。这样所有的页面切换都在一个组件里,所以解决了重复 build 的问题(妙啊.jpg)。其中还有一些细节,大家可以再仔细看看这个 PR。
详情可见:OverlayEntries 和 Routes 进行了重建优化 。
六、总结 1.12 和 1.22 生命周期对比
1.12.13版本的生命周期总结:
- 每次打开一个新页面,会先执行新页面的相关构建周期(init - didChange - build),而后执行路由堆栈中所有的页面的(deact - didChange - build)。
- 退出一个页面(记作 C)显示其他页面(记作 B)时,会先执行 B 的(deact- didChange - build),而后执行路由堆栈中所有的页面的(deact - didChange - build),最后执行 C 的(deact - dispose)。
- 在 1.22.2(准确来说是 1.17)版本之后,每次打开页面只有新页面进行构建(init - didChange -build),上一个页面不会再次构建。
- 退出一个页面时,只有退出的页面会被(deact - dispose)
七、最后 感谢各位吴彦祖和彭于晏的点赞和关注
这期文章真的是憋了太久,因为首先看到 didChangeDependencies 的时候,我就朝着 InheritedWidget 的方向去思考,结果完全偏离了正确的方向。在后来,我完全清空思路,通过断点和 Element 树的逐步分析,才捋清了逻辑。可能一些大型的项目还在使用 1.12.13 之前的版本(没错就是我们!!),明白老版本路由设计也能帮助我们写更少的 BUG。对于 1.17 之后的版本,不用担心生命周期相关的问题。我们,都有光明的未来哈哈哈。
过程中发现 Flutter 的渲染基础真的非常重要,如果你在阅读本文时感到吃力,建议先阅读:
Widget、Element、Render是如何形成树结构?
当然也欢迎在评论区提出你的疑惑~
如果你觉得文章写得还不错~ 点个关注、点个赞啦,彦祖~
欢迎搜索公众号:进击的Flutter或者 runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,获取我的最新文章~
上周参加了掘金的创作者活动技术创作者们,快来这里交作业啦 | 创作者训练营第二期 ,多位行业大牛介绍了技术写作、思维提升、职场晋升等等心得,受益匪浅。链接中有录播,一起提笔开启你的写作之旅吧。