6.学习 Flutter - RenderObject 绘制过程

309 阅读15分钟

通过一系列文章记录一下学习 Flutter 的过程,总结一下相关知识点。

  1. 学习Flutter -- 框架总览
  2. 学习Flutter -- 启动过程做了什么
  3. 学习Flutter -- Widget 的组成
  4. 学习Flutter -- Element 的作用
  5. 学习Flutter -- RenderObject 布局过程
  6. 学习Flutter -- RenderObject 绘制过程

概述

本篇将会介绍一下 Flutter 渲染流水线的最后一步:绘制(paint),Flutter 会遍历 RenderObject 的子树来逐一绘制,最终呈现到屏幕上面的其实是由不同的图层(layers)组合(composite)而成的。并且这些图层也是以树形结构组织起来的,也就是Flutter 中的第四棵树:layer tree。

下面是 Flutter 渲染机制的示意图:

绿色背景部分

UI 线程,就是 Flutter 渲染流水线运行的地方,通过 GPU 的 Vsync 信号驱动,渲染完成后会输出一个 layer tree,之后会被送入 engine。

蓝色背景部分

GPU 线程,engine 将 layer tree 在 GPU 线程进行合成(composite),然后由 Skia 渲染引擎渲染后交给 GPU 显示。


接下来,我们将针对渲染机制中的部分流程详细展开介绍。为方便理解,先介绍几个常用的类及其概念。

基本概念

RepaintBoundary (绘制边界)

RepaintBoundary 是Flutter 自带的一个组件,作用就是向组件树中插入一个绘制边界节点。

什么是绘制边界节点?

将 isRepaintBoundary 属性值为 true 的 RenderObject 节点称为绘制边界节点

绘制边界节点(后续简称:边界节点),在重绘时,使 RenderObject 可独立于父结点进行绘制,切断了绘制请求向父结点传播,目的还是为了优化性能,避免不必要的重绘。

Layer

源码:

abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
    
  @override
  ContainerLayer get parent => super.parent;

  Layer get previousSibling => _previousSibling;
  Layer _previousSibling;
      
  Layer get nextSibling => _nextSibling;
  Layer _nextSibling;
  //...
}

Layer ,是 Flutter 中的图层,继承自 AbstractNode,表明它也是个树形结构,是构成 layer tree 的节点。其中 parent 指向父结点,previousSibling 和 nextSibling 表示同一图层的前一个和后一个兄弟节点,图层的孩子节点们是用双向链表的结构存储的。

Layer 的种类

具体的 Layer 可分为两大类:

  • 容器类 Layer (ContainerLayer)

    用于管理一组 Layers,是唯一可以拥有 child layer 的 Layer。

    它的作用和具体使用场景是什么呢?

    1. Flutter 通过容器 Layer 生成 layer tree

      父 Layer 可以包含多个子 Layer,子 Layer又可以包含多个子 Layer。

    2. 可以对多个 Layer 整体应用一些变换效果

      容器 Layer 可以对其子 Layer 整体做一些变化效果,如裁剪效果(ClipRectLayer)、矩阵变换(TransformLayer)、透明变换(OpacityLayer)等等。

  • 绘制类 Layer

    没有 child layer,在 layer tree 中作为叶子节点,是真正用于承载渲染结果的 layer。如PictureLayer 承载图片的渲染结果,TextureLayer 承载纹理的渲染结果。

Canvas

Canvas 类,封装了 Flutter Skia 的各种绘制指令,比如:drawLine、drawCircle、drawRect 等绘制相关的基础接口,RenderObject 正是通过 Canvas 中的这些基础接口完成绘制任务的。

Canvas 是 Framework(Dart) 层与 Engine(C++) 层的桥接,真正的功能在 Engine 层实现。

源码:

 Canvas(PictureRecorder recorder, [ Rect? cullRect ]) : assert(recorder != null) 

