绘制相关实现在渲染对象RenderObject中,RenderObject中和绘制相关的主要属性有:
- layer
- isRepaintBoundary(bool类型)
- needsCompositing(bool类型)
绘制边界概念:将isRepaintBoundary属性值为true的RenderObject节点称为绘制边界节点。
RepaintBoundary
Flutter自带的一个RepaintBounary组件,它的功能就是向组件树中插入一个绘制边界节点。
组件树绘制流程(非完整,忽略层合成)
Flutter 第一次绘制时,会从上到下开始递归的绘制子节点,每当遇到一个边界节点,则判断如果该边界节点的layer属性为空(类型ContainerLayer),就会创建一个新的OffsetLayer并赋值给它;如果不为空,则直接使用它。然后会将边界节点的layer传递给子节点,接下来有两种情况:
- 如果子节点是非边界节点,且需要绘制,则会在第一次绘制时:
- 创建一个Canvas对象和一个PictureLayer,然后将它们绑定,后续调用Canvas绘制都会落到和其绑定的PictureLayer上。
- 接着将这个PictureLayer加入到边界节点的layer中。
- 如果不是第一次绘制,则复用已有的PictureLayer和Canvas对象。
- 如果子节点是边界节点,则对子节点递归上述过程。当子树的递归完成后,就要将子节点的layer添加到父级layer中。
整个流程执行完成后就生成了一颗Layer树。如下图,左边是widget树,右边是最终生成的Layer树。
- RenderView是Flutter应用的根节点,绘制会从它开始,因为它是一个绘制边界节点,在第一次绘制时,会为它创建一个OffsetLayer,记为OffsetLayer1,接下来OffseLayer1会传递给Row。
- 由于Row是一个容器类组件,且不需要绘制自身,那么接下来它会绘制自己的子Widget,它有两个子Widget,先绘制第一个Column1,将OffsetLayer1传给Column1,而Column1也不需要绘制自身,那么它又会将OffsetLayer1传递给第一个子节点Text1.
- Text需要绘制文本,它会使用OffsetLayer1进行绘制,由于OffsetLayer1是第一次绘制,所以会新建一个PictureLayer1和一个Canvas1,然后将Canvas1和PictureLayer1绑定,接下来文本内容通过Canvas1对象绘制,Text1绘制完成后,Column1又会将OffsetLayer1传给Text2.
- Text2也需要使用OffsetLayer1绘制文本,但是此时OffsetLayer1已经不是第一次绘制,所以会复用之前的Canvas1和PictureLayer1,调用Canvas1来绘制文本。
- Column1的子节点绘制完成后,PictureLayer1上承载的是Text1和Text2的绘制产物。
- 接下来Row完成了Column1的绘制后,开始绘制第二个子节点RepaintBoundary,Row会将OffsetLayer1传递给RepaintBoundary,由于它是一个绘制边界节点,且是第一次绘制,则会为它创建一个OffsetLayer2,接下来RepaintBoundary会将OffsetLayer2传递给Column2,和Column1不同的是,Column2会使用OffsetLayer2而不是OffsetLayer1去绘制Text3和Text4.
- 当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正是完成这个过程,当第一个节点调用了它时:
- 会从当前节点一直往父级查找,直到找到一个绘制边界点时终止查找,然后会将该绘制边界点添加到PiplineOwner的_nodesNeedingPaint列表中(保存需要重绘的绘制边界点)。
- 在查找过程中,会将自己到绘制边界节点路径上所有节点的_needsPaint属性置为true,表示需要重新绘制。
- 请求新的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流程
- 遍历需要绘制的节点列表,然后逐个开始绘制。
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);
}
}
需要注意:
- 绘制子节点时,如果遇到边界节点且当其不需要重绘_needsPaint为false时,会直接复用该边界节点的layer,而无需重绘!这就是边界节点能跨frame的原理。
- 因为边界节点的layer类型是ContainerLayer,所以可以给它添加子节点。
- 注意是将当前边界节点的layer添加到父边界节点,而不是父节点。
创建新的PictureLayer
对上图进行修改:
因为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树如下图:
总结:父节点在绘制子节点时,如果子节点是绘制边界节点,则在绘制完子节点后会生成一个新的PictureLayer,后续其他的子节点会在新的PictureLayer上绘制。
为什么不直接复用PictureLayer1?直接复用似乎没有啥问题,但是当用到Stack组件时会有问题。先看图:
左边是一个Stack布局,右边是对应的Layer树结构。Stack布局中会根据其子组件加入的顺序进行层叠绘制,最先加入的子组件在最底层,最后加入的在最上层。假如绘制child3时复用了PictureLayer1,则会导致Child3被Child2遮挡,这显然不符合预期,但是如果新建一个PictureLayer再添加到OffsetLayer最后面,则可以获得期望的结果。
再假如,child2的父节点不是RepaintBoundary,那么是否意味着child3和child1就可以共享同一个Picture Layer?
答案是否定的。如果child的父组件改为一个自定义的组件,在这个自定义的组件中希望对子节点在渲染时进行一些变化,为了实现这个功能,创建一个新的TransformLayer并指定变换规则,然后把它传递给child2,child2绘制完成后,需要将transFormLayer添加到Layer树中(不添加到Layer树中是不会显示的),则组件树和最终的Layer树结构如图:
可以发现这种情况和上面使用RepaintBoundary情况是一样的,child3仍然不应该复用PictureLayer1。
总结规律:只要一个组件需要往Layer树中添加新的Layer,那么久必须也要结束掉当前PictureLayer的绘制。这也是为什么PaintingContext中需要往Layer树中添加新Layer的方法(比如pushLayer、addLayer中都有如下两行代码):
stopRecordingIfNeeded(); //先结束当前 PictureLayer 的绘制
appendLayer(layer);// 再添加到 layer树
这是向layer树中添加Layer的标准操作。
综上,Layer树的最终结构大致如下图:
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,渲染出来。