谷歌DevFest 2021 广州国际嘉年华-带你了解不一样的 Flutter

5,284 阅读13分钟

hello 大家好,我是《Flutter开发实战详解》的作者郭树煜,看标题就知道今天我要给大家分享的是 Flutter 相关的主题,分享内容是也比较直接简单,就是关于 Flutter 布局相关的知识点

相信大家可能都听说过或者用过 Flutter ,对这部分内容可能有一定了解,但是正如标题所示,本次的主题是带你了解不一样的 Flutter ,或者说经常性被萌新忽略的东西 ,所以这次将通过不一样的角度,带你看看 Flutter 的尺寸布局有趣的地方。

一、开始之前

在聊 Flutter 的布局之前,首先大家觉得 Flutter 是什么?

Flutter 其实主要是跨平台的 UI 框架,它核心能力是解决 UI 的跨平台,和别的跨平台框架不一样的地方在于:它在性能接近原生的同时,做到了控件和平台无关的实现

但如果大家用过 Flutter ,应该知道 Flutter 里的我们写的界面都是通过 Widget 完成,并且可能会看起来嵌套得很多层,为什么呢?

这里就要先简单说一下 Flutter 的一些基础信息,在 Flutter 里有 WidgetElementRenderObjectLayer 等关键的核心设定

其中我们最常写的 Widget 并不是真正的 View 实例,它需要转化为对应的 RenderObject 才能绘制,而 ElementWidgetRenderObject 关键的中间实例,我们日常 Flutter 开发里用到的 BuildContext 就是 Element 的抽象对象

也就是大致 Widget -> Element -> RenderObject 这样的过程。

所以在 Flutter 里 Widget 代码只是“配置文件”的作用,真正工作的实例是它内部对应的 ElementRenderObject 实体

这也是 Widget 为什么可以是不可变的原因,它可以在使用时的被频繁构建,因为它不是真正干活的,Widget 承载的是 RenderObject 里绘制时需要的各种状态信息

这里举个简单例子,如图代码所示,我们定义了一个 text 的 Widget,然后分别在 4 个地方添加,并成功运行,如果是一个真正的 View ,是不可以同时在 4 个地方被加载。

通过这个例子可以看到 Widget 并不是真正干活的,而主要负责绘制和布局的逻辑都在 RenderObject因为布局和绘制的主要逻辑都在 RenderObject ,所以今天我们主要的内容也是在 RenderObject

在 Flutter 里 RenderObject 作为绘制和布局的实体,主要可以分为两大子类:RenderBoxRenderSliver ,其中 RenderSliver 主要是在可滑动列表这种场景中使用,所以本次我们主要讨论的是 RenderBox 这种布局场景。

二、Flutter 的布局

一般情况 Flutter 里的大小布局是从上往下传递 Constraints ,从下往上返回 Size 这样的流程

简单理解这句话就是:父容器根据布局需要往下传递一个约束信息,而最子容器会根据自己的状态返回一个明确的大小,如果自己没有就继续往下的 child 递归。

更粗旷一些说就是:从上往下传递约束,传入的约束一般是有 minHeightmaxHeightminWidthmaxWidth 等等,但是从下往上返回的 size 时,就会是一个固定 widthheight 尺寸。

而对于 Flutter ,布局的逻辑主要在对应 RenderObjectperformLayout

所以一般如果对于 Widget 的布局感兴趣或者有疑惑,就可以先找到这个 WidgetRednerObject ,看这个 RednerObjectperformLayout 逻辑是怎么实现。

在 Flutter 最常用的就是应是 Container 了, Container 作为 Flutter 里最常用的抽象配置模版,它在宽高布局这一块用的是 ConstrainedBox,而不管是 ConstrainedBox 还是 SizedBox, 他们对应的 RenderObject 都是 RenderConstrainedBox

所以我们就以 RenderConstrainedBox 相关的例子来举例,看看 ConstrainedBox 是如何大小布局。

2.1、ConstrainedBox 的约束布局

如下代码所示,可以看到 ColoredBox 没有指定大小,但是运行后 ColoredBox 得到的是一个 100 x 100 的红色正方形, 因为它的父级 ConstrainedBox 往下传递的是 100 x 100 大小的 ConstrainedBox 约束。

Scaffold(
  body: Center(
    child: ConstrainedBox(
      constraints: BoxConstraints(
          maxHeight: 100, minHeight: 100, maxWidth: 100, minWidth: 100),
      child: ColoredBox(
        color: Colors.red,
      ),
    ),
  ),
)