如上,在 Canvas 初始化时,需要传入 PictureRecorder 对象,用于记录所有“绘制操作指令”。

除了正常的绘制操作(draw),Canvas 还支持矩阵变换(transform matrix)、区域裁剪(clip),下面列举了部分方法:

void scale(double sx, [double sy]);
void rotate(double radians) native;
void transform(Float64List matrix4);

void clipRect(Rect rect, { ClipOp clipOp = ClipOp.intersect, bool doAntiAlias = true });
void clipPath(Path path, {bool doAntiAlias = true});

void drawColor(Color color, BlendMode blendMode);
void drawLine(Offset p1, Offset p2, Paint paint);
void drawRect(Rect rect, Paint paint);

PictureRecorder

用于记录 Canvas 上执行的所有 “绘制操作指令”,最终通过 endRecording 方法生成绘制结果 Picture。

Picture

是一系列“绘制操作指令”的集合,通过 toImage 方法将记录的所有操作进行光栅化处理,最后生成 Image 对象。

Scene

是一系列 Picture、Texture 合成的结果。在渲染流水线中,经过 build、layout、paint等操作最终生成 Scene。通过 window.render 将 Scene 送入Engine层,最后经过GPU 光栅化后显示到屏幕上。

举个🌰

下面通过一个例子来看一下这些类是如何使用的。

void main() {
  // 初始化 Canvas,并传入指令记录器 PictureRecorder
  PictureRecorder recorder = PictureRecorder();
  Canvas canvas = Canvas(recorder);

  Paint paint= Paint();
  paint.color = Colors.yellowAccent;

  // 调用 Canvas 的绘制接口,画一个矩形
  var rect = const Rect.fromLTWH(100, 100, 300,300);
  canvas.drawRect(rect, paint);

  // 绘制结束,生成Picture
  Picture picture = recorder.endRecording();

  // 创建 SceneBuilder ,并传入 picture
  SceneBuilder sceneBuilder = SceneBuilder();
  sceneBuilder.addPicture(const Offset(0, 0), picture);
  sceneBuilder.pop();

  // 生成 Scene
  Scene scene = sceneBuilder.build();

  window.onDrawFrame = () {
    // 将 scene 送入 Engine 层进行渲染显示
    window.render(scene);
  };
  window.scheduleFrame();

}

运行之后,在屏幕上画出了一个黄色的矩形。

以上只是为了演示,通过直接操作 Canvas 的接口进行图形绘制,日常开发中并不需要直接操作这些基础的 API。

方法解析

markNeedsRepaint - (RenderObject)

RenderObject 对象(节点)是通过该方法发起重绘请求的,当一个节点需要重绘时,首先需要找到距离它最近的父级的边界节点,然后让该边界节点重绘即可,源码:

void markNeedsPaint() {
  if (_needsPaint) return;
  _needsPaint = true;
  if (isRepaintBoundary) { // 当前节点是边界节点
    	//将当前节点添加到需要重新绘制的列表中。
      owner!._nodesNeedingPaint.add(this); 
	    // 请求新的frame,该方法最终会调用scheduleFrame()
      owner!.requestVisualUpdate(); 
    
  } else if (parent is RenderObject) { // 若不是边界节点且存在父节点
    final RenderObject parent = this.parent! as RenderObject;
    // 递归调用父节点的 markNeedsPaint
    parent.markNeedsPaint(); 
  } else {
    // 如果是根节点,直接请求新的 frame 即可
    if (owner != null)
      owner!.requestVisualUpdate();
  }
}

当一个节点调用了 markNeedsPaint 后,具体流程是:

  1. 从当前节点一直递归的向父级节点查找,直到找到一个边界节点时才终止,然后将该边界节点添加到 PiplineOwner 的 _nodesNeedingPaint 列表中(后续重绘时会遍历该列表)。

  2. 在向上查找过程中,会将当前节点到边界节点整个路径上的所有节点的 _needsPaint 都标记为 true,表示需要重绘。

  3. 最后,请求新的 frame,执行重绘流程。

