Flutter 布局生命周期详解

502 阅读18分钟

背景

我们在学习某个新技术的时候,首先至关重要的是要去了解他的一个整体流程如生命周期等,这样我们在学习和使用的过程才能对技术细节把控,以下主要讲解生命周期相关细节。

生命周期

先看图:

2-5.a59bef97.jpg

Flutter的生命周期主要包含以下内容:

1. initState():此方法仅在小部件插入到树中时调用一次,可以在此处执行小部件初始化操作。

initState:当 widget 第一次插入到 widget 树时会被调用,对于每一个State对象,Flutter 框架只会调用一次该回调,所以,通常在该回调中做一些一次性的操作,如状态初始化、订阅子树的事件通知等。不能在该回调中调用BuildContext.dependOnInheritedWidgetOfExactType(该方法用于在 widget 树上获取离当前 widget 最近的一个父级InheritedWidget,关于InheritedWidget我们将在后面章节介绍),原因是在初始化完成后, widget 树中的InheritFrom widget也可能会发生变化,所以正确的做法应该在在build()方法或didChangeDependencies()中调用它

2. didChangeDependencies():此方法仅在小部件首次构建时被调用。如果该小部件依赖于父级小部件,则当父级更改时,将再次调用此方法。

当State对象的依赖发生变化时会被调用;例如:在之前build() 中包含了一个InheritedWidget ,然后在之后的build() 中Inherited widget发生了变化,那么此时InheritedWidget的子 widget 的didChangeDependencies()回调都会被调用。典型的场景是当系统语言 Locale 或应用主题改变时,Flutter 框架会通知 widget 调用此回调。需要注意,组件第一次被创建后挂载的时候(包括重创建)对应的didChangeDependencies也会被调用

3. build():此方法是构建小部件本身的地方,并且是必需的。每当需要更改UI时,系统会自动调用此方法。

build():此回调读它主要是用于构建 widget 子树的,会在如下场景被调用:

    1. 在调用initState()之后。
    1. 在调用didUpdateWidget()之后。
    1. 在调用setState()之后。
    1. 在调用didChangeDependencies()之后。
    1. 在State对象从树中一个位置移除后(会调用deactivate)又重新插入到树的其他位置之后。

4. didUpdateWidget():如果同一个小部件多次插入到树中,则会调用此方法。您可以在此方法中检查前后小部件属性的变化,并相应地更新小部件状态。

在 widget 重新构建时,Flutter 框架会调用widget.canUpdate来检测 widget 树中同一位置的新旧节点,然后决定是否需要更新,如果widget.canUpdate返回true则会调用此回调。正如之前所述,widget.canUpdate会在新旧 widget 的 key 和 runtimeType 同时相等时会返回true,也就是说在在新旧 widget 的key和runtimeType同时相等时didUpdateWidget()就会被调用。

5. setState():如果您需要更改小部件状态并使其重新生成UI,则可以调用此方法。它会通知框架重建小部件的UI。

setState 在使用过程中可能会引起有些不必要的渲染可以使用

ValueListenableBuilder 做优化

6. deactivate():当小部件从树中删除时,将调用此方法。此时,小部件被视为“停用”。

当 State 对象从树中被移除时,会调用此回调。在一些场景下,Flutter 框架会将 State 对象重新插到树中,如包含此 State 对象的子树在树的一个位置移动到另一个位置时(可以通过GlobalKey 来实现)。如果移除后没有重新插入到树中则紧接着会调用dispose()方法。

7. dispose():该方法仅在小部件永久从树中删除时调用,可以在此处执行资源清理或释放操作。

当 State 对象从树中被永久移除时调用;通常在此回调中释放资源。

其他 reassemble()

reassemble():此回调是专门为了开发调试而提供的,在热重载(hot reload)时会被调用,此回调在Release模式下永远不会被调用。

以上主要为整个生命周期的过程方法。

布局过程