那如果这时候,把 min 的宽高改为 10 会发生什么事?

可以看到此时 ColoredBox 的大小变成和 min 的宽高一样大,为什么呢?

Scaffold(
  body: Center(
    child: ConstrainedBox(
      constraints: BoxConstraints(
          maxHeight: 100, minHeight: 10, maxWidth: 100, minWidth: 10),
      child: ColoredBox(
        color: Colors.red,
      ),
    ),
  ),
)

首先 ColoredBox 并没有实现自己的 performLayout,而是通过继承了 RenderProxyBox 默认的逻辑来实现,这种情况在 Flutter 里比较常见,可以看到默认 RenderProxyBox 下:

  • 在没有 child 的时候,用的是 constraints.smallest ,也就是传递下来约束的最小值宽高;
  • 在有 child 的时候使用 child 的大小;

所以我们知道了,当控件没有实现自定义的 performLayout 时,并且没有 child 时,它很可能就是跟着父级约束的 smallest 走。

继续测试,如果这时候给 ColoredBox 增加一个 80 的 child ,可以看到红色框变了,变成了 ColoredBox 的 child 的大小 80 而不是 smallest,因为这时候 ColoredBox 有了 child, 用的是 child 的大小。

Scaffold(
  body: Center(
    child: ConstrainedBox(
      constraints: BoxConstraints(
          maxHeight: 100, minHeight: 10, maxWidth: 100, minWidth: 10),
      child: ColoredBox(
        color: Colors.red,
        child: SizedBox(
          width: 80,
          height: 80,
        ),
      ),
    ),
  ),
)

那如果我把 ColoredBox 的 child 修改为 150 的大小呢?

可以看到运行后红色方块还是 100 的大小,并没有变成 150。

 Scaffold(
  body: Center(
    child: ConstrainedBox(
      constraints: BoxConstraints(
          maxHeight: 100, minHeight: 10, maxWidth: 100, minWidth: 10),
      child: ColoredBox(
        color: Colors.red,
        child: SizedBox(
          width: 150,
          height: 150,
        ),
      ),
    ),
  ),
)

这是为什么呢?

我们通过 Flutter 的调试工具看,可以看到我们虽然给 SizedBox 配置了 150 的参数,但是实际 RenderConstrainedBox 最终渲染时输出是 100 。

这里有两点:

  • 第一就是 Widget 仅仅是作为配置信息,我们配置的宽高是 150 ,而实际 RenderObject 输出的是 100 ,所以我们写的并不是真实的 View, 真正的布局效果还是要看 RenderObject 的脸色;

  • SizedBoxRenderConstrainedBox 看, 它的 performLayout 的实现在没有 child时, 150 的大小会被 enforce 成 parent 的 100

对应 enforce 内部是通过 clamp 这个 API 完成, enforce 执行效果等同于 150.clamp(10, 100),所以会得到 100 的结果。

clamp 便是如果数据时在区间内就返回该数值,否则返回离其最近的边界值。

所以通过 enforce RenderConstrainedBox 不会超出父容器的大小。

那么为了实验,我们接下来把 SizeBox 换成 ConstrainedBox ,并且调整为约束为 10 - 150 的大小。

Scaffold(
  body: Center(
    child: ConstrainedBox(
      constraints: BoxConstraints(
          maxHeight: 100, minHeight: 10, maxWidth: 100, minWidth: 10),
      child: ColoredBox(
        color: Colors.red,
        child: ConstrainedBox(
          constraints: BoxConstraints(
              maxHeight: 150, minHeight: 10, maxWidth: 150, minWidth: 10),
        ),
      ),
    ),
  ),
)

可以看到红色正方形又变成了 10 的大小,为什么呢?

通过源码可以看到:

  • 首先 enforce 执行是 150.clamp(10, 100)10.clamp(10, 100) ,等到的自然就是 10-100
  • 之后再到 constrain 里 0.clamp(10, 100),所以输出的是 10 这个最小值;

先前是 100.clamp(10, 100) 自然就是 100 的大小,而现在是 0.clamp(10, 100) ,自然就成了 10 。

从上面的例子,可以看到父布局约束影响 child 的大小的过程,甚至是变相局限住了 child 的大小返回,但是这都是在 child.layout 之后取得的大小。

那如果想要在 child.layout 之前就获取到 child 的大小呢?也就是 child 布局之前就获取到 child 的大小?

