深入研究Flutter布局原理

作者:RayC 来源:juejin.cn 更新时间:2023-05-25 21:55

? 在Flutter开发中,界面的实现都是由一个个Widget嵌套完成。诸如MaterialAppContainerText等等都是Widget,可谓万物皆Widget,使用好Widget并了解Widget原理对开发会有很大帮助。

Widget原理简介

1、何为StatelessWidget、StatefulWidget

? StatelessWidget、StatefulWidget都继承自Widget。StatelessWidget的方法比较少,没有刷新逻辑,只需要实现build方法返回Widget树即可。StatefulWidget存在页面刷新逻辑,因而将build方法移动到派生类State中,State类用于保存页面状态,有一系列的回调方法,可以通过setState方法触发rebuild,以刷新页面。

? 两个类都有一个createElement()方法,以StatelessElement为例:

/// An [Element] that uses a [StatelessWidget] as its configuration.
class StatelessElement extends ComponentElement {
  /// Creates an element that uses the given widget as its configuration.
  StatelessElement(StatelessWidget widget) : super(widget);

  @override
  StatelessWidget get widget => super.widget as StatelessWidget;

  @override
  Widget build() => widget.build(this);

  @override
  void update(StatelessWidget newWidget) {
    super.update(newWidget);
    assert(widget == newWidget);
    _dirty = true;
    rebuild();
  }
}

? 构造方法会将当前Widget实例传入,build方法会调用Widget的方法,并将自身传入。这里我们可以看出build方法中的参数BuildContext即为Element类。

abstract class Element extends DiagnosticableTree implements BuildContext

? 我们其实可以看出,这两个类本身并不负责控件的绘制和布局,它们存在的价值就是将build方法产生的Widget树和Element做一个关联,实际的绘制、布局等其实都是由build方法下的各个Widget实现的。

2、SingleChildRenderObjectWidget

? SingleChildRenderObjectWidget继承自RenderObjectWidget,可以容纳一个child Widget,重写了createElement方法。Flutter中很多容器的实现都是继承此实现的,例如AlignColoredBoxConstrainedBox等。SingleChildRenderObjectWidget和RenderObjectWidget类都是抽象类,子类需要实现createRenderObject方法来返回不同的RenderObject,并重写updateRenderObject方法以更新,以此来完成不同的布局或者绘制效果。

? 类比于Android的代码,SingleChildRenderObjectWidget相当于一个SingleChild的ViewGroup,只能容纳一个子Widget,可以对其布局、宽高等进行定制,也可以自己进行一些绘制。

3、MultiChildRenderObjectWidget

? MultiChildRenderObjectWidget同样继承自RenderObjectWidget,和SingleChildRenderObjectWidget不同的是,他可以容纳一个child集合,创建的Element实例也不相同。Flutter中,能够容纳多个child widget的容器基本都是继承此类实现。例如RowColumn,他们继承自Flex,而Flex则继承自MultiChildRenderObjectWidget。

? 这一类的实现就和Android中的ViewGroup的职责很相像, 我们需要为children进行宽高的设定,还有绘制区域的设定等一系列的操作。

4、LeafRenderObjectWidget

? 这一类可能接触的会相对少一点。官方的类注释中是这么写的:

/// A superclass for RenderObjectWidgets that configure RenderObject subclasses
/// that have no children.

? LeafRenderObjectWidget同样继承自RenderObjectWidget,只是没有容纳任何的子Widget,此Widget主要职责就是自己进行绘制操作了。诸如TextureAndroidSurfaceViewRawImage等,他们所关注的要点是绘制。

5、小结

? 在Flutter的官方库中,为我们提供了数百个Widget的实现,同时掌握和了解这么多的Widget的使用是不现实的,也没有必要。官方提供的很多Widget其实都是继承实现的,而从上文我们可以看出,影响Widget样式的实际类是RenderObject,此类才是绘制和布局的核心。掌握并了解了RenderObject,我们完全可以自定进行一些布局或者绘制,这样当官方类库的实现无法满足我们需求的时候,我们也不会毫无办法。

RenderObject解析

1、什么是RenderObject

? 先来看看官方的介绍:

/// An object in the render tree.
///
/// The [RenderObject] class hierarchy is the core of the rendering
/// library's reason for being.
///
/// [RenderObject]s have a [parent], and have a slot called [parentData] in
/// which the parent [RenderObject] can store child-specific data, for example,
/// the child position. The [RenderObject] class also implements the basic
/// layout and paint protocols.

