Flutter 组件树绘制流程

86 阅读11分钟

绘制相关实现在渲染对象RenderObject中,RenderObject中和绘制相关的主要属性有:

  • layer
  • isRepaintBoundary(bool类型)
  • needsCompositing(bool类型)

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

RepaintBoundary

Flutter自带的一个RepaintBounary组件,它的功能就是向组件树中插入一个绘制边界节点。

组件树绘制流程(非完整,忽略层合成)

Flutter 第一次绘制时,会从上到下开始递归的绘制子节点,每当遇到一个边界节点,则判断如果该边界节点的layer属性为空(类型ContainerLayer),就会创建一个新的OffsetLayer并赋值给它;如果不为空,则直接使用它。然后会将边界节点的layer传递给子节点,接下来有两种情况:

  1. 如果子节点是非边界节点,且需要绘制,则会在第一次绘制时:
    1. 创建一个Canvas对象和一个PictureLayer,然后将它们绑定,后续调用Canvas绘制都会落到和其绑定的PictureLayer上。
    2. 接着将这个PictureLayer加入到边界节点的layer中。
  2. 如果不是第一次绘制,则复用已有的PictureLayer和Canvas对象。
  3. 如果子节点是边界节点,则对子节点递归上述过程。当子树的递归完成后,就要将子节点的layer添加到父级layer中。

整个流程执行完成后就生成了一颗Layer树。如下图,左边是widget树,右边是最终生成的Layer树。

image.png

  1. RenderView是Flutter应用的根节点,绘制会从它开始,因为它是一个绘制边界节点,在第一次绘制时,会为它创建一个OffsetLayer,记为OffsetLayer1,接下来OffseLayer1会传递给Row。
  2. 由于Row是一个容器类组件,且不需要绘制自身,那么接下来它会绘制自己的子Widget,它有两个子Widget,先绘制第一个Column1,将OffsetLayer1传给Column1,而Column1也不需要绘制自身,那么它又会将OffsetLayer1传递给第一个子节点Text1.
  3. Text需要绘制文本,它会使用OffsetLayer1进行绘制,由于OffsetLayer1是第一次绘制,所以会新建一个PictureLayer1和一个Canvas1,然后将Canvas1和PictureLayer1绑定,接下来文本内容通过Canvas1对象绘制,Text1绘制完成后,Column1又会将OffsetLayer1传给Text2.
  4. Text2也需要使用OffsetLayer1绘制文本,但是此时OffsetLayer1已经不是第一次绘制,所以会复用之前的Canvas1和PictureLayer1,调用Canvas1来绘制文本。
  5. Column1的子节点绘制完成后,PictureLayer1上承载的是Text1和Text2的绘制产物。
  6. 接下来Row完成了Column1的绘制后,开始绘制第二个子节点RepaintBoundary,Row会将OffsetLayer1传递给RepaintBoundary,由于它是一个绘制边界节点,且是第一次绘制,则会为它创建一个OffsetLayer2,接下来RepaintBoundary会将OffsetLayer2传递给Column2,和Column1不同的是,Column2会使用OffsetLayer2而不是OffsetLayer1去绘制Text3和Text4.
  7. 当RepaintBoudary的子节点绘制完成时,要将RepaintBoundary的layer(OffsetLayer2)添加到父级Layer(OffsetLayer1)中。

至此,整棵组件树绘制完成,生成了一棵右图所示的Layer树。需要说明的是PcitureLayer1和OffsetLayer2是兄弟关系,它们都是OffsetLayer1的孩子。可以发现:同一个Layer是可以多个组件共享的,比如Text1和Text2共享PictureLayer1.
由于共享,所以当Text1发生变化时,Text2也会跟着重绘。这样似乎会消耗资源,但是需要注意的是如果Layer太多时Skia会更消耗资源,所以只能是弃芝麻保西瓜。

发起重绘

RenderObject是通过调用markNeedsRepaint来发起重绘请求的。先来梳理一下,如果发生绘制,应该怎么走流程。

首先,绘制过程存在Layer共享,所以重绘时需要重绘所有共享同一个Layer的组件。比如上面Text1变化了,Text2也会跟着重绘。可以看出,OffsetLayer1的拥有者是根节点RenderView,它同时是Text1和Text2的第一个父级绘制边界点,通用OffsetLayer2也是Text3和Text4的第一个父级绘制边界点,因此:当一个节点需要重绘时,得找到离它最近的第一个父级绘制边界点,然后让他重绘即可,而markNeedsRepaint正是完成这个过程,当第一个节点调用了它时:

  1. 会从当前节点一直往父级查找,直到找到一个绘制边界点时终止查找,然后会将该绘制边界点添加到PiplineOwner的_nodesNeedingPaint列表中(保存需要重绘的绘制边界点)。
  2. 在查找过程中,会将自己到绘制边界节点路径上所有节点的_needsPaint属性置为true,表示需要重新绘制。
  3. 请求新的frame,执行重新绘制流程。

