Flutter框架分析(六)-- 布局

5,704 阅读15分钟

Flutter框架分析分析系列文章:

《Flutter框架分析(一)-- 总览和Window》

《Flutter框架分析(二)-- 初始化》

《Flutter框架分析(三)-- Widget,Element和RenderObject》

《Flutter框架分析(四)-- Flutter框架的运行》

《Flutter框架分析(五)-- 动画》

《Flutter框架分析(六)-- 布局》

《Flutter框架分析(七)-- 绘制》

前言

之前的文章给大家介绍了Flutter渲染流水线的动画(animate), 构建(build)阶段。本篇文章会结合Flutter源码给大家介绍一下渲染流水线接下来的布局(layout)阶段。

概述

如同Android,iOS,h5等其他框架一样,页面在绘制之前框架需要确定页面内各个元素的位置和大小(尺寸)。对于页面内的某个元素而言,如果其包含子元素,则只需在知道子元素的尺寸之后再由父元素确定子元素在其内部的位置就完成了布局。所以只要确定了子元素的尺寸和位置,布局就完成了。Flutter框架的布局采用的是盒子约束(Box constraints)模型。其布局流程如下图所示:

布局流程
图中的树是render tree。每个节点都是一个RenderObject。从根节点开始,每个父节点启动子节点的布局流程,在启动的时候会传入Constraits,也即“约束”。Flutter使用最多的是盒子约束(Box constraints)。盒子约束包含4个域:最大宽度(maxWidth)最小宽度(minWidth)最大高度(maxHeight)和最小高度(minHeight)。子节点布局完成以后会确定自己的尺寸(size)。size包含两个域:宽度(width)和高度(height)。父节点在子节点布局完成以后需要的时候可以获取子节点的尺寸(size)整体的布局流程可以描述为一下一上,一下就是约束从上往下传递,一上是指尺寸从下往上传递。这样Flutter的布局流程只需要一趟遍历render tree即可完成。具体布局过程是如何运行的,我们通过分析源码来进一步分析一下。

分析

回顾《Flutter框架分析(四)-- Flutter框架的运行》我们知道在vsync信号到来以后渲染流水线启动,在engine回调windowonDrawFrame()函数。这个函数会运行Flutter的“持久帧回调”(PERSISTENT FRAME CALLBACKS)。渲染流水线的构建(build),布局(layout)和绘制(paint)阶段都是在这个回调里,WidgetsBinding.drawFrame()。这个函数是在RendererBinding初始化的时候加入到“Persistent”回调的。

void drawFrame() {
   try {
    if (renderViewElement != null)
      buildOwner.buildScope(renderViewElement);
    super.drawFrame();
    buildOwner.finalizeTree();
  } finally {
     ...
  }
}

代码里的这一行buildOwner.buildScope(renderViewElement)是渲染流水线的构建(build)阶段。这部分我们在《Flutter框架分析(四)-- Flutter框架的运行》做了说明。而接下来的函数super.drawFrame()会走到RendererBinding中。

void drawFrame() {
  pipelineOwner.flushLayout();
  pipelineOwner.flushCompositingBits();
  pipelineOwner.flushPaint();
  renderView.compositeFrame(); // this sends the bits to the GPU
  pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}

里面的第一个调用pipelineOwner.flushLayout()就是本篇文章要讲的布局阶段了。好了,我们就从这里出发吧。先来看看PiplineOwner.flushLayout()

void flushLayout() {
      while (_nodesNeedingLayout.isNotEmpty) {
        final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
        _nodesNeedingLayout = <RenderObject>[];
        for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
          if (node._needsLayout && node.owner == this)
            node._layoutWithoutResize();
        }
      }
  }

