Flutter渲染之绘制启动及layout

1,026 阅读9分钟

RenderObject

之前讲过三棵树的绘制,它们最后都是为RenderObject树服务的,RenderObject是确定节点位置、大小,处理子父位置关系的类,当构建过程完成之后,就生成了一棵RenderObject树,然后进入布局及绘制阶段。

RenderObject中包含parentparentDataconstraints等属性,layoutpaint等抽象方法,不过其中没有定义size、offset等具体大小和位置信息,为了更好的扩展性,其大小是通过RenderBox这个继承至RenderObject的类来实现的,parentData会保存位置等信息,在绘制时父节点再实时计算传给paint。具体实现细节,我们后面来慢慢讲解。

接收通知

Flutter在什么时候开始渲染流程呢?为了让渲染更加流畅,渲染只能被设计成异步,因为你不知道开发者会在复杂的业务中同时发起多少次渲染,渲染本身的耗时是昂贵的,如果每次都会经历渲染,那界面一定会卡顿。

如果只是简单的通过Flutter的Future来进行异步渲染,同上也会有性能问题,因为业务中也会存在多个异步。

所以AndroidiOS都有一个叫Vsync的机制。Vsync(垂直同步)是VerticalSynchronization的简写,让AppUI和SurfaceFlinger可以按硬件产生的VSync节奏进行工作,以此来达到界面的刷新和渲染保持在60FPS以内,让人类视觉上感觉到不卡顿。

上一篇文章讲了三棵树的构建完成后会发送一个通知,然后会等待Vsync信号的到来,在Flutter中,SchedulerBinding中有一个addPersistentFrameCallback方法来注册回调监听

/// 注册回调监听
void addPersistentFrameCallback(FrameCallback callback) {
  _persistentCallbacks.add(callback);
}

当Vsync发送到平台端时,会调用window.onDrawFrame方法,后面会触发到handleDrawFrame

void handleDrawFrame() {
  assert(_schedulerPhase == SchedulerPhase.midFrameMicrotasks);
  try {
    // 处理addPersistentFrameCallback回调
    _schedulerPhase = SchedulerPhase.persistentCallbacks;
    for (final FrameCallback callback in _persistentCallbacks)
      _invokeFrameCallback(callback, _currentFrameTimeStamp!);
    _schedulerPhase = SchedulerPhase.postFrameCallbacks;
    // 处理addPersistentFrameCallback回调 end

    // 处理addPostFrameCallback添加的回调,此回调会在添加监听的下一时刻被调用且只会被调用一次
    final List<FrameCallback> localPostFrameCallbacks =
        List<FrameCallback>.from(_postFrameCallbacks);
    _postFrameCallbacks.clear();
    for (final FrameCallback callback in localPostFrameCallbacks)
      _invokeFrameCallback(callback, _currentFrameTimeStamp!);
    // 处理addPostFrameCallback添加的回调 end
  } finally {
    _schedulerPhase = SchedulerPhase.idle;
    //...
    _currentFrameTimeStamp = null;
  }
}

上面的方法会循环遍历回调队列并执行,其中还会执行通过SchedulerBinding.instance.addPostFrameCallback添加的回调。

虽然正常的流程是在树构建完成后发送window.scheduleFrame事件,然后通过window.onDrawFrame来接收下一侦开始信号,不过在首次渲染时,为了让界面更快的显示,runApp方法中也会在构建完成后第一时间调用scheduleWarmUpFrame先进行渲染(后面也会调用handleDrawFrame)。

[-> packages/flutter/lib/src/widgets/binding.dart:WidgetsBinding]

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

在应用启动的时候,_handlePersistentFrameCallback方法就会被注册到_persistentCallbacks中,上面的handleDrawFrame就会触发上面这个drawFrame方法,它做了三件事情

  1. 调用BuildOwner.buildScope,重新构建脏节点
  2. 调用super.drawFrame方法,开始渲染流程
  3. 调用buildOwner.finalizeTree,开发模式中做一些全局检查(Key重复使用)

先看看渲染流程图

渲染管线

其对应的源码也非常简洁

[-> packages/flutter/lib/src/rendering/binding.dart:RenderBinding]

@protected
void drawFrame() {
  assert(renderView != null);
  pipelineOwner.flushLayout(); // 布局
  pipelineOwner.flushCompositingBits(); // 更新所有节点,计算待绘制区域数据
  pipelineOwner.flushPaint(); // 绘制
  if (sendFramesToEngine) {
    renderView.compositeFrame(); // 将绘制数据提交到GPU线程
    pipelineOwner.flushSemantics(); // 更新语义化,给一些视力障碍人士提供UI的的语义
    _firstFrameSent = true;
  }
}

PipelineOwner就是渲染流水线俗称渲染管线,它通过持有根节点的RenderObject及所有子节点会持有它来对节点的布局绘制的控制。

layout

