flutter布局计算规则

之前主要是负责Web开发的,在刚开始写Flutter的时候很容易把Container组件当成div标签来使用 ,以至于在碰到Container(width:100,height:100,color:Colors.red),代码并没有按预期展示宽高各100的元素时感到非常诧异。

后来查阅了相关文章之后,终于了解到:Flutter与Web浏览器的布局差异非常之大!本文主要整理Flutter渲染流程中的布局计算规则。

<!--more-->

参考 距离上一次学习Flutter已经过去了很长时间,现在官方文档感觉写的已经比较详细了,建议flutter初学者可以先过一下官方的整体文档

1. Flutter渲染三棵树

1.1. Widget组件树

在UI开发中,Flutter的设计思想与React基本一致

UI = f(State)

Widget对应的是就是虚拟DOM,是不可变的,它的改变意味着重新创建。在Dart语言中,实例的初始化和销毁是非常迅速的,这也是为什么Flutter选择了Dart作为开发语言。

Widget是最基本的布局组件,大概可以分为三类组合类、代理类、绘制类

  • 组合类,都继承自StatefulWidgetStatelessWidget,通过实现build方法返回Widget组件树
  • 代理类,用来为child widget提供一些中间功能,如InheritedWidget
  • 绘制类,是最核心的Widget类型,所有需要绘制的Widget最后都继承自这个RenderObjectWidget

1.2. RenderObject渲染树

前面提到所有需要绘制的Widget,最终都继承自RenderObjectWidget

abstract class RenderObjectWidget extends Widget {
  @protected
  @factory
  RenderObject createRenderObject(BuildContext context);
}

影响Widget样式的实际类是RenderObject,此类才是绘制和布局的核心。渲染树的任务是做组件的具体的布局渲染工作

渲染树上每个节点都是一个继承自 RenderObject 类的对象,负责布局约束和尺寸计算

RenderObject定义了几个比较布局阶段比较重要的方法

abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
  @protected
  void performLayout();

  // 布局,主要由子节点调用
  void layout(Constraints constraints, { bool parentUsesSize = false }) {}

  // 在canvas上绘制最终的内容
  void paint(PaintingContext context, Offset offset) { }
}

Flutter 中的控件在屏幕上绘制渲染之前需要先进行布局(Layout)操作,也就会调用performLayout方法 。

来找个实现了createRenderObject方法的Widget,比如Padding

abstract class SingleChildRenderObjectWidget extends RenderObjectWidget {}

class Padding extends SingleChildRenderObjectWidget {
  @override
  RenderPadding createRenderObject(BuildContext context) {
    return RenderPadding(
      padding: padding,
      textDirection: Directionality.maybeOf(context),
    );
  }
}

RenderPadding是一个继承自RenderObject的类,我们来看看他实现的相关方法

abstract class RenderBox extends RenderObject {
  // RenderBox是RenderObject的一个非常重要的子类
  // 因为RenderObject的定义比较顶层,甚至连宽高信息这一很重要的属性都没有定义
  // 因而我们通常都需要继承RenderBox来进行定制RenderObject
}
abstract class RenderShiftedBox extends RenderBox with RenderObjectWithChildMixin<RenderBox> {}
class RenderPadding extends RenderShiftedBox {
  @override
  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    _resolve();
    if (child == null) {
      size = constraints.constrain(Size(
        _resolvedPadding!.left + _resolvedPadding!.right,
        _resolvedPadding!.top + _resolvedPadding!.bottom,
      ));
      return;
    }
    final BoxConstraints innerConstraints = constraints.deflate(_resolvedPadding!);
    // 将约束传递给子节点
    child!.layout(innerConstraints, parentUsesSize: true);
    final BoxParentData childParentData = child!.parentData! as BoxParentData;
    childParentData.offset = Offset(_resolvedPadding!.left, _resolvedPadding!.top);
    // 根据子节点的尺寸计算自己的尺寸
    size = constraints.constrain(Size(
      _resolvedPadding!.left + child!.size.width + _resolvedPadding!.right,
      _resolvedPadding!.top + child!.size.height + _resolvedPadding!.bottom,
    ));
  }
}

这里就可以看见performLayout、也就是Flutter布局的具体工作,可分为两个线性过程:

  • 从顶部向下传递约束
  • 从底部向上传递布局尺寸信息

Flutter内置了许多Widget,大都已经实现了相关的performLayout,因此我们完全也可以参考源码,实现各种自定义渲染的组件。

1.3. Element元素树

在业务开发中,我们最常接触的是Widget组件,实际渲染的又是RenderObject,这两者如何关联起来呢?

Widget对应的是虚拟DOM,那么肯定有一个与之对应的、最后用来真正绘制在屏幕上面的“真实DOM”对象。这就是Element