这里会遍历dirtyNodes数组。这个数组里放置的是需要重新做布局的RenderObject。遍历之前会对dirtyNodes数组按照其在render tree中的深度做个排序。这里的排序和我们在构建(build)阶段遇到的对element tree的排序一样。排序以后会优先处理上层节点。因为布局的时候会递归处理子节点,这样如果先处理上层节点的话,就避免了后续重复布局下层节点。之后就会调用RenderObject._layoutWithoutResize()来让节点自己做布局了。

void _layoutWithoutResize() {
    try {
      performLayout();
      markNeedsSemanticsUpdate();
    } catch (e, stack) {
      ...
    }
    _needsLayout = false;
    markNeedsPaint();
  }

RenderObject中,函数performLayout()需要其子类自行实现。因为有各种各样的布局,就需要子类个性化的实现自己的布局逻辑。在布局完成以后,会将自身的_needsLayout标志置为false。回头看一下上一个函数,在循环体里,只有_needsLayouttrue的情况下才会调用_layoutWithoutResize()。我们知道在Flutter中布局,渲染都是由RenderObject完成的。大部分页面元素使用的是盒子约束。RenderObject有个子类RenderBox就是处理这种布局方式的。而Flutter中大部分Widget最终是由RenderBox子类实现最终渲染的。源代码中的注释里有一句对RenderBox的定义

A render object in a 2D Cartesian coordinate system.

翻译过来就是一个在二维笛卡尔坐标系中的render object。每个盒子(box)都有个size属性。包含高度和宽度。每个盒子都有自己的坐标系,左上角为坐标为(0,0)。右下角坐标为(width, height)。

abstract class RenderBox extends RenderObject {
    ...
    Size _size;
    ...
}

我们在写Flutter app的时候设定组件大小尺寸的时候都是在创建Widget的时候把尺寸或者类似居中等这样的配置传进去。例如以下这个Widget我们规定了它的大小是100x100;

Container(width: 100, height: 100);

因为布局是在RenderObject里完成的,这里更具体的说应该是RenderBox。那么这个100x100的尺寸是如何传递到RenderBox的呢?RenderBox又是如何做布局的呢? Container是个StatelessWidget。它本身不会对应任何RenderObject。根据构造时传入的参数,Container最终会返回由AlignPaddingConstrainedBox等组合而成的Widget

  Container({
    Key key,
    this.alignment,
    this.padding,
    Color color,
    Decoration decoration,
    this.foregroundDecoration,
    double width,
    double height,
    BoxConstraints constraints,
    this.margin,
    this.transform,
    this.child,
  }) : decoration = decoration ?? (color != null ? BoxDecoration(color: color) : null),
       constraints =
        (width != null || height != null)
          ? constraints?.tighten(width: width, height: height)
            ?? BoxConstraints.tightFor(width: width, height: height)
          : constraints,
       super(key: key);
       
  final BoxConstraints constraints;

  @override
  Widget build(BuildContext context) {
    Widget current = child;

    if (child == null && (constraints == null || !constraints.isTight)) {
      current = LimitedBox(
        maxWidth: 0.0,
        maxHeight: 0.0,
        child: ConstrainedBox(constraints: const BoxConstraints.expand()),
      );
    }

    if (alignment != null)
      current = Align(alignment: alignment, child: current);

    final EdgeInsetsGeometry effectivePadding = _paddingIncludingDecoration;
    if (effectivePadding != null)
      current = Padding(padding: effectivePadding, child: current);

    if (decoration != null)
      current = DecoratedBox(decoration: decoration, child: current);

    if (foregroundDecoration != null) {
      current = DecoratedBox(
        decoration: foregroundDecoration,
        position: DecorationPosition.foreground,
        child: current,
      );
    }

    if (constraints != null)
      current = ConstrainedBox(constraints: constraints, child: current);

    if (margin != null)
      current = Padding(padding: margin, child: current);

    if (transform != null)
      current = Transform(transform: transform, child: current);

    return current;
  }

在本例中返回的是一个ConstrainedBox

class ConstrainedBox extends SingleChildRenderObjectWidget {
  