layout几乎是所有现代前端技术必不可少的流程,它是通过一系列复杂的计算来确定每个节点具体占据多少的位置,处理父子布局关系及显示位置的计算。

我们先看看布局的大概流程图

layout

setSize表示设置盒子的大小

flushLayout

PipelineOwner调用了flushLayout之后,会开始布局流程,在flushLayout

[-> packages/flutter/lib/src/rendering/object.dart:PipelineOwner]

void flushLayout() {
  // ...
  try {
    while (_nodesNeedingLayout.isNotEmpty) {
      // 取出需要重新布局的所有`RenderObject`节点
      final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
      _nodesNeedingLayout = <RenderObject>[];
      for (final RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
        if (node._needsLayout && node.owner == this)
          // 调用节点的_layoutWithoutResize开始布局
          node._layoutWithoutResize();
      }
    }
  } finally {
    // ...
  }
}

_nodesNeedingLayout是一个需要重新构建的列表,它会存储所有需要重新布局的节点,一般在节点构建过程中会通过markNeedsLayout将自己添加到待重新layout列表中。有一点比较特殊,在RendererBinding初始化时,也会提前将根节点RenderView添加进列表中。

[-> packages/flutter/lib/src/rendering/object.dart:RenderObject]

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

flushLayout中会存储所有需要布局的节点,然后调用每个节点的_layoutWithoutResize_layoutWithoutResize中会调用performLayout进行布局。

performLayout

performLayoutRenderObject是一个抽象方法,需要由子类实现。我们来看看在Flutter使用最多的RenderObject子类RenderProxyBox,它的mixin——RenderProxyBoxMixin中是这样实现performLayout

[-> packages/flutter/lib/src/rendering/proxy_box.dart:RenderProxyBoxMixin]

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

其逻辑很简单,如果当前节点存在孩子节点,则调用孩子节点的layout,然后将size设置为子节点的size,如果不存在子节点,则调用computeSizeForNoChild对size进行赋值。

layout、performResize

layout存在于RenderObject类中,它不参与真正的布局,所以一般也不要重写此方法。它的作用是父节点通过调用child.layout来对子节点的布局。

[-> packages/flutter/lib/src/rendering/object.dart:RenderObject]

void layout(Constraints constraints, { bool parentUsesSize = false }) {
  // ...
  RenderObject? relayoutBoundary;
  // 确定当前的relayoutBoundary,一般relayoutBoundary是自己或者祖先节点
  if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
    relayoutBoundary = this;
  } else {
    relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
  }
  if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
    // ... 
    return;
  }
  _constraints = constraints;
  if (_relayoutBoundary != null && relayoutBoundary != _relayoutBoundary) {
    visitChildren(_cleanChildRelayoutBoundary);
  }
  _relayoutBoundary = relayoutBoundary;
  // ...
  if (sizedByParent) {
    assert(() {
      _debugDoingThisResize = true;
      return true;
    }());
    try {
      performResize();
      // ...
    } catch (e, stack) {
      _debugReportException('performResize', e, stack);
    }
  }
  // ...
  try {
    performLayout();
    markNeedsSemanticsUpdate();
    assert(() {
      debugAssertDoesMeetConstraints();
      return true;
    }());
  } catch (e, stack) {
    _debugReportException('performLayout', e, stack);
  }
  // ...
  _needsLayout = false;
  markNeedsPaint();
}

为了layout的性能考虑,layout过程中会使用_relayoutBoundary来优化性能。它根据下面的四个条件满足其一即可让_relayoutBoundary等于自己:

  • parentUsesSize 为false,表示子节点的布局不会影响父节点,父节点不会根据子节点的大小来调整自身
  • sizedByParent 为true,表示如果父节点传给子节点的约束(constraints)不变,那么子节点不会重新计算盒子大小,子节点的孩子节点的布局变化也不会影响子节点的大小,如该节点始终充满父节点。
  • constraints.isTight 为true,表示约束(constraints)确定后,盒子大小就唯一确定。比如当盒子的最大高度和最小高度一样,同时最大宽度和最小宽度一样时,那么盒子大小就确定了
  • parent不是RenderObject时,parent是AbstractNode类型,所以还存在parent不是RenderObject的情况,比如SemanticsNode(语义辅助节点)

否则_relayoutBoundary会指向父节点的_relayoutBoundary。在当前类调用markNeedsLayout的时候,它会从当前向父节点遍历,直到找到节点为_relayoutBoundary时停止,并会标记所有遍历的节点为_needsLayout=true,如果当前类的_relayoutBoundary节点离自己越近越好,最好就是自己。

sizedByParent为true时,才会调用performResize,此时其大小在performResize中就确定了,在后面的performLayout方法中将不会再被修改了,这种情况下performLayout只负责布局子节点。

RenderBox

前面说过,RenderObject仅仅控制绘制流程,并没有具体定义盒子的size,size都是通过RenderBox类来定义的