? 可以看出,RenderObject是渲染库实现的核心。我们可以通过实现它来完成Widget的测量、布局绘制等一系列操作。在上篇中我们也可以看到各个核心类库的实现都是通过自定义RenderObject实现。

? RenderObject可以有一个parentData,可以用来存储一些child的特定信息,比如child的位置信息等。但RenderObject类本身并没有定义为一个容器,想要容纳其他的RenderObject,需要通过官方提供的一些mixin实现。RenderObject是一个比较顶层的定义,在很多场景下,实现一个RenderObject的子类RenderBox会是一个更好的选择。

2、RenderObject主要方法介绍

void setupParentData(covariant RenderObject child)

? 重写此方法,可以在child被加入进来之前为他设置一个ParenData,定义不同的ParenData可以存储一些我们需要的信息,满足不同的需求。

void markNeedsLayout()

? 此方法会对布局信息进行标记,渲染管道会在一个合适的时间对此对象的布局信息进行更新。如果一个RenderObject的Parent声明了需要用到此RenderObject的布局信息,当为此RenderObject调用markNeedsLayout()方法后,也会对此RenderObject的Parent进行标记。当父容器和子容器都需要重新布局的时候,会仅仅通知父容器,当其布局完成后,会调用子容器的layout方法,这样child也会完成布局。

  • Flutter中的layout和Android的layout命名看起来类似,实际的作用却不太一样,在Flutter中执行的layout操作更接近与Android中的measure操作,他主要负责完成Widget的Constraints布局约束的配置。Constraints会影响RenderObject展示的区域。

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

? 这个方法通常是为child调用的。这个方法会将Constraints传递给child,用来描述child可以使用的宽高约束,child需要遵循这个约束。如果需要child的宽高来调整大小信息,则需要传递parentUsesSize为true,这样当child布局信息发生改变之后会通知此RenderObject来更新布局信息。这个方法不应该被重写,作为替代应该去重写performResize或者performLayout方法,layout方法会把实际工作代理给这两个方法。如果RenderObject定义为容器,那么他需要为容纳的所有RenderObject调用此方法。

bool get sizedByParent

? 这个get方法默认返回的是false,这样返回的话表明这个RenderObject的大小信息不会受到父容器的影响,这样我们可以在performLayout方法中为这个RenderObject设置大小信息。如果返回true,那我们需要把RenderObject的大小设置调整到performResize方法中实现。

void performResize()

? 重写此方法以用来更新Widget的大小信息。如果想要依赖父容器的大小来设置大小,则需要sizedByParent

返回true。父容器完成布局工作后,这个方法会被调用,可以在这里拿到父容器传递的布局约束信息。这个方法只有sizedByParent返回true的时候才会被调用。

void performLayout()

? 通常情况下我们会重写此方法来为此RenderObject设置大小信息,如果有child,还需要在这个方法里为child调用layout方法。

void markNeedsPaint()

? 这个方法在RenderObject需要重新绘制的时候调用。调用之后渲染管道会进行规划,会在一个合适的实际调用次RenderObject的paint方法完成绘制更新。

Rect get paintBounds

? 通过这个方法拿到此RenderObject应该绘制的区域。如果返回为null,那么这个RenderObject将会被完整绘制。这个Rect也是showOnScreen方法显示Rect所使用的。

void paint(PaintingContext context, Offset offset)

? 重写此方法,通过调用PaintingContext.canvas ,我们可以拿到Canvas对象来进行一些绘制。通常情况下,我们需要把此canvas的起点平移到offset,如果不进行移动,canvas的默认起点将会是屏幕的左上角。如果此RenderObject是一个容器类型,那么可以在这里调用PaintingContext.paintChild来绘制指定的child,paintChild方法需要传递一个child和Offset,根据传入的Offset不同,会影响到child在屏幕中显示位置。

? 相对Android的onDraw方法,Flutter中的paint方法里进行绘制的时候,需要根据Offset进行偏移绘制。这个Offset定义了此RenderObject应该绘制的起点信息。

Rect operator &(Size other) => Rect.fromLTWH(dx, dy, other.width, other.height);

? Offset类中重写了操作符&,我们之前也确定了此RenderObject的Size,通过Offset & Size我们可以拿到Rect,这个区域就是此RenderObject应该绘制的区域。

  • 虽然通过offset & size可以拿到一个rect,但是我们实际绘制中是可以不在这个区域内进行的,你可以在屏幕的任何区域进行绘制,但是最好还是遵守此区域的设置,不要绘制出边界,除非你有特殊的需求。