  ConstrainedBox({
    Key key,
    @required this.constraints,
    Widget child,
  }) : assert(constraints != null),
       assert(constraints.debugAssertIsValid()),
       super(key: key, child: child);

  /// The additional constraints to impose on the child.
  final BoxConstraints constraints;

  @override
  RenderConstrainedBox createRenderObject(BuildContext context) {
    return RenderConstrainedBox(additionalConstraints: constraints);
  }

  @override
  void updateRenderObject(BuildContext context, RenderConstrainedBox renderObject) {
    renderObject.additionalConstraints = constraints;
  }
 
}

而这个Widget对应的会创建RenderConstrainedBox。那么具体的布局工作就是由它来完成的,并且从上述代码可知,那个100x100的尺寸就在constraints里面了。

class RenderConstrainedBox extends RenderProxyBox {
  
  RenderConstrainedBox({
    RenderBox child,
    @required BoxConstraints additionalConstraints,
  }) : 
       _additionalConstraints = additionalConstraints,
       super(child);

  BoxConstraints _additionalConstraints;

  @override
  void performLayout() {
    if (child != null) {
      child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }
}

RenderConstrainedBox继承自RenderProxyBox。而RenderProxyBox则又继承自RenderBox

在这里我们看到了performLayout()的实现。当有孩子节点的时候,这里会调用child.layout()请求孩子节点做布局。调用时要传入对孩子节点的约束constraints。这里会把100x100的约束传入。在孩子节点布局完成以后把自己的尺寸设置为孩子节点的尺寸。没有孩子节点的时候就把约束转换为尺寸设置给自己。

我们看一下child.layout()。这个函数在RenderObject类中:

void layout(Constraints constraints, { bool parentUsesSize = false }) {
    RenderObject relayoutBoundary;
    if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
      relayoutBoundary = this;
    } else {
      final RenderObject parent = this.parent;
      relayoutBoundary = parent._relayoutBoundary;
    }

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

    if (sizedByParent) {
      try {
        performResize();
      } catch (e, stack) {
        ...
      }
    }
    try {
      performLayout();
      markNeedsSemanticsUpdate();
     
    } catch (e, stack) {
      ...
    }
    _needsLayout = false;
    markNeedsPaint();
  }

这个函数比较长一些,也比较关键。首先做的事情是确定relayoutBoundary。这里面有几个条件:

  1. parentUsesSize:父组件是否需要子组件的尺寸,这是调用时候的入参,默认为false
  2. sizedByParent:这是个RenderObject的属性,表示当前RenderObject的布局是否只受父RenderObject给与的约束影响。默认为false。子类如果需要的话可以返回true。比如RenderErrorBox。当我们的Flutter app出错的话,屏幕上显示出来的红底黄字的界面就是由它来渲染的。
  3. constraints.isTight:代表约束是否是严格约束。也就是说是否只允许一个尺寸。
  4. 最后一个条件是父亲节点是否是RenderObject。 在以上条件任一个满足时,relayoutBoundary就是自己,否则取父节点的relayoutBoundary

接下来是另一个判断,如果当前节点不需要做重新布局,约束也没有变化,relayoutBoundary也没有变化就直接返回了。也就是说从这个节点开始,包括其下的子节点都不需要做重新布局了。这样就会有性能上的提升。

然后是另一个判断,如果sizedByParenttrue,会调用performResize()。这个函数会仅仅根据约束来计算当前RenderObject的尺寸。当这个函数被调用以后,通常接下来的performLayout()函数里不能再更改尺寸了。

performLayout()是大部分节点做布局的地方了。不同的RenderObject会有不同的实现。

最后标记当前节点需要被重绘。布局过程就是这样递归进行的。从上往下一层层的叠加不同的约束,子节点根据约束来计算自己的尺寸,需要的话,父节点会在子节点布局完成以后拿到子节点的尺寸来做进一步处理。也就是我们开头说的一下一上。