布局过程4步骤

  1. 父节点向子节点传递约束(constraints)信息,限制子节点的最大和最小宽高。
  2. 子节点根据约束信息确定自己的大小(size)。
  3. 父节点根据特定布局规则(不同布局组件会有不同的布局算法)确定每一个子节点在父节点布局空间中的位置,用偏移 offset 表示。
  4. 递归整个过程,确定出每一个节点的大小和位置。

可以看到,组件的大小是由自身决定的,而组件的位置是由父组件决定的。

Flutter 中的布局类组件很多,根据孩子数量可以分为单子组件和多子组件,下面我们先通过分别自定义一个单子组件和多子组件来直观理解一下Flutter的布局过程,之后会介绍一下布局更新过程和 Flutter 中的 Constraints(约束)。

flutter 布局更新

对于更新边界是一个非常重要的知识点,对于我们实现高性能刷新绘制有很大的作用。

1.布局边界

理论上,某个组件的布局变化后,就可能会影响其他组件的布局,所以当有组件布局发生变化后,最笨的办法是对整棵组件树 relayout(重新布局)!但是对所有组件进行 relayout 的成本还是太大,所以我们需要探索一下降低 relayout 成本的方案。实际上,在一些特定场景下,组件发生变化后我们只需要对部分组件进行重新布局即可(而无需对整棵树 relayout )。

Rondorview.png

上图 假如 Text3 的文本长度发生变化,则会导致 Text4 的位置和 Column2 的大小也会变化;又因为 Column2 的父组件 SizedBox 已经限定了大小,所以 SizedBox 的大小和位置都不会变化。所以最终我们需要进行 relayout 的组件是:Text3、Column2,这里需要注意:

  1. Text4 是不需要重新布局的,因为 Text4 的大小没有发生变化,只是位置发生变化,而它的位置是在父组件 Column2 布局时确定的。
  2. 很容易发现:假如 Text3 和 Column2 之间还有其他组件,则这些组件也都是需要 relayout 的。

在本例中,Column2 就是 Text3 的 relayoutBoundary (重新布局的边界节点)。每个组件的 renderObject 中都有一个 _relayoutBoundary 属性指向自身的布局边界节点,如果当前节点布局发生变化后,自身到其布局边界节点路径上的所有的节点都需要 relayout。

那么,一个组件是否是 relayoutBoundary 的条件是什么呢?这里有一个原则和四个场景,原则是“组件自身的大小变化不会影响父组件”,如果一个组件满足以下四种情况之一,则它便是 relayoutBoundary :

1. 当前组件父组件的大小不依赖当前组件大小时;

这种情况下父组件在布局时会调用子组件布局函数时并会给子组件传递一个 parentUsesSize 参数,该参数为 false 时表示父组件的布局算法不会依赖子组件的大小。

2. 组件的大小只取决于父组件传递的约束,而不会依赖后代组件的大小。

这样的话后代组件的大小变化就不会影响自身的大小了,这种情况组件的 sizedByParent 属性必须为 true。

3. 父组件传递给自身的约束是一个严格约束(固定宽高);

这种情况下即使自身的大小依赖后代元素,但也不会影响父组件。

4. 组件为根组件;

Flutter 应用的根组件是 RenderView,它的默认大小是当前设备屏幕大小

if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
  _relayoutBoundary = this;

} else {

  _relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;

}

2. markNeedsLayout

当组件布局发生变化时,它需要调用 markNeedsLayout 方法来更新布局,它的功能主要有两个:

  1. 将自身到其 relayoutBoundary 路径上的所有节点标记为 “需要布局” 。

  2. 请求新的 frame;在新的 frame 中会对标记为“需要布局”的节点重新布局。

void markNeedsLayout() {

   _needsLayout = true;

  if (_relayoutBoundary != this) { // 如果不是布局边界节点

    markParentNeedsLayout(); // 递归调用前节点到其布局边界节点路径上所有节点的方法 markNeedsLayout

  } else {// 如果是布局边界节点 

    if (owner != null) {

      // 将布局边界节点加入到 pipelineOwner._nodesNeedingLayout 列表中

      owner!._nodesNeedingLayout.add(this); 

      owner!.requestVisualUpdate();//该函数最终会请求新的 frame

    }

  }

}