3、RenderBox

? 说到RenderObject就不得不说到RenderBoxRenderBoxRenderObject的一个非常重要的子类。因为RenderObject的定义比较顶层,甚至连宽高信息这一很重要的属性都没有定义,因而我们通常都需要继承RenderBox来进行定制RenderObject

? RenderBox中定义了一系列的获取宽高的方法,诸如getMinIntrinsicHeightgetMaxIntrinsicWidth等,这类方法本质交由外部调用的,用于暴露此RenderBox的宽高信息。我们应该重写的是一些诸如computeMaxIntrinsicWidthcomputeMinIntrinsicHeight

方法,计算并返回我们想要的宽高信息。RenderBox还提供了Size属性,用来指定此RenderBox的尺寸,这个Size通常会根据约束信息进行计算,从而得到一个合适的尺寸。Size的尺寸不能超过布局约束中的constraints.maxWidthconstraints.maxHeight,否则会导致渲染出错。RenderBox中还定义了getDistanceToBaseline方法供外部调用,用来返回距离给定的TextBaseline的y轴距离。getDistanceToBaseline方法的返回值由computeDistanceToActualBaseline方法计算得来。

? RenderBox的子类众多,官方提供的大部分容器的的RenderObject的实现都是RenderBox的子类。诸如Align,他继承自SingleChildRenderObjectWidgetRenderObject的具体实现是RenderPositionedBoxRenderPositionedBox的继承关系如下:

? 看起来是不是很多?其实这只是官方做的职责分明,各个实现类里做了一些不同的事情。RenderShiftedBox会沿用child的宽高信息,并重写了paint方法,使child的绘制在正确的区域。RenderAligningShiftedBox则会通过计算Alignment,为child的parentData设置一个Offset,核心方法如下

/// Apply the current [alignment] to the [child].
///
/// Subclasses should call this method if they have a child, to have
/// this class perform the actual alignment. If there is no child,
/// do not call this method.
///
/// This method must be called after the child has been laid out and
/// this object's own size has been set.
@protected
void alignChild() {
  _resolve();
  assert(child != null);
  assert(!child!.debugNeedsLayout);
  assert(child!.hasSize);
  assert(hasSize);
  assert(_resolvedAlignment != null);
  final BoxParentData childParentData = child!.parentData as BoxParentData;
  childParentData.offset = _resolvedAlignment!.alongOffset(size - child!.size as Offset);
}

? RenderPositionedBox则主要用来定义容器的大小,他的size会尽可能的填满布局constraints的大小。如果有child,则会调用RenderAligningShiftedBoxalignChild方法计算偏移,最终让child显示在指定的位置。

4、小结

? RenderObject是Render树里的一个对象,他的实现会影响到Widget最终显示在屏幕上的效果。他定义了一些基本的方法,通过重写

performLayout()或者performResize()方法,我们确认此RenderObject的大小信息,通过在performLayout()方法中为child调用layout,来传递布局约束信息。paint方法里我们可以进行一些绘制,如果有child,还需要为child进行绘制。绘制顺序不同也会影响实际显示效果。诸如Decoration的实现,前景色和背景色的效果主要还是绘制顺序的影响。想要改变child显示的位置,我们需要给child设置一定的绘制OffsetOffset是相对于此Widget而言的。通常来说,Offset存储在child的parentData里,调用paintChild绘制时,需要取出parentData中的Offset结合上文传递的Offset进行绘制。

自定义布局实战

1、布局约束

? 在上面,我们介绍组件渲染流程时,我们了解到了 Flutter 中的控件在屏幕上绘制渲染之前需要先进行布局(Layout)操作。其具体可分为两个线性过程:从顶部向下传递约束,从底部向上传递布局信息,其过程可用下图表示。

? 第一个线性过程用于传递布局约束。父节点给每个子节点传递约束,这些约束是每个子节点在布局阶段必须要遵守的规则。就好像父母告诉自己的孩子 :“你必须遵守学校的规定,才可以做其他的事”。常见的约束包括规定子节点最大最小宽度或者子节点最大最小的高度。这种约束会向下延伸,子组件也会产生约束传递给自己的孩子,一直到叶子结点。