调用layout()的时候我们需要传入约束,那么我们就来看一下这个约束是怎么回事:

abstract class Constraints {
  bool get isTight;

  bool get isNormalized;
}

这是个抽象类,仅有两个getterisTight就是我们之前说的严格约束。因为Flutter中主要是盒子约束。所以我们来看一下Constraints的子类:BoxConstraints

BoxConstraints

class BoxConstraints extends Constraints {
  const BoxConstraints({
    this.minWidth = 0.0,
    this.maxWidth = double.infinity,
    this.minHeight = 0.0,
    this.maxHeight = double.infinity,
  });
  
  final double minWidth;
  
  final double maxWidth;

  final double minHeight;

  final double maxHeight;
  ...
 }

盒子约束有4个属性,最大宽度,最小宽度,最大高度和最小高度。这4个属性的不同组合构成了不同的约束。

当在某一个轴方向上最大约束和最小约束是相同的,那么这个轴方向被认为是严格约束(tightly constrained)的。

BoxConstraints.tight(Size size)
    : minWidth = size.width,
      maxWidth = size.width,
      minHeight = size.height,
      maxHeight = size.height;

const BoxConstraints.tightFor({
    double width,
    double height,
  }) : minWidth = width != null ? width : 0.0,
       maxWidth = width != null ? width : double.infinity,
       minHeight = height != null ? height : 0.0,
       maxHeight = height != null ? height : double.infinity;
    
BoxConstraints tighten({ double width, double height }) {
    return BoxConstraints(minWidth: width == null ? minWidth : width.clamp(minWidth, maxWidth),
                              maxWidth: width == null ? maxWidth : width.clamp(minWidth, maxWidth),
                              minHeight: height == null ? minHeight : height.clamp(minHeight, maxHeight),
                              maxHeight: height == null ? maxHeight : height.clamp(minHeight, maxHeight));
  }

当在某一个轴方向上最小约束是0.0,那么这个轴方向被认为是宽松约束(loose)的。

  BoxConstraints.loose(Size size)
    : minWidth = 0.0,
      maxWidth = size.width,
      minHeight = 0.0,
      maxHeight = size.height;
      

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

当某一轴方向上的最大约束的值小于double.infinity时,这个轴方向的约束是有限制的。

 bool get hasBoundedWidth => maxWidth < double.infinity;
 
 bool get hasBoundedHeight => maxHeight < double.infinity;

当某一轴方向上的最大约束的值等于double.infinity时,这个轴方向的约束是无限制的。如果最大最小约束都是double.infinity,这个轴方向的约束是扩展的(exbanding)。

  const BoxConstraints.expand({
    double width,
    double height,
  }) : minWidth = width != null ? width : double.infinity,
       maxWidth = width != null ? width : double.infinity,
       minHeight = height != null ? height : double.infinity,
       maxHeight = height != null ? height : double.infinity;

最后,在布局的时候节点需要把约束转换为尺寸。这里得到的尺寸被认为是满足约束的。

  Size constrain(Size size) {
    Size result = Size(constrainWidth(size.width), constrainHeight(size.height));
    return result;
  }
  
  double constrainWidth([ double width = double.infinity ]) {
    return width.clamp(minWidth, maxWidth);
  }

  double constrainHeight([ double height = double.infinity ]) {
    return height.clamp(minHeight, maxHeight);
  }

布局例子

我们知道render tree的根节点是RenderView。在RendererBinding创建RenderView的时候会传入一个ViewConfiguration类型的配置参数:

void initRenderView() {
   assert(renderView == null);
   renderView = RenderView(configuration: createViewConfiguration(), window: window);
   renderView.scheduleInitialFrame();
 }

ViewConfiguration定义如下,包含一个尺寸属性和一个设备像素比例属性:

@immutable
class ViewConfiguration {

  const ViewConfiguration({
    this.size = Size.zero,
    this.devicePixelRatio = 1.0,
  });