3. flushLayout()

markNeedsLayout 执行完毕后,就会将其 relayoutBoundary 节点添加到 pipelineOwner._nodesNeedingLayout 列表中,然后请求新的 frame,新的 frame 到来时就会执行 drawFrame 方法(

flushLayout() 中会对之前添加到 _nodesNeedingLayout 中的节点重新布局


void flushLayout() {

  while (_nodesNeedingLayout.isNotEmpty) {

    final List<RenderObject> dirtyNodes = _nodesNeedingLayout;

    _nodesNeedingLayout = <RenderObject>[]

    //按照节点在树中的深度从小到大排序后再重新layout

    for (final RenderObject node in dirtyNodes..sort((a,b) => a.depth - b.depth)) {

      if (node._needsLayout && node.owner == this)

        node._layoutWithoutResize(); //重新布局

    }

  }

}

为什么 flushLayout() 中刷新布局时要先对dirtyNodes 根据在树中的深度按照从小到大排序?从大到小不行吗?

  • 因为节点更新是从父节点到子节点以此跟新,如果颠倒了顺序会带来不必要的更新操作,大大影响了性能。

4. Layout流程

如果组件有子组件,则在 performLayout 中需要调用子组件的 layout 方法先对子组件进行布局, layout 的核心流程如下


void layout(Constraints constraints, { bool parentUsesSize = false }) {

  RenderObject? relayoutBoundary;

  // 先确定当前组件的布局边界

  if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {

    relayoutBoundary = this;

  } else {

    relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;

  }

  // _needsLayout 表示当前组件是否被标记为需要布局

  // _constraints 是上次布局时父组件传递给当前组件的约束

  // _relayoutBoundary 为上次布局时当前组件的布局边界

  // 所以,当当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,

  // 且布局边界也没有发生变化时则不需要重新布局,直接返回即可。

  if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {

    return;

  }

  // 如果需要布局,缓存约束和布局边界

  _constraints = constraints;

  _relayoutBoundary = relayoutBoundary;

  


  // 后面解释

  if (sizedByParent) {

    performResize();

  }

  // 执行布局

  performLayout();

  // 布局结束后将 _needsLayout 置为 false

  _needsLayout = false;

  // 将当前组件标记为需要重绘(因为布局发生变化后,需要重新绘制)

  markNeedsPaint();

}

简单来讲布局过程分以下几步:

  1. 确定当前组件的布局边界。
  2. 判断是否需要重新布局,如果没必要会直接返回,反之才需要重新布局。不需要布局时需要同时满足三个条件:
  • 当前组件没有被标记为需要重新布局。
  • 父组件传递的约束没有发生变化。
  • 当前组件的布局边界也没有发生变化时。
  1. 调用 performLayout() 进行布局,因为 performLayout() 中又会调用子组件的 layout 方法,所以这时一个递归的过程,递归结束后整个组件树的布局也就完成了。

  2. 请求重绘。

sizedByParent

if (sizedByParent) {

  performResize(); //重新确定组件大小

}

sizedByParent 为 true 时表示:当前组件的大小只取决于父组件传递的约束,而不会依赖后代组件的大小。前面我们说过,performLayout 中确定当前组件的大小时通常会依赖子组件的大小,如果 sizedByParent 为 true,则当前组件的大小就不依赖子组件大小了,为了逻辑清晰,Flutter 框架中约定,当sizedByParent 为 true 时,确定当前组件大小的逻辑应抽离到 performResize() 中,这种情况下 performLayout 主要的任务便只有两个:对子组件进行布局和确定子组件在当前组件中的布局起始位置偏移

通过一个 AccurateSizedBox 示例来演示一下 sizedByParent 为 true 时我们应该如何布局:

AccurateSizedBox

Flutter 中的 SizedBox 组件会将其父组件的约束传递给其子组件,这也就意味着,如果父组件限制了最小宽度为100,即使我们通过 SizedBox 指定宽度为50,那也是没用的,因为 SizedBox的实现中会让 SizedBox 的子组件先满足 SizedBox 父组件的约束。还记得之前我们想在 AppBar 中限制 loading 组件大小的例子吗:


AppBar(

    title: Text(title),

    actions: <Widget>[

      SizedBox( // 尝试使用SizedBox定制loading 宽高

        width: 20, 

        height: 20,

        child: CircularProgressIndicator(

          strokeWidth: 3,

          valueColor: AlwaysStoppedAnimation(Colors.white70),

        ),

      )

    ],

 )

尺寸限制类容器.png

之所以不生效,是因为父组件限制了最小高度,当然我们也可以使用 UnconstrainedBox + SizedBox 来实现我们想要的效果,但是这里我们希望通过一个组件就能搞定,为此我们自定义一个 AccurateSizedBox 组件,它和 SizedBox 的主要区别是 AccurateSizedBox 自身会遵守其父组件传递的约束而不是让其子组件去满足AccurateSizedBox 父组件的约束,具体:

  1. AccurateSizedBox 自身大小只取决于父组件的约束和用户指定的宽高。

  2. AccurateSizedBox 确定自身大小后,限制其子组件大小。

根据上边的几个规则找到解决方案:

if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {

  _relayoutBoundary = this;

} else {

  _relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;

}

class AccurateSizedBox extends SingleChildRenderObjectWidget {

  const AccurateSizedBox({

    Key? key,

    this.width = 0,

    this.height = 0,

    required Widget child,

  }) : super(key: key, child: child);

  


  final double width;

  final double height;

  


  @override

  RenderObject createRenderObject(BuildContext context) {

    return RenderAccurateSizedBox(width, height);

  }

  


  @override

  void updateRenderObject(context, RenderAccurateSizedBox renderObject) {

    renderObject

      ..width = width

      ..height = height;

  }

}

  


class RenderAccurateSizedBox extends RenderProxyBoxWithHitTestBehavior {

  RenderAccurateSizedBox(this.width, this.height);

  


  double width;

  double height;

  


  // 当前组件的大小只取决于父组件传递的约束

  @override

  bool get sizedByParent => true;

  


  


  // performResize 中会调用

  @override

  Size computeDryLayout(BoxConstraints constraints) {

    //设置当前元素宽高,遵守父组件的约束

    return constraints.constrain(Size(width, height));

  }

  


  // @override

  // void performResize() {

  //   // default behavior for subclasses that have sizedByParent = true

  //   size = computeDryLayout(constraints);

  //   assert(size.isFinite);

  // }

  


  @override

  void performLayout() {

    child!.layout(

      BoxConstraints.tight(

          Size(min(size.width, width), min(size.height, height))),

      // 父容器是固定大小,子元素大小改变时不影响父元素

      // parentUseSize为false时,子组件的布局边界会是它自身,子组件布局发生变化后不会影响当前组件

      parentUsesSize: false,

    );

  }

}

  

上面代码有三点需要注意:

  1. 我们的 RenderAccurateSizedBox 不再直接继承自 RenderBox,而是继承自 RenderProxyBoxWithHitTestBehavior,RenderProxyBoxWithHitTestBehavior 是间接继承自 RenderBox的,它里面包含了默认的命中测试和绘制相关逻辑,继承自它后就不用我们再手动实现了。

  2. 我们将确定当前组件大小的逻辑挪到了computeDryLayout 方法中,因为RenderBox 的 performResize 方法会调用 computeDryLayout ,并将返回结果作为当前组件的大小。按照Flutter 框架约定,我们应该重写computeDryLayout 方法而不是 performResize 方法,就像在布局时我们应该重写 performLayout 方法而不是 layout 方法;不过,这只是一个约定,并非强制,但我们应该尽可能遵守这个约定,除非你清楚的知道自己在干什么并且能确保之后维护你代码的人也清楚。

  3. RenderAccurateSizedBox 在调用子组件 layout 时,将 parentUsesSize 置为 false,这样的话子组件就会变成一个布局边界。

测试结果


class AccurateSizedBoxRoute extends StatelessWidget {

  const AccurateSizedBoxRoute({Key? key}) : super(key: key);

  


  @override

  Widget build(BuildContext context) {

    final child = GestureDetector(

      onTap: () => print("tap"),

      child: Container(width: 300, height: 300, color: Colors.red),

    );

    return Row(

      children: [

        ConstrainedBox(

          constraints: BoxConstraints.tight(Size(100, 100)),

          child: SizedBox(

            width: 50,

            height: 50,

            child: child,

          ),

        ),

        Padding(

          padding: const EdgeInsets.only(left: 8),

          child: ConstrainedBox(

            constraints: BoxConstraints.tight(Size(100, 100)),

            child: AccurateSizedBox(

              width: 50,

              height: 50,

              child: child,

            ),

          ),

        ),

      ],

    );

  }

}

  

306.png

可以发现,当父组件约束子组件大小宽高是100时,我们通过 SizedBox 指定 Container 大小是为 50×50 是不能成功的, 而通过 AccurateSized 时成功了。

这里需要提醒一下读者,如果一个组件的的 sizedByParent 为 true,那它在布局子组件时也是能将 parentUsesSize 置为 true 的,sizedByParent 为 true 表示自己是布局边界,而将 parentUsesSize 置为 true 或 false 决定的是子组件是否是布局边界,两者并不矛盾,这个不要混淆了。顺便提一点 Flutter 自带的 OverflowBox 组件的实现中,它的 sizedByParent 为 true,在调用子组件layout 方法时,parentUsesSize 传的是 true,具体通过OverflowBox 的实现源码可以找到原因。

AfterLayout

AfterLayout 可以在布局结束后拿到子组件的代理渲染对象 (RenderAfterLayout), RenderAfterLayout 对象会代理子组件渲染对象 ,因此,通过RenderAfterLayout 对象也就可以获取到子组件渲染对象上的属性,比如件大小、位置等。

代码实现


class AfterLayout extends SingleChildRenderObjectWidget {

  AfterLayout({

    Key? key,

    required this.callback,

    Widget? child,

  }) : super(key: key, child: child);

  


  @override

  RenderObject createRenderObject(BuildContext context) {

    return RenderAfterLayout(callback);

  }

  


  @override

  void updateRenderObject(

      BuildContext context, RenderAfterLayout renderObject) {

    renderObject..callback = callback;

  }

  ///组件树布局结束后会被触发,注意,并不是当前组件布局结束后触发

  final ValueSetter<RenderAfterLayout> callback;

}

  


class RenderAfterLayout extends RenderProxyBox {

  RenderAfterLayout(this.callback);

  


  ValueSetter<RenderAfterLayout> callback;

  


  @override

  void performLayout() {

    super.performLayout();

    // 不能直接回调callback,原因是当前组件布局完成后可能还有其他组件未完成布局

    // 如果callback中又触发了UI更新(比如调用了 setState)则会报错。因此,我们

    // 在 frame 结束的时候再去触发回调。

    SchedulerBinding.instance

        .addPostFrameCallback((timeStamp) => callback(this));

  }

  


  /// 组件在屏幕坐标中的起始点坐标(偏移)

  Offset get offset => localToGlobal(Offset.zero);

  /// 组件在屏幕上占有的矩形空间区域

  Rect get rect => offset & size;

}

上面代码有三点需要注意:

  1. callback 调用时机不是在子组件完成布局后就立即调用,原因是子组件布局完成后可能还有其他组件未完成布局,如果此时调用callback,一旦 callback 中存在触发更新的代码(比如调用了 setState)则会报错。因此我们在 frame 结束的时候再去触发回调。

  2. RenderAfterLayout 的 performLayout方法中直接调用了父类 RenderProxyBox 的 performLayout方法:

void performLayout() {

  if (child != null) {

    child!.layout(constraints, parentUsesSize: true);

    size = child!.size;

  } else {

    size = computeSizeForNoChild(constraints);

  }

}

可以看到是直接将父组件传给自身的约束传递给子组件,并将子组件的大小设置为自身大小。也就是说 RenderAfterLayout 的大小和其子组件大小是相同的

我们定义了 offset 和 rect 两个属性,它们是组件相对于屏幕的的位置偏移和占用的矩形空间范围。但是实战中,我们经常需要获取的是子组件相对于某个父级组件的坐标和矩形空间范围,这时候我们可以调用 RenderObject 的localToGlobal 方法,比如下面的的代码展示了Stack中某个子组件获取相对于Stack 的矩形空间范围:


Widget build(context){

  return Stack(

    alignment: AlignmentDirectional.topCenter,

    children: [

      AfterLayout(

        callback: (renderAfterLayout){

         //我们需要获取的是AfterLayout子组件相对于Stack的Rect

         _rect = renderAfterLayout.localToGlobal(

            Offset.zero,

            //找到 Stack 对应的 RenderObject 对象

            ancestor: context.findRenderObject(),

          ) & renderAfterLayout.size;

        },

        child: Text('Flutter@wendux'),

      ),

    ]

  );

}

  

Constraints

Constraints(约束)主要描述了最小和最大宽高的限制,理解组件在布局过程中如何根据约束确定自身或子节点的大小对我们理解组件的布局行为有很大帮助,现在我们就通过一个实现 200*200 的红色 Container 的例子来说明。为了排除干扰,我们让根节点(RenderView)作为 Container 的父组件,我们的代码是:

Container(width: 200, height: 200, color: Colors.red)

但实际运行之后,你会发现整个屏幕都变成了红色!为什么呢?我们看看 RenderView 的布局实现:

@override

void performLayout() {

  //configuration.size 为当前设备屏幕

  _size = configuration.size; 

  if (child != null)

    child!.layout(BoxConstraints.tight(_size)); //强制子组件和屏幕一样大

}

这里需要介绍一下两种常用的约束:

  1. 宽松约束:不限制最小宽高(为0),只限制最大宽高,可以通过 BoxConstraints.loose(Size size) 来快速创建。
  2. 严格约束:限制为固定大小;即最小宽度等于最大宽度,最小高度等于最大高度,可以通过 BoxConstraints.tight(Size size) 来快速创建。

可以发现,RenderView 中给子组件传递的是一个严格约束,即强制子组件大小等于屏幕大小,所以 Container 便撑满了屏幕。那我们怎么才能让指定的大小生效呢?标准答案就是引入一个中间组件,让这个中间组件遵守父组件的约束,然后对子组件传递新的约束。对于这个例子来讲,最简单的方式是用一个 Align 组件来包裹 Container:


@override

Widget build(BuildContext context) {

  var container = Container(width: 200, height: 200, color: Colors.red);

  return Align(

    child: container,

    alignment: Alignment.topLeft,

  );

}

Align 会遵守 RenderView 的约束,让自身撑满屏幕,然后会给子组件传递一个宽松约束(最小宽高为0,最大宽高为200),这样 Container 就可以变成 200 * 200 了。

当然我们还可以使用其他组件来代替 Align,比如 UnconstrainedBox,但原理是相同的.

总结:

 flutter 的布局流程

saz's.png

在进行布局的时候,Flutter 会以 DFS(深度优先遍历)方式遍历渲染树,并 将限制以自上而下的方式 从父节点传递给子节点。子节点若要确定自己的大小,则 必须 遵循父节点传递的限制。子节点的响应方式是在父节点建立的约束内 将大小以自下而上的方式 传递给父节点。