markNeedsRepaint删减后的核心代码:

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

需要注意的是,当前版本的Flutter中是永远不会走到最后一个else分之的,因为当前版本中的根节点是一个RenderView,而该组件的isRepaintBoundary属性为true,所以如果调用renderView.markNeedsPaint()是会走到isRepaintBoundary为true的分支。

请求新的frame后,下一个frame到来时就会走drawframe流程,drawFrame中和绘制相关的涉及flushCompositingBits、flushPaint和compositeFrame三个函数,而重新绘制流程在flushPaint中,flushCompositingBits,它涉及组件树中Layer的合成。

flushPaint流程

  1. 遍历需要绘制的节点列表,然后逐个开始绘制。
final List<RenderObject> dirtyNodes = nodesNeedingPaint;
for (final RenderObject node in dirtyNodes){
  PaintingContext.repaintCompositedChild(node);
}

注意:组件树中某个节点要更新自己时会调用markNeedsRepaint方法,而该方法会从当前节点一直往上查找,直到找到一个isRepaintBoudary为ture的节点,然后会将该节点添加到nodesNeedingPaint列表中,因此,nodesNeedingPaint中的节点的isRepaintBoundary必然为true,换句话说,能被添加到nodesNeedingPaint列表中的节点都是绘制边界。继续看PaintingContext.repaintCompositedChild函数的实现:

static void repaintCompositedChild( RenderObject child, PaintingContext? childContext) {
  assert(child.isRepaintBoundary); // 断言:能走的这里,其isRepaintBoundary必定为true.
  OffsetLayer? childLayer = child.layer;
  if (childLayer == null) { //如果边界节点没有layer,则为其创建一个OffsetLayer
    final OffsetLayer layer = OffsetLayer();
    child.layer = childLayer = layer;
  } else { //如果边界节点已经有layer了(之前绘制时已经为其创建过layer了),则清空其子节点。
    childLayer.removeAllChildren();
  }
  //通过其layer构建一个paintingContext,之后layer便和childContext绑定,这意味着通过同一个
  //paintingContext的canvas绘制的产物属于同一个layer。
  paintingContext ??= PaintingContext(childLayer, child.paintBounds);
  
  //调用节点的paint方法,绘制子节点(树)
  child.paint(paintingContext, Offset.zero);
  childContext.stopRecordingIfNeeded();//这行后面解释
}

可以看到,在绘制边界节点时会首先检查其是否有layer,如果没有就会创建一个新的offsetLayer给它,随后会根据该offsetLayer构建一个PaintingContext对象(记为Context),之后子组件在获取context的Canvas对象时会创建一个PictureLayer,然后再创建一个Canvas对象和新创建的PictureLayer关联起来,意味着后续通过同一个paintingContext的canvas绘制的产物属于同一个PictureLayer。

Canvas get canvas {
 //如果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!);
}

再看下child.paint方法的实现,该方法需要节点自己实现,用于绘制自身,节点类型不同,绘制算法一般也不同,不过功能是差不多的,即:如果是容器组件,要绘制子组件和自身(容器自身可能没有绘制逻辑,比如Center组件),如果不是容器组件,则绘制自身,如Image

void paint(PaintingContext context, Offset offset) {
  // ...自身的绘制
  if(hasChild){ //如果该组件是容器组件,绘制子节点。
    context.paintChild(child, offset)
  }
  //...自身的绘制
}

接下来看contextpaintChild方法:它的主要逻辑是:如果当前节点是边界节点且需要重新绘制,则先调用上面解析过的repaintCompositedChild方法,该方法执行完毕后,会将当前节点的layer添加到父边界节点的layer中;如果当前节点不是边界节点,则调用paint方法:

//绘制孩子
void paintChild(RenderObject child, Offset offset) {
  //如果子节点是边界节点,则递归调用repaintCompositedChild
  if (child.isRepaintBoundary) {
    if (child._needsPaint) { //需要重绘时再重绘
      repaintCompositedChild(child);
    }
    //将孩子节点的layer添加到Layer树中,
    final OffsetLayer childOffsetLayer = child.layer! as OffsetLayer;
    childOffsetLayer.offset = offset;
    //将当前边界节点的layer添加到父边界节点的layer中.
    appendLayer(childOffsetLayer);
  } else {
    // 如果不是边界节点直接绘制自己
    child.paint(this, offset);
  }
}