? 第二的线性过程用来传递具体的布局信息。子节点接受到来自父节点的约束后,会依据它产生自己具体的布局信息,如父节点规定我的最小宽度是 500 的单位像素,子节点按照这个规则可能定义自己的宽度为 500 个像素,或者大于 500 像素的任何一个值。这样,确定好自己的布局信息之后,将这些信息告诉父节点。父节点也会继续此操作向上传递一直到最顶部。

? 下面我们具体介绍有哪些具体的布局约束可在树中传递。Flutter 中有两种主要的布局协议:Box 盒子协议和 Sliver 滑动协议。这里我们以盒子协议为例展开具体的介绍。

? 在盒子协议中,父节点传递给其子节点的约束为 BoxConstraints。该约束规定了允许每个子节点的最大和最小宽度和高度。如下图,父节点传入 Min Width 为 150,Max Width 为 300 的 BoxConstraints:

当子节点接受到该约束,便可以取得上图中绿色范围内的值,即宽度在 150 到 300 之间,高度大于 100,当取得具体的值之后再将取得具体的大小的值上传给父节点,从而达到父子的布局通信。

2、Alignment

? Alignment会定义一个点,来表示在一个矩阵中的位置,Alignment中默认定义了以下的点位:

/// 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能定义的点就只有上面声明的这些了吗?并不是。下面将各个点整合,会让我们更容易理解Alignment的本质:

? Alignment以Rect的中间为基点,定义为0,0。可以依此建立一个坐标系,横向取值-1.0~1.0,实际就是一个百分比,根据Rect的宽度来计算百分比,从而确定横向的位置,横向也是一个原理。举例说明,定义点Alignment(0.5,0,0),那么这个点会基于中心点向右偏移Rect宽度的1/4。

3、自己实现一个Align

? 说了那么多,不如自己手写一个Align,来加深对Flutter布局体系的理解。这里就做比较简单一点的实现。

class CustomAlign extends SingleChildRenderObjectWidget {
  final Alignment alignment;

  const CustomAlign({Key key, Widget child, this.alignment = Alignment.topLeft})
      : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    // TODO: implement createRenderObject
    throw UnimplementedError();
  }
}

? 首先定义一个类CustomAlign,他继承自SingleChildRenderObjectWidget,因为需要基于Alignment定位,所以这里定一个参数来使用。接下来我们需要自己实现一个AlignRenderBox来实现此效果。

class AlignRenderBox extends RenderBox
    with RenderObjectWithChildMixin<RenderBox> {
  Alignment alignment;

  AlignRenderBox({RenderBox child, this.alignment}) {
    this.child = child;
  }

  @override
  void performLayout() {
    ///super.performLayout(); 要注意千万不要调用super.performLayout()
    if (child == null) {
      size = Size.zero;
      ///没有child则不占用控件
    } else {
      size = constraints.constrain(Size.infinite); //尽可能填满
      child.layout(constraints.loosen(), parentUsesSize: true); //不对child的大小进行限制
      BoxParentData parentData = child.parentData as BoxParentData;
      parentData.offset = alignment.alongOffset(size - child.size as Offset); //设置偏移
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    super.paint(context, offset);
    if (child != null) {
      //如果child不为空 绘制child 并为child
      BoxParentData parentData = child.parentData as BoxParentData;
      context.paintChild(child, offset + parentData.offset);
    }
  }
}

? AlignRenderBox实现也很简单,performLayout()中我们会首先判断有无child,如果没有则此RenderObject的宽高设定为0,如果有child,则尽可能填满父容器。取出child的BoxParentData,指定Alignment带来的偏移坐标。最后在paint方法中绘制child,为其指定offset。最后把此AlignRenderBox提供给CustomAlign使用即可:

class CustomAlign extends SingleChildRenderObjectWidget {
  final Alignment alignment;

  const CustomAlign({Key key, Widget child, this.alignment = Alignment.topLeft})
      : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return AlignRenderBox(alignment: alignment);
  }

  @override
  void updateRenderObject(
      BuildContext context, covariant AlignRenderBox renderObject) {
    renderObject.alignment = alignment;
  }
}

? 需要注意的是,我们还需要重写updateRenderObject来更新AlignRenderBox

? 以上,一个简单的Align就算完成了,开始测试。

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  void _incrementCounter() {
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: SizedBox(
        child: CustomAlign(
          alignment: Alignment.center,
          child: Text('文件传输中'),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

运行效果如下:

参考文章:1、Flutter原理:三棵重要的树(渲染过程、布局约束、应用视图的构建等)