每个Widget都会对应一个Element,因此WIdget组件树也会对应一颗Element元素树。

abstract class Widget extends DiagnosticableTree { 
  @protected
  @factory
  Element createElement();
}

Element持有其对应 Widget 的引用,在Widget改变后,会被标记成dirty Element,在每次更新时,只会更新那些dirty Element的元素,从而提升性能。

前面提到,Widget是不可变的,每次改变都会生成一个新的Widget,那StatefulWidget的state是谁来负责保存呢?也是由Element负责的

abstract class StatefulWidget extends Widget {
  @override
  StatefulElement createElement() => StatefulElement(this);
}
class StatefulElement extends ComponentElement {
  /// Creates an element that uses the given widget as its configuration.
  StatefulElement(StatefulWidget widget)
      : _state = widget.createState(),
        super(widget) {
    state._element = this;
    state._widget = widget;
    assert(state._debugLifecycleState == _StateLifecycle.created);
  }
  @override
  Widget build() => state.build(this);
}

这里也可以看到,Widget的build方法中的BuildContext实际上就是该widget对应的Element

class _MyHomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    // 这个context就是对应的element
  }
}

此外,Element维持了获取renderObject实例的引用

abstract class Element extends DiagnosticableTree implements BuildContext {
  RenderObject? get renderObject {
    RenderObject? result;
    void visit(Element element) {
      assert(result == null); // this verifies that there's only one child
      if (element._lifecycleState == _ElementLifecycle.defunct) {
        return;
        // 遍历Element子树,找到RenderObjectElement,并获取其renderObject
      } else if (element is RenderObjectElement) {
        result = element.renderObject;
      } else {
        element.visitChildren(visit);
      }
    }
    visit(this);
    return result;
  }
}

可以看出,Element会遍历子节点,找到类型为RenderObjectElement、也就是真正被渲染到屏幕上的那个renderObject。

所以,Element是Widget和RenderObject之间的中间人。

1.4. 小结

了解了Flutter中的三棵树之后,总结一下Flutter的渲染流程

  • 通过Widget的build获取组件树
  • 通过RenderObject的layout计算布局所需的尺寸和位置,然后执行paint将内容绘制在画布上

2. 约束

前面已经提到,RenderObjectlayout布局流程是从上到下传递约束,从下向上提供尺寸。这一章节来看看约束相关的知识。

2.1. 约束定义

约束Constraints 在Flutter中是一种布局协议,约束实际上就是四个数值:最大/最小宽度,最大/最小高度

约束可以根据最大最小值分为两大类

  • tight(紧约束):当max和min值相等时,这时传递给子类的是一个确定的宽高值。
  • loose(松约束):当max和min不相等的时候,这种时候对子类的约束是一个范围,称为松约束。

Flutter 整个布局过程就是向下约束和向上传值的过程: 父节点传递约束,子节点向上传递尺寸,最后由父节点决定你的位置

Constraints go down. Sizes go up. Parent sets position.

大概流程就是

  • 从根节点开始,Widget从其父节点获取自身可以用于布局的约束,对于根节点而言,就是屏幕大小的宽度和高度
  • 然后Widget会依次遍历其子节点,然后计算剩余可供每个子节点布局的约束,并将该约束传递给子节点,
    • 每个子节点布局的约束可能不一样,比如对于第一个子节点,还有100的高度可以布局,它用掉50个高度之后,对于后面的子节点,就只有50的高度可以布局了
  • 子节点也会依次向其子节点传递约束,所以是一个递归过程,最后子节点会返回它需要用于布局的大小
  • Widget获取其每个子节点的大小之后,就会对他们逐个进行布局,最后获得了Widget自己的大小,并返回给其父节点

因此Flutter的布局有一些重要的限制

  • 一个 widget 仅在其父级给其约束的情况下才能决定自身的大小。这意味着 widget 通常情况下 不能任意获得其想要的大小。
  • 一个 widget 无法知道,也不需要决定其在屏幕中的位置。因为它的位置是由其父级决定的。
  • 当轮到父级决定其大小和位置的时候,同样的也取决于它自身的父级。所以,在不考虑整棵树的情况下,几乎不可能精确定义任何 widget 的大小和位置。
  • 如果子级想要拥有和父级不同的大小,然而父级没有足够的空间对其进行布局的话,子级的设置的大小可能会不生效。 这时请明确指定它的对齐方式

2.2. 工作流程

了解了布局约束,就不难猜测为什么我们写的Container(width:100,height:100)在某些时候,得到的Container size为什么不是符合预期的100了。

来看看Container的源码

class Container extends StatelessWidget {
    @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 (color != null)
      current = ColoredBox(color: color!, child: current);

