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

ad6623發表於2019-05-22

前言

之前的文章給大家介紹了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的原始碼就可以了。

相關文章