可以这样吗?当然可以!一般在官方的 RenderBox 都会有这四个方法:

  • computeMaxIntrinsicWidth
  • computeMinIntrinsicWidth
  • computeMaxIntrinsicHeight
  • computeMinIntrinsicHeight

为什么说一般呢?

因为你不写一般也不报错,并且这四个方法其实一般很少被调用,官方对它的描述是开销昂贵,并且我们调用时也不是直接调用它,而是通过对应的 get 方法:

  • getMaxIntrinsicWidth
  • getMinIntrinsicWidth
  • getMaxIntrinsicHeight
  • getMinIntrinsicHeight

在默认规范里,一般你只能 override compute 开头的 API 去实现需要的逻辑,然后调用只能通过 get 对应的方法去调用,最后会执行到 compute 开头的 API ,它们之间时一一对应的。

也就是通过 getMinIntrinsicWidth 来调用,比如:child.getMinIntrinsicWidth 最终调用到 computeMinIntrinsicWidth

看到这里大家有没想过: RenderBox 如何拿到 child ?child 如何从 Widget 变成 RenderObject?

这里就是 Element 起到的作用,当 Widget 被加载时:

  • 就会调用 inflateWidget 去创建它的 Element,然后通过 mountcreateRenderObject 创建出它的 RenderObject
  • 之后再执行 attachRenderObject , 这时候这个 child 会通过 _findAncestorRenderObjectElement 去找到它的 parent ,也就是离他最近的一个 RenderObjectElment
  • 最后执行 parent 的 insertRenderObjectChild ,这时 child 就被插入进去 RenderObject,在 RenderObject 里就可以获取到 Widget

也就是 child 在 Element 里被加载后,创建出对应的 RenderObject ,并且找到自己的 parent 然后将自己加入进去。

Flutter 既然有具备 RenderObjectElement ,那同样也就有没有 RenderObjectElement ,比如 ComponentElement ,也就是我们常用的 StatelessWidget 等。

这里可以看到 Element 得连接作用

三、多个 Child 的布局

前面介绍了单个 Child 的布局,这里简单介绍下多个 Child 主要有什么不同。

其实多个 Child 和单个一样,都会是从上往下传递 Constraints ,从下往上返回 Size 这样的流程。

比如下图,这是我们前面看到的例子,这里使用了 Column 控件对多个 Text 进行布局。

而其实 ColumnRow 都是 Flex 的子类,我们按照思路去看 RenderFlex 的实现,就可以看到,对于多个 Child 的布局主要有这么几个关键点:

  • MultiChildRenderObjectWidget
  • MultiChildRenderObjectElement
  • ParentData

WidgetElement 的逻辑我们这里暂时不深入展开,主要讲解不同的就是在 RenderBoxParentData

如上图所示,基本上所有 Multi Child 的实现都有自己特有的 ParentData ,并且他们还不是直接继承 ParentData, 而是继承他们的子类 ContainterBoxParentData

如图所示,他们的作用就是:

  • BoxParentData 具备 Offset 参数,是用来觉得 Child 在控件的位置;
  • ContainterBoxParentData 带有两个 Sibling 参数,主要是 RenderBox 里访问 children 就是通过这个双链表的方式访问的;
  • FlexParentData 就是当前 RenderFlex 布局所需的参数;

可以看到这就是 RenderFlex 布局时关键的参数所在,我们添加的 children Widget,在经过 Element 加载后,在前面说过的 insert 步骤会从一个 List<Widget> 变成通过 ParentData 的两个 Sibling 参数连接在一起的双向链表,访问时就是通过它进行访问的。

所以在 children 布局时,我们通过对应的 ParentData 子类返回 child,然后通过给 ParentData 配置 Offset 来决定 child 的位置

官方提供了更方便的自定义布局 CustomMultiChildLayout ,不需要你一步一步实现,比如常用的默认页面脚手架 Scaffold 就是用它实现。

四、有趣的知识点

既然聊到这个,我们在深入聊聊一些有趣的知识点,比如前面代码里的一直出现的 Scaffold ,这个是我们 Flutter 开发里最常用到的页面脚手架,也是一个页面布局的开始。

如果这时候把 Scaffold 给去掉,运行最初的代码,可以看到整个屏幕都红了,也即是 ConstrainedBox 铺满了整个屏幕。

MaterialApp(
  title: 'GSY Flutter Demo',
  theme: ThemeData(
    primarySwatch: Colors.blue,
  ),
  home: ConstrainedBox(
    constraints: BoxConstraints(
        maxHeight: 100, minHeight: 10, maxWidth: 100, minWidth: 10),
    child: ColoredBox(
      color: Colors.red,
    ),
  ),
);