  final Size size;

  final double devicePixelRatio;
}

ViewConfiguration实例由函数createViewConfiguration()创建:

ViewConfiguration createViewConfiguration() {
    final double devicePixelRatio = window.devicePixelRatio;
    return ViewConfiguration(
      size: window.physicalSize / devicePixelRatio,
      devicePixelRatio: devicePixelRatio,
    );
  }

可见,尺寸取的是窗口的物理像素大小再除以设备像素比例。在Nexus5上,全屏窗口的物理像素大小(window.physicalSize)是1080x1776。设备像素比例(window.devicePixelRatio)是3.0。最终ViewConfigurationsize属性为360x592。

那么我们来看一下RenderView如何做布局:

@override
  void performLayout() {
    _size = configuration.size;
    if (child != null)
      child.layout(BoxConstraints.tight(_size));
  }

根节点根据配置的尺寸生成了一个严格的盒子约束,以Nexus5为例的话,这个约束就是最大宽度和最小宽度都是360,最大高度和最小高度都是592。在调用子节点的layout()的时候传入这个严格约束。

假如我们想在屏幕居中位置显示一个100x100的矩形,代码如下:

runApp(Center(child: Container(width: 100, height: 100, color: Color(0xFFFF9000),)));

运行以后则render tree结构如下:

render tree

RenderView的子节点是个RenderPositionedBox。其布局函数如下:

@override
  void performLayout() {

    if (child != null) {
      child.layout(constraints.loosen(), parentUsesSize: true);
      size = constraints.constrain(Size(shrinkWrapWidth ? child.size.width * (_widthFactor ?? 1.0) : double.infinity,
                                            shrinkWrapHeight ? child.size.height * (_heightFactor ?? 1.0) : double.infinity));
      alignChild();
    } 
  }

这里的constraints来自根节点RenderView。我们之前分析过,这是一个360x592的严格约束。在调用孩子节点的layout()时候会给孩子节点一个新的约束,这个约束是把自己的严格约束宽松以后的新约束,也就是说,给子节点的约束是[0-360]x[0-592]。并且设置了parentUsesSizetrue

接下来就是子节点RenderConstrainedBox来布局了:

 @override
  void performLayout() {
    if (child != null) {
      child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }

这里又会调用子节点RenderDecoratedBox的布局函数,给子节点的约束是啥样的呢? _additionalConstraints来自我们给我们在Container中设置的100x100大小。从前述分析可知,这是个严格约束。而父节点给过来的是[0-360]x[0-592]。通过调用enforce()函数生成新的约束:

BoxConstraints enforce(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: minWidth.clamp(constraints.minWidth, constraints.maxWidth),
      maxWidth: maxWidth.clamp(constraints.minWidth, constraints.maxWidth),
      minHeight: minHeight.clamp(constraints.minHeight, constraints.maxHeight),
      maxHeight: maxHeight.clamp(constraints.minHeight, constraints.maxHeight),
    );
  }

从上述代码可见,新的约束就是100x100的严格约束了。最后我们就来到了叶子节点(RenderDecoratedBox)的布局了:

 @override
  void performLayout() {
    if (child != null) {
      child.layout(constraints, parentUsesSize: true);
      size = child.size;
    } else {
      performResize();
    }
  }

因为是叶子节点,它没有孩子,所以走的是else分支,调用了performResize()

@override
  void performResize() {
    size = constraints.smallest;
  }

没有孩子的时候默认布局就是使自己在当前约束下尽可能的小。所以这里得到的尺寸就是100x100;

至此布局流程的“一下”这个过程就完成了。可见,这个过程就是父节点根据自己的配置生成给子节点的约束,然后让子节点根据父节点的约束去做布局。

“一下”做完了,那么就该“一上”了。 回到叶子节点的父节点RenderConstrainedBox

child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child.size;

没干啥,把孩子的尺寸设成自己的尺寸,孩子多大我就多大。再往上,就到了RenderPositionedBox