drawFrame - (RenderBinding)

当请求新的 frame 后,下一个 Vsync 信号到来后,就会触发 drawFrame 方法,即:渲染流水线。

void drawFrame() {
  pipelineOwner.flushLayout(); //1.布局
  pipelineOwner.flushCompositingBits(); //图层合成相关
  pipelineOwner.flushPaint(); //绘制相关
  renderView.compositeFrame(); // this sends the bits to the GPU
  //...省略
}

其中重绘的流程主要是从 flushPaint 方法开始,我们先重点看下这个方法。

flushPaint - (PipelineOwner)

从上文知,当某个 RenderObject 节点需要重绘时,会调用 markNeedsPaint 方法,将其最近的父级边界节点加入到 PiplineOwner 的 _nodesNeedingPaint 列表中。此方法,正是对之前通过markNeedsPaint  方法加入的那些节点进行重绘。

  void flushPaint() {
      final List<RenderObject> dirtyNodes = _nodesNeedingPaint;
      //深度优先排序,从下到上遍历
      for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) {
          if (node._layer.attached) {
            PaintingContext.repaintCompositedChild(node);
          //...   	
      }
  }
 

在处理需要重绘的节点时,首先会按节点深度排序,越深的先进行重绘。只有当 _layer.attached 为 true 时(表示已经在layer tree 上),会调用 PaintingContext.repaintCompositedChild(node)   进行绘制。

repaintCompositedChild - (PaintingContext)

这个方法比较重要,为各种重绘操作进行图层的创建,以及与绘制上下文之间的绑定。从代码中的 asset 断言可以看出,进入该方法的节点都是绘制边界节点。源码

static void repaintCompositedChild( RenderObject child, PaintingContext? childContext) {
  assert(child.isRepaintBoundary); 
  
  OffsetLayer? childLayer = child.layer;
  if (childLayer == null) {  
    final OffsetLayer layer = OffsetLayer();
    child.layer = childLayer = layer;
  } else { //(之前绘制时已经为其创建过layer了),则清空其子节点。
    childLayer.removeAllChildren();
  }
  //创建新的 PaintingContext
  paintingContext ??= PaintingContext(childLayer, child.paintBounds);
  
  //_paintWithContext 方法内部逻辑简单,主要是调用 paint 方法
   child._paintWithContext(childContext, Offset.zero);

  //结束
  childContext.stopRecordingIfNeeded();
}
  • 图层的创建

该方法会先检查 RenderObject 节点的 layer 属性,为 null 则会创建一个  OffsetLayer 给它;若图层已经存在,则清空其所有子节点。

  • PaintingContext 的创建

随后通过该 OffsetLayer 构建一个 PaintingContext 对象,即二者绑定。这意味着通过同一个paintingContext 的canvas 绘制的产物属于同一个 layer。先看下 PaintingContext 的部分源码

class PaintingContext extends ClipContext {
 
  @protected
  PaintingContext(this._containerLayer, this.estimatedBounds)

  @override
  Canvas get canvas {
    if (_canvas == null)
      _startRecording();
    return _canvas;
  }

  //创建PictureLayer和canvas
  void _startRecording() {
    _currentLayer = PictureLayer(estimatedBounds);
    _recorder = ui.PictureRecorder();
    _canvas = Canvas(_recorder);
    //将pictureLayer添加到_containerLayer(是绘制边界节点的Layer)中
    _containerLayer.append(_currentLayer);
  }
  
    void stopRecordingIfNeeded() {
    _currentLayer!.picture = _recorder!.endRecording();
    _currentLayer = null;
    _recorder = null;
    _canvas = null;
  }
  