为什么呢?

我们通过 Flutter 的调试工具可以看到,此时上级给你的约束就是屏幕大小,没有区间,而 enforce 等于 10.clamp(392.72, 392.72)

看到了没有,你没得选,clamp(392.72, 392.72) 也就是强行都变成了屏幕的宽度。

那如果这时候,我们加了一个 Center 控件呢?

可以看到约束大小又有了!

MaterialApp(
  title: 'GSY Flutter Demo',
  theme: ThemeData(
    primarySwatch: Colors.blue,
  ),
  home: Center(
	  child:ConstrainedBox(
	    constraints: BoxConstraints(
	        maxHeight: 100, minHeight: 10, maxWidth: 100, minWidth: 10),
	    child: ColoredBox(
	      color: Colors.red,
	    ),
	  )
  ),
);

可以看到约束变成了 0-392.72 的约束,也就是 10.clamp(0, 392.72)

为什么呢?

因为 CenterRenderObjectRenderPositionedBox它在布局的时候会有一个 constraints.loosen() 的操作,这也是为什么你有时候加多一个 Center 布局就突然生效的原因,因为 loosen 就成了 0-392.72 的约束。

BoxConstraints loosen() {
  assert(debugAssertIsValid());
  return BoxConstraints(
    minWidth: 0.0,
    maxWidth: maxWidth,
    minHeight: 0.0,
    maxHeight: maxHeight,
  );
}

如果不加 Center,像之前用的 Scaffold 为什么也能让 BoxConstraints 生效呢?

因为会出现虽然位置不对,所以这里调成了 100 比较好看到。

Scaffold(
  body: ConstrainedBox(
    constraints: BoxConstraints(
        maxHeight: 100, minHeight: 100, maxWidth: 100, minWidth: 100),
    child: ColoredBox(
      color: Colors.red,
    ),
  ),
)

这其实是因为 Scaffold 的实现是一个叫 CustomMultiChildLayout 的控件。

Scaffold 内的 CustomMultiChildLayout 布局时,对 body 使用了一个叫 _BodyBoxConstraintsConstraints 子类,这个类默认下所有 min 都是 0

所以对于 body 下的 child 而言,都会有 0 的 min 约束信息存在。

所以 10.clamp(0, 392.72) 可以生效。

那可能还会有人就疑惑, child 返回的 size 是在哪里使用?

答案肯定是在 paint 的时候了使用,那这个 Offset 又是什么?

举个例子,我们看之前用过的 Center 里面,它会在 paintChild 的时候,会添加 Offset 信息,所以 child 就会在绘制的时候有偏移,从而绘制到准确的地方。

所以最终如下图所示,ColoredBox 在绘制 Rect 时,通过 Offset (决定位置) 和 Size(决定大小),而至绘制出对应位置的红色方框

那如果我画的时候不遵循这个 Offset 呢?

这里我们可以通过一个简单的例子,直接用 CustomPaint 画一个 Demo。

new Container(
  height: 200,
  width: 200,
  color: Colors.greenAccent,
  child: CustomPaint(
    ///直接使用值做动画
    foregroundPainter: _AnimationPainter(animation1),
  ),
)

可以看到,虽然 CustomPaint 是在 200 x 200 的大小下,但是动画绘制的圆可以很直接的超出这个大小。

所以可以看到 Flutter 本质是一块画板,通过各种 Layer 分层,在每个 Layer 上又根据约定好的 SizeOffset 绘制控件

Layer 就是一群 RenderObject 的集合。

其实只要你拿到这个 Layer 上的 Canvas ,就可以会知道这个 Layer 上的任意位置,当然一般情况下为了正确布局绘制,还是要遵循这个规则的。

常见的每个 Route 就是一个独立的 Layer

总结

最后做个总结:

  • Widget 只是配置文件,它不可变,每次改变都会重构,它并不是真正的 View
  • 布局逻辑主要在 RenderBox 子类的 performLayout,并且可以提前获取 child.size
  • Element 的连接作用,Widget 被首次加载会创建 ElementRenderObject ,并连接到一起;
  • child 布局里是通过 ContainerBoxParentData 来访问多个 child;
  • 约束布局时 smallest 和有没有 0 值(区间最小值)会影响约束的效果;
  • 控件绘制时遵循对应的 SizeOffset ,也可以超出 Size 绘制,具体看所在 LayerCanvas