child.layout(constraints.loosen(), parentUsesSize: true);
      size = constraints.constrain(Size(shrinkWrapWidth ? child.size.width * (_widthFactor ?? 1.0) : double.infinity,
                                            shrinkWrapHeight ? child.size.height * (_heightFactor ?? 1.0) : double.infinity));
      alignChild();

这里shrinkWrapWidthshrinkWrapHeight都是false。而约束是360x592的严格约束,所以最后得到的尺寸就是360x592了。而孩子节点是100x100,那就需要知道把孩子节点放在自己内部的什么位置了,所以要调用alignChild()

  void alignChild() {
    _resolve();
    final BoxParentData childParentData = child.parentData;
    childParentData.offset = _resolvedAlignment.alongOffset(size - child.size);
  }

孩子节点在父节点内部的对齐方式由Alignment决定。

class Alignment extends AlignmentGeometry {
  const Alignment(this.x, this.y)
  
  final double x;

  final double y;

  @override
  double get _x => x;

  @override
  double get _start => 0.0;

  @override
  double get _y => y;

  /// The top left corner.
  static const Alignment topLeft = Alignment(-1.0, -1.0);

  /// The center point along the top edge.
  static const Alignment topCenter = Alignment(0.0, -1.0);

  /// The top right corner.
  static const Alignment topRight = Alignment(1.0, -1.0);

  /// The center point along the left edge.
  static const Alignment centerLeft = Alignment(-1.0, 0.0);

  /// The center point, both horizontally and vertically.
  static const Alignment center = Alignment(0.0, 0.0);

  /// The center point along the right edge.
  static const Alignment centerRight = Alignment(1.0, 0.0);

  /// The bottom left corner.
  static const Alignment bottomLeft = Alignment(-1.0, 1.0);

  /// The center point along the bottom edge.
  static const Alignment bottomCenter = Alignment(0.0, 1.0);

  /// The bottom right corner.
  static const Alignment bottomRight = Alignment(1.0, 1.0);

其内部包含两个浮点型的系数。通过这两个系数的组合就可以定义出我们通用的一些对齐方式,比如左上角是Alignment(-1.0, -1.0)。顶部居中就是Alignment(0.0, -1.0)。右上角就是Alignment(1.0, -1.0)。我们用到的垂直水平都居中就是Alignment(0.0, 0.0)。那么怎么从Alignment来计算偏移量呢?就是通过我们在上面见到的 Alignment.alongOffset(size - child.size)调用了。

Offset alongOffset(Offset other) {
    final double centerX = other.dx / 2.0;
    final double centerY = other.dy / 2.0;
    return Offset(centerX + x * centerX, centerY + y * centerY);
  }

入参就是父节点的尺寸减去子节点的尺寸,也就是父节点空余的空间。分别取空余长宽然后除以2得到中值。然后每个中值在加上Alignment的系数乘以这个中值就得到了偏移量。是不是很巧妙?我们的例子是垂直水平都居中,xy都是0。所以可得偏移量就是[130,246]。

回到alignChild(),在取得偏移量之后,父节点会通过设置childParentData.offset把这个偏移量保存在孩子节点那里。这个偏移量在后续的绘制流程中会被用到。

最后就回到了根节点RenderView。至此布局流程的“一上”也完成了。可见这个后半段流程父节点有可能根据子节点的尺寸来决定自己的尺寸,同时也有可能要根据子节点的尺寸和自己的尺寸来决定子节点在其内部的位置。

总结

本篇文章介绍了Flutter渲染流水线的布局(layout)阶段,布局(layout)阶段主要就是要掌握住“一下一上”过程,一下就是约束层层向下传递,一上就是尺寸层层向上传递。本篇并没有过多介绍各种布局的细节,大家只要掌握了布局的流程,具体哪种布局是如何实现的只需要查阅对应RenderObject的源码就可以了。