 //... 省略

从源码可以看出,当调用 context 的 canvas 时,会创建一个 PictureLayer,并将该 layer 添加到了 _containerLayer 中(即构造函数传入的边界节点)。从 stopRecordingIfNeeded 方法可以看出,当绘制结束之后,会将 Canvas 的绘制产物保存在 PictureLayer 中,因此通过同一个 context 的 canvas 绘制的产物属于同一个 PictureLayer。

paint - (RenderObject)

RenderObject 中的 paint 是个空方法, 需要子类自己实现,用于绘制自身。不同类型的节点,绘制算法也不同。

  • 非容器组件:绘制自身,如 Image Widget (对应:RenderImage),直接通过 paintImage 进行自身相关 image 的绘制。
void paint(PaintingContext context, Offset offset) {
    paintImage(
      canvas: context.canvas,
      rect: offset & size,
      image: _image,
      ...
    );
}
  • 容器类的组件:要绘制自身和子节点。自身若不需要绘制,则只绘制子节点,如 Center Widget (对应:RenderShiftedBox),通过 paintChild 对子节点进行绘制。
  void paint(PaintingContext context, Offset offset) {
    final RenderBox? child = this.child;
    if (child != null) {
      final BoxParentData childParentData = child.parentData! as BoxParentData;
      context.paintChild(child, childParentData.offset + offset);
    }
  }

paintChild - (PaintingContext)

该方法会递归的绘制子节点,源码:

  void paintChild(RenderObject child, Offset offset) {
    if (child.isRepaintBoundary) {
       stopRecordingIfNeeded();
      _compositeChild(child, offset);
    } else if (child._wasRepaintBoundary) {
      child._layerHandle.layer = null;
      child._paintWithContext(this, offset);
    } else {
      //不是边界节点,则绘制自身
      //_paintWithContext 方法内部逻辑简单,主要是调用 paint 方法
      child._paintWithContext(this, offset);
    }
  }

  //
  void _compositeChild(RenderObject child, Offset offset) {
    if (child._needsPaint || !child._wasRepaintBoundary) {
      repaintCompositedChild(child, debugAlsoPaintedParent: true);
    } else {
      if (child._needsCompositedLayerUpdate) {
        updateLayerProperties(child);
      }

    }
     //将孩子节点的layer添加到Layer树中,
    final OffsetLayer childOffsetLayer = child._layerHandle.layer! as OffsetLayer;
     //将当前边界节点的layer添加到父边界节点的layer中.
    childOffsetLayer.offset = offset;
    appendLayer(childOffsetLayer);
  }

从源码得知,如果子节点是边界节点且需要绘制,则递归调用 repaintCompositedChild(上面分析过),然后将当前节点的 layer 添加到父边界节点的 layer 中。如果子节点不是边界节点,则调用 paint 方法进行绘制。

注意:绘制子节点时,如果字节点是边界节点但不需要绘制(即_needsPaint 为 false)时,会直接复用将该节点的 layer,无需重绘。

stopRecordingIfNeeded - (PaintingContext)

结束绘制指令的记录,该方法是在上述的 repaintCompositedChild 方法最后调用的。

void stopRecordingIfNeeded() {
  _currentLayer!.picture = _recorder!.endRecording();// 将canvas绘制产物保存在 PictureLayer中
  _currentLayer = null; 
  _recorder = null;
  _canvas = null;
}
 
//void _startRecording() {
  //...
  //_containerLayer.append(_currentLayer)
//}

在结束绘制时,会将绘制产物保存在 _currentLayer (PictureLayer) 中,然后会将所有变量都置空。

注意:此时只是将 _currentLayer 的引用置空,在最开始的 _startRecording 中已经将 _currentLayer 添加到了边界节点的 _containerLayer (即 PaintContext 初始化时候传入的边界节点的 OffsetLayer)中了。

小结

通过以上分析,将主要的方法调用通过流程图串起来,如图所示:

按照上述流程执行完毕后,最终所有边界节点的 layer 连接在一起就组成了一颗 layer tree。

第四棵树的生成 - Layer Tree

边界节点的特点

通过以上代码分析得知,绘制边界节点的主要特点有:

