深度分析·不同版本中的 Flutter 生命周期差异 | 创作者训练营第二期

3,147 阅读12分钟

深度分析 · 不同版本中的 Flutter 生命周期差异

学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,欢迎关注,共同进步。image.png 欢迎搜索公众号:进击的Flutter或者runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,探讨你的问题,获取我的最新文章~

导语

无论是原生还是 Flutter,组件的生命周期一定是面试中必问的一个知识点。根据面试官的水平,程度可深可浅。对于开发者而言,理解生命周期的回调过程,能让我们更深刻的理解 framework 的设计,写更少的 BUG

在之前的文章《面试官问我State的生命周期,该怎么回答》中,我们细致地分析了单个页面中 widget 的生命周期。但当发生页面切换时,又会触发怎样的生命周期?本期我们基于 1.12.131.22.2 两个版本,深入对比分析 Flutter 的生命周期差异,以及源码的变化演进。


一、1.12.13 版本与 1.22.2 版本(包含之后)生命周期对比

我们分别在两个版本测试同样的场景:页面A -打开-> 页面B -打开-> 页面C -返回-> 页面B。

1.12.13 版本生命周期

1.12.13版本路由演示.gif

页面A页面B页面C
A 打开 Bdeactivate
didChangeDependencies
build
(先执行)initState
build
didChangeDependencies
———
B 打开 C同Bdeactivate
didChangeDependencies
build
(先执行)initState
build
didChangeDependencies
C 返回 Bdeactivate
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版本.gif

  • 在 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,提供了页面切换的基本功能。 image.png
  • 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:舞台+观众席

image.png

_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 的整体设计:

image.png

如图,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

image.png

一开始,只有页面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,
    );

这时状态变成了下面的样子:

image.png

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,于是出现了我们上面看到的现象。

image.png

需要注意的是,页面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 的渲染基础真的非常重要,如果你在阅读本文时感到吃力,建议先阅读:

为什么不建议大家使用setState()。

面试官问我State的生命周期,该怎么回答

Flutter的布局约束原理

Widget、Element、Render是如何形成树结构?

当然也欢迎在评论区提出你的疑惑~

如果你觉得文章写得还不错~ 点个关注、点个赞啦,彦祖~

欢迎搜索公众号:进击的Flutter或者 runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,获取我的最新文章~

上周参加了掘金的创作者活动技术创作者们,快来这里交作业啦 | 创作者训练营第二期 ,多位行业大牛介绍了技术写作、思维提升、职场晋升等等心得,受益匪浅。链接中有录播,一起提笔开启你的写作之旅吧。