需要注意:

  1. 绘制子节点时,如果遇到边界节点且当其不需要重绘_needsPaint为false时,会直接复用该边界节点的layer,而无需重绘!这就是边界节点能跨frame的原理。
  2. 因为边界节点的layer类型是ContainerLayer,所以可以给它添加子节点。
  3. 注意是将当前边界节点的layer添加到父边界节点,而不是父节点。

创建新的PictureLayer

对上图进行修改: image.png
因为text5是在RepaintBoundary绘制完成后才会绘制,上例中当RepaintBoundary的子节点绘制完时,将RepaintBoundary的layer(offsetLayer2)添加到父级Layer(OffsetLayer1)中后,会发生什么?看repaintCompositedChild的最后一行:

childContext.stopRecordingIfNeeded(); 

//进入里面看
void stopRecordingIfNeeded() {
  _currentLayer!.picture = _recorder!.endRecording();// 将canvas绘制产物保存在 PictureLayer中
  _currentLayer = null; 
  _recorder = null;
  _canvas = null;
}

当绘制完RepaintBoundary走到childContext.stopRecordingIfNeeded()时,childContext对应的layer是OffsetLayer1,而_currentLayer是PictureLayer1,_canvas对应的是Canvas1。实现是先将Canvas1的绘制产物保存在PictureLayer1中,然后将一些变量都置空。
接下来再绘制Text5时,要先通过context.canvas来绘制,根据canvas getter的实现源码,此时就会走到_startRecording()方法,它会重新生成一个PictureLayer和一个新的Canvas:

Canvas get canvas {
 //如果canvas为空,则是第一次获取;
 if (_canvas == null) _startRecording(); 
 return _canvas!;
}

之后,将新生成的PictureLayer和Canvas记为PictureLayer3和Canvas3,Text5的绘制会落在Picture Layer3上,最终的Layer树如下图:

image.png

总结:父节点在绘制子节点时,如果子节点是绘制边界节点,则在绘制完子节点后会生成一个新的PictureLayer,后续其他的子节点会在新的PictureLayer上绘制
为什么不直接复用PictureLayer1?直接复用似乎没有啥问题,但是当用到Stack组件时会有问题。先看图:

image.png

左边是一个Stack布局,右边是对应的Layer树结构。Stack布局中会根据其子组件加入的顺序进行层叠绘制,最先加入的子组件在最底层,最后加入的在最上层。假如绘制child3时复用了PictureLayer1,则会导致Child3被Child2遮挡,这显然不符合预期,但是如果新建一个PictureLayer再添加到OffsetLayer最后面,则可以获得期望的结果。

再假如,child2的父节点不是RepaintBoundary,那么是否意味着child3和child1就可以共享同一个Picture Layer?

答案是否定的。如果child的父组件改为一个自定义的组件,在这个自定义的组件中希望对子节点在渲染时进行一些变化,为了实现这个功能,创建一个新的TransformLayer并指定变换规则,然后把它传递给child2,child2绘制完成后,需要将transFormLayer添加到Layer树中(不添加到Layer树中是不会显示的),则组件树和最终的Layer树结构如图:

image.png

可以发现这种情况和上面使用RepaintBoundary情况是一样的,child3仍然不应该复用PictureLayer1。
总结规律:只要一个组件需要往Layer树中添加新的Layer,那么久必须也要结束掉当前PictureLayer的绘制。这也是为什么PaintingContext中需要往Layer树中添加新Layer的方法(比如pushLayer、addLayer中都有如下两行代码):

stopRecordingIfNeeded(); //先结束当前 PictureLayer 的绘制
appendLayer(layer);// 再添加到 layer树

这是向layer树中添加Layer的标准操作。
综上,Layer树的最终结构大致如下图:

image.png

compositeFrame

创建好Layer后,接下来就需要上屏展示了,这部分工作是由renderView.compositeFrame方法来完成的。实际上它的实现逻辑很简单:先通过Layer构建Scene,最后通过window.render API来渲染:

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;
}

其中最关键的一行就是调用addToScene,该方法主要的功能就是将Layer树中的每一个layer传给Skia(最终会调用native API,建议查看OffsetLayer和PictureLayer的addToScene方法),这是上屏前的最后一个准备动作,最后就是调用window.render将绘制数据发给GPU,渲染出来。