  • 每个边界节点在绘制时,都有一个独属于自己的 OffsetLayer (继承自 ContainerLayer),其自身及子孙节点的绘制结果都将 attach 到该 OffsetLayer 为根节点的字数上;
  • 每个边界节点在绘制时,都有一个独属于自己的 PaintingContext (及其 Canvas、PictureLayer),从而将绘制过程与父结点完全隔离开。

layer tree 绘制过程

通过以上方法的分析,layer tree 的生成过程大概是这样的:当 Flutter 第一次绘制时,会从上到下递归的绘制子节点。当遇到一个边界节点时,判断其layer 属性(ContainerLayer)是否为 null,为空的话则会创建一个 OffsetLayer 给它;不为空则直接使用。然后将边界节点的 layer 传递给子节点,当绘制子节点的逻辑是:

  • 如果子节点不是边界节点,且需要绘制
    • 当第一次绘制时:
  1. 创建一个 Canvas 和 PictureLayer 对象,后续通过 Canvas 的绘制结果都会绑定在 PictureLayer 上;
  1. 并将这个 PictureLayer 添加到边界节点的 layer 中。
    • 不是第一次绘制,则复用已有的 PictureLayer 和 Canvas 对象
  • 子节点是边界节点,则对子节点递归上述过程。当子树递归完成后,则将子节点的 layer 添加到父级 layer 中。

以上整个流程执行完成之后就生成了一颗 layer 树。下面通过一个例子来理解一下,如图:左侧是一颗 RenderObect Tree,右侧是一颗 Layer Tree。

如上图:

  • 根节点

RenderView 是根节点,从它开始绘制。同时也是边界节点,所以会为它 OffsetLayer。

  • R1 、R4、R5
    • 子节点 R1 是边界节点,会为它 OffsetLayer。同时添加到父级的边界节点中。
    • 子节点R4不是边界节点且需要绘制,此时是第一次绘制,会创建对应的 PictureLayer 来承载绘制结果。并添加到边界节点的 OffsetLayer 中。
    • 子节点R5 不是边界节点也需要绘制,但此时不是第一次绘制,因此会复用之前绘制 R4 的PictureLayer。
  • R2、R6、R8
    • 字节点 R2 的绘制会有一个新的PictureLayer 来承载绘制结果。
    • R6 是边界节点,会将它的 OffsetLayer 添加到父级的OffsetLayer 中。
    • R8 同理会创建新的 PictureLayer。
  • R3、R7
    • R3 与 R2 是兄弟节点,且都不是边界节点。但是 R2 的子节点是边界节点,所以R2 绘制完成后会将 Canvas 置空。所以绘制 R3 的时候新建 PictureLayer。
    • 同理R7 (同 R5)与 R3 绘制在同一个 PictureLayer 上。

📢:Repaint Boundary 并不是越多越好,太多反而会增加引擎的负担,只适用那些需要频繁重绘的场景(如视频),或特殊布局场景(如 Stack)。

开天辟地第一帧

上文提到,Flutter 渲染流水线的运行是通过 GPU 的 Vsync 信号驱动的,那么 Flutter 的第一帧的渲染也需要等待 Vsync 信号吗?

runApp

我们再回到 Flutter App 的入口函数 runApp 中,之前的文章 2.学习Flutter -- 启动过程做了什么 中已经介绍过启动过程每一步骤具体的作用。

void main() {
  runApp(MyApp());
}

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame(); //预热第一帧
}

这里在简要说明一下每一步的作用,从而将整个思路串起来。

第一步:根节点的创建

WidgetsFlutterBinding 混入了多个 Binding,所以在初始化过程中又调用了各个 Binding 的相关初始化方法。其中涉及到绘制相关就是 RendeBinding,

RendererBinding 的初始化

