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

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

《Flutter框架分析(一)-- 總覽和Window》數組

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

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

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

《Flutter框架分析(五)-- 動畫》框架

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

《Flutter框架分析(七)-- 繪製》ide

前言

以前的文章給你們介紹了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的源碼就能夠了。

相關文章
相關標籤/搜索