RenderBox有一些比较重要的属性及方法

  • Size size: 定义盒子大小
  • BoxConstraints constraints: 盒子约束,其中保存了盒子的最大最小高度宽度限制,由父盒子传递
  • Size computeDryLayout(): 此方法在Flutter2.0中被定义,用于当sizedByParent 为true时用来计算盒子的大小,不能在它内部进行size赋值,只需要返回其计算的大小即可。无法计算盒子大小时,返回Size.zero。如果我们自定义的RenderObject类中sizedByParent=true,只需要继承实现此方法来计算布局大小.
  • bool hitTest(BoxHitTestResult result,{required Offset position}): 用于事件的命中测试,它会遍历当前及子节点,如果事件在当前节点中发生,它会将当前节点添加到命中测试结果列表中。

确定位置及parentData

之前说layout阶段会确定盒子大小和位置,那么位置是如何保存的呢,答案就在这个parentData里。

在盒子初始化时,父节点会调用setupParentData来初始化子节点的parentData

[-> packages/flutter/lib/src/rendering/object.dart:RenderObject]

void setupParentData(covariant RenderObject child) {
  if (child.parentData is! ParentData)
    child.parentData = ParentData();
}

ParentData只是一个简单的空方法,一般需要继承它来定义自己的信息,比如我们最常用的BoxParentData,它的作用就是当我们子节点只有一个的时候,存储子节点的位置信息

[-> packages/flutter/lib/src/rendering/box.dart:BoxParentData]

class BoxParentData extends ParentData {
  Offset offset = Offset.zero;

  @override
  String toString() => 'offset=$offset';
}

我用一个我们经常使用的Center组件的例子来看看是如何确定位置的。

Center组件是继承的Align组件,Align组件是通过RenderPositionedBox类来创建RenderObject

class Center extends Align {
  const Center({ Key? key, double? widthFactor, double? heightFactor, Widget? child })
    : super(key: key, widthFactor: widthFactor, heightFactor: heightFactor, child: child);
}
//...
class Align extends SingleChildRenderObjectWidget {
  const Align({
    Key? key,
    this.alignment = Alignment.center,
    this.widthFactor,
    this.heightFactor,
    Widget? child,
  });
  // ...
  @override
  RenderPositionedBox createRenderObject(BuildContext context) {
    return RenderPositionedBox(
      alignment: alignment,
      widthFactor: widthFactor,
      heightFactor: heightFactor,
      textDirection: Directionality.maybeOf(context),
    );
  }
  // ...
}

Align的构造函数中alignment属性默认为Alignment.center,我们看看RenderPositionedBox是如何确定大小及位置的

[-> packages/flutter/lib/src/rendering/shifted_box.dart:RenderPositionedBox]

@override
void performLayout() {
  final BoxConstraints constraints = this.constraints;
  // _widthFactor表示容器大小是子盒子的倍数,如果_widthFactor不为空或者 盒子的最大长度为最长时为true(当constraints.maxWidth == double.infinity且_widthFactor为空时,盒子宽度为子盒子实际宽度)
  final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
  final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;
  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();
  } else {
    size = constraints.constrain(Size(shrinkWrapWidth ? 0.0 : double.infinity,
                                      shrinkWrapHeight ? 0.0 : double.infinity));
  }
}

我们看到在它performLayout阶段结束后,会调用alignChild来设置子盒子的位置

[-> packages/flutter/lib/src/rendering/shifted_box.dart:RenderAligningShiftedBox]

@protected
void alignChild() {
  _resolve();
  // ...
  final BoxParentData childParentData = child!.parentData! as BoxParentData;
  childParentData.offset = _resolvedAlignment!.alongOffset(size - child!.size as Offset);
}

在这里,它先拿到子节点的parentData,然后对其的offset赋值,size-child.size表示当前盒子大小减去子盒子的大小,此时只需要将Size的长宽除以2,就可以知道子盒子相对于父盒子的Offset,然后将这个Offset赋值给子节点的parentData。现在,子节点的parentData中存储了自己相对于父节点的相对位置信息了。

总结

这一篇讲了Flutter绘制流程的启动及布局过程,handleDrawFrame会启动绘制流程,drawFrame方法中使用PipelineOwner来进行布局、合成、绘制、提交等流程,performLayout是每个节点都会调用的方法,我们一般在它下面进行设置盒子大小(size)及调用子节点的layout方法,layout方法也会调用performLayout。布局是比较复杂的,子节点会影响父节点,父节点的大小又会影响子节点,所以其中引入了sizedByParentparentUsesSize等属性,还引入了_relayoutBoundary来优化布局的性能。然后通过parentData进行存储节点位置信息,给绘制时使用。

这一步,我们的layout过程已经完成,接下来就可以绘制了。绘制过程又做了哪些事情,又有哪些优化呢,后面我会持续更新。