在 RendererBinding 创建了根 RenderObject 节点:renderView,并且直接对该节点调度了绘制相关操作,摘取了部分源码:

	//初始换根节点
  void initRenderView() {
    renderView = RenderView(configuration: createViewConfiguration(), window: window);
    renderView.prepareInitialFrame();
  }

	//根 RenderObject 保存在了 PipelineOwner 中,作为渲染树的根节点
  set renderView(RenderView value) {
    _pipelineOwner.rootNode = value;
  }


// RenderView 
class RenderView extends RenderObject {
  //... 
  void prepareInitialFrame() {
    scheduleInitialLayout();
    scheduleInitialPaint(_updateMatricesAndCreateNewRootLayer());
  }
  
  TransformLayer _updateMatricesAndCreateNewRootLayer() {
    _rootTransform = configuration.toMatrix();
    final TransformLayer rootLayer = TransformLayer(transform: _rootTransform);
    rootLayer.attach(this);
    return rootLayer;
  }

  void scheduleInitialPaint(ContainerLayer rootLayer) {
    _layerHandle.layer = rootLayer;
    owner!._nodesNeedingPaint.add(this);
  }

  
  //根节点是 绘制边界节点
  bool get isRepaintBoundary => true;

 //... 
}

主要流程:

  1. 调度初始化布局 scheduleInitialLayout
  2. 调度初始化绘制 scheduleInitialPaint ,将根节点加入到 PipelineOwner 的 _nodesNeedingPaint 列表中,后续进行绘制的时候就会对该列表中的节点进行绘制(上述 flushPaint 方法内)。
  3. 创建根 layer (TransformLayer),并赋值给了 renderView 的 layer。

注意:根节点也是绘制边界节点。

第二步:生成完整的 RenderObject 树

runApp 方法的第二步  scheduleAttachRootWidget 将所有子节点 RenderObject 对象的关联到根 RenderObject 上,生成了完成的 RenderObject 树。

第三步:触发渲染

这一步的就是要将准备好的 RenderObject 树,进行绘制并显示到屏幕上。

void scheduleWarmUpFrame() {
  ... 
  handleBeginFrame(null);
  handleDrawFrame();
  ...
}

正如方法名,预热第一帧,在该方法内部直接调用了 handleBeginFrame()和handleDrawFrame(),强制渲染,从而触发渲染流水线。因此,Flutter 第一帧的绘制不需要等待 Vsync 信号驱动。

以上通过绘制流程的方法分析,又反过来总结了一下 Flutter App 中根节点对第一帧的绘制调度过程。

小结

上屏显示

绘制完 layer 后,接下来就要上屏显示了。这一过程是由  compositeFrame()  方法来完成,再回到 drawFrame 中,该方法是通过根节点 renderView 来调用的,源码:

void drawFrame() {
  //...
  renderView.compositeFrame(); // this sends the bits to the GPU
  //...
}

compositeFrame

通过 renderView 的 layer 来构建 scene,将整棵layer tree 转换成 scene 对象,从这里开始就都是对图层的操作了。

void compositeFrame() {
  final ui.SceneBuilder builder = ui.SceneBuilder();
  final ui.Scene scene = layer.buildScene(builder);
  //
  _window.render(scene);
}


//构建 Scene
ui.Scene buildScene(ui.SceneBuilder builder) {
  updateSubtreeNeedsAddToScene();
  addToScene(builder); 
  final ui.Scene scene = builder.build();
  return scene;
}

最后当所有的图层处理完之后,再通过 window.render  将 scene 送入 engine 去渲染显示。

总结

  • 整个绘制过程就是对 RenderObject Tree 进行深度遍历,输出一颗 Layer Tree。
  • 重绘时,与布局过程的 markNeedsLayout 相似,当 RenderObject 需要重绘时,会通过 markNeedsPaint 方法标记 dirty 节点存在PipelineOwner 中。
  • 重绘边界 Repaint Boundary 与 布局边界 Relayout Boundary不同的是,不仅是为了重绘时的性能优化,也能通过图层的隔离实现不同的布局效果。