    if (clipBehavior != Clip.none) {
      assert(decoration != null);
      current = ClipPath(
        clipper: _DecorationClipper(
          textDirection: Directionality.maybeOf(context),
          decoration: decoration!,
        ),
        clipBehavior: clipBehavior,
        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!, alignment: transformAlignment, child: current);

    return current!;
  }
}

可以看见Container更像是一个代理类型的Widget,根据各种参数,最后在build方法中返回对应的组件。

2.3. ConstrainedBox

回到文章开头的例子

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return  Container(width: 100, height: 100, color: Colors.red);
  }
}

通过断点可以看见Container(width:100,height:100,color:Colors.red)最后进入的是

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

其中ColoredBox只是设置了一下renderObject的color属性,与布局无关,这里不展开。我们来看看ConstrainedBox

class ConstrainedBox extends SingleChildRenderObjectWidget {
  @override
  RenderConstrainedBox createRenderObject(BuildContext context) {
    return RenderConstrainedBox(additionalConstraints: constraints);
  }
}

class RenderConstrainedBox extends RenderProxyBox {
  @override
  void performLayout() {
    // 此时constraints是父元素传入的屏幕大小 BoxConstraints(w=428.0, h=926.0)
    final BoxConstraints constraints = this.constraints;
    // 此时 child 是ColoredBox
    if (child != null) {
      child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child!.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }
}

最后得到的size也就是Size(428.0, 926.0),因此我们就看到了一个铺满屏幕的红色容器,而不是一个Size(100.0, 100.0)的容器了。

2.4. LimitedBox

趁热打铁,顺便看看Container第一种情况:在没有child且没有约束时的情况下返回的是LimitedBox

current = LimitedBox(
  maxWidth: 0.0,
  maxHeight: 0.0,
  child: ConstrainedBox(constraints: const BoxConstraints.expand()),
);
class LimitedBox extends SingleChildRenderObjectWidget {
  @override
  RenderLimitedBox createRenderObject(BuildContext context) {
    // 找到对应的RenderObject
    return RenderLimitedBox(
      maxWidth: maxWidth,
      maxHeight: maxHeight,
    );
  }
}

class RenderLimitedBox extends RenderProxyBox {
  Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild }) {
    if (child != null) {
      final Size childSize = layoutChild(child!, _limitConstraints(constraints));
      return constraints.constrain(childSize);
    }
    return _limitConstraints(constraints).constrain(Size.zero);
  }

  @override
  void performLayout() {
    // 计算子节点的尺寸,然后将其作为自身的尺寸大小
    size = _computeSize(
      constraints: constraints,
      layoutChild: ChildLayoutHelper.layoutChild, // 实际上就是调用child.layout,然后返回child.size
    );
  }
}

class ChildLayoutHelper {
  static Size layoutChild(RenderBox child, BoxConstraints constraints) {
    child.layout(constraints, parentUsesSize: true);
    return child.size;
  }
}

根据这个流程,我们可以找到所有内置Widget对应RenderObject的布局计算规则,也就弄清楚了Container在不同场景下渲染不同结果的原因了。

3. 自定义布局组件

了解了Flutter的布局约束原理,我们可以很方便地实现各种自定义组件,接下来我们实现一个自定义的居中组件

import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';

class CustomCenter extends SingleChildRenderObjectWidget {
  const CustomCenter({Key? key, Widget? child}) : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderCustomCenter();
  }
}

class RenderCustomCenter extends RenderShiftedBox {
  RenderCustomCenter() : super(null);

  @override
  void performLayout() {
    child!.layout(constraints.loosen(), parentUsesSize: true);
    size = constraints.constrain(Size(child!.size.width, child!.size.height));
    BoxParentData parentData = child!.parentData as BoxParentData;
    parentData.offset = ((size - child!.size) as Offset) / 2;
  }
}

现在看起来是不是非常简单了。真正居中的只有最后那一行代码:通过设置子节点的parentData.offset来处理子节点的偏移。

将该计算公式进行调整,就可以得到居左、居右各种自定义组件了。

4. 小结

本文首先通过分析Flutter源码,整理了Flutter渲染的基本流程,包括:buildlayoutpaint

然后整理了layout中,也就是Flutter布局的基本原理:向下传递约束,向上传递尺寸;

最后实现了一个自定义组件。

在整个过程中,发现Flutter的源码还是比较容易阅读的。除了对Dart的部分语法不了解之外,比如number.clamp

double clamp<T extends num>(T number, T low, T high) =>
    max(low * 1.0, min(number * 1.0, high * 1.0));

其他感觉还是挺轻松的。之后会尝试进一步深入阅读Flutter相关源码。