赞
踩
绘制相关实现在渲染对象 RenderObject 中,RenderObject 中和绘制相关的主要属性有:
Flutter 自带了一个 RepaintBoundary 组件,它的功能其实就是向组件树中插入一个绘制边界节点。
我们先讲一下Flutter绘制组件树的一般流程,注意,并非完整流程,因为我们暂时会忽略子树中需要“层合成”(Compositing)的情况,这部分我们会在后面讲到。下面是大致流程:
Flutter第一次绘制时,会从上到下开始递归的绘制子节点,每当遇到一个边界节点,则判断如果该边界节点的 layer 属性为空(类型为ContainerLayer),就会创建一个新的 OffsetLayer 并赋值给它;如果不为空,则直接使用它。然后会将边界节点的 layer 传递给子节点,接下来有两种情况:
整个流程执行完后就生成了一棵Layer树。下面我们通过一个例子来理解整个过程:图14-10 左边是 widget 树,右边是最终生成的Layer树,我们看一下生成过程:
至此,整棵组件树绘制完成,生成了一棵右图所示的 Layer 树。需要说名的是 PictureLayer1 和 OffsetLayer2 是兄弟关系,它们都是 OffsetLayer1 的孩子。通过上面的例子我们至少可以发现一点:同一个 Layer 是可以多个组件共享的,比如 Text1 和 Text2 共享 PictureLayer1。
等等,如果共享的话,会不会导致一个问题,比如 Text1 文本发生变化需要重绘时,是不是也会连带着 Text2 也必须重绘?
答案是:是!这貌似有点不合理,既然如此那为什么要共享呢?不能每一个组件都绘制在一个单独的 Layer 上吗?这样还能避免相互干扰。原因其实还是为了节省资源,Layer 太多时 Skia 会比较耗资源,所以这其实是一个trade-off。
再次强调一下,上面只是绘制的一般流程。一般情况下 Layer 树中的 ContainerLayer 和 PictureLayer 的数量和结构是和 Widget 树中的边界节点一一对应的,注意并不是和 Widget一一对应。 当然,如果 Widget 树中有子组件在绘制过程中添加了新的 Layer,那么Layer 会比边界节点数量多一些,这时就不是一一对应了。关于如何在子组件中使用Layer。
RenderObject 是通过调用 markNeedsRepaint 来发起重绘请求的,在介绍 markNeedsRepaint 具体做了什么之前,我们根据上面介绍的 Flutter绘制流程先猜一下它应该做些什么?
我们知道绘制过程存在Layer共享,所以重绘时,需要重绘所有共享同一个Layer的组件。比如上面的例子中,Text1发生了变化,那么我们除了 Text1 也要重绘 Text2;如果 Text3 发生了变化,那么也要重绘Text4;那如何实现呢?
因为Text1 和 Text2 共享的是 OffsetLayer1,而 OffsetLayer1 的拥有者是谁呢?找到它让它重绘不就行了!OK,可以很容发现 OffsetLayer1 的拥有者是根节点 RenderView,它同时也是 Text1 和 Text2的第一个父级绘制边界节点。同样的,OffsetLayer2 也正是 Text3 和 Text4 的第一个父级绘制边界节点,所以我们可以得出一个结论**:当一个节点需要重绘时,我们得找到离它最近的第一个父级绘制边界节点,然后让它重绘即可**,而markNeedsRepaint 正是完成了这个过程,当一个节点调用了它时,具体的步骤如下:
_nodesNeedingPaint
列表中(保存需要重绘的绘制边界节点)。_needsPaint
属性置为true,表示需要重新绘制。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 中,所以我们先重点看一下flushPaint的流程。
下面我们通过源码,看看具体是如何实现的。注意,flushPaint执行流程的源码还是比较多的,为了便于读者理解核心流程,笔者会将源码删减后列出关键步骤:
- final List<RenderObject> dirtyNodes = nodesNeedingPaint;
- for (final RenderObject node in dirtyNodes){
- PaintingContext.repaintCompositedChild(node);
- }
这里需要提醒一点,我们在介绍stateState流程一节说过,组件树中某个节点要更新自己时会调用markNeedsRepaint方法,而该方法会从当前节点一直往上查找,直到找到一个isRepaintBoundary为 true 的节点,然后会将该节点添加到 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)
- }
- //...自身的绘制
- }
接下来我们看一下context.paintChild方法:它的主要逻辑是:如果当前节点是边界节点且需要重新绘制,则先调用上面解析过的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就会相连起来组成一棵Layer树。
现在,我们在本节最开篇示例基础上,给 Row 添加第三个子节点 Text5,如上图,那么它的Layer 树会变成什么样的?
因为 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 的绘制会落在 PictureLayer3 上,所以最终的 Layer 树如图14-12:
我们总结一下:父节点在绘制子节点时,如果子节点是绘制边界节点,则在绘制完子节点后会生成一个新的 PictureLayer,后续其它子节点会在新的 PictureLayer 上绘制。原理我们搞清楚了,但是为什么要这么做呢?直接复用之前的 PictureLayer1 有问题吗?这个问题,笔者当时也比较疑惑,后来在用到 Stack 组件时才猛然醒悟。先说结论,答案是:在当前的示例中是不会有问题,但是在层叠布局的场景中就会有问题,下面我们看一个例子,结构图见图14-13:
左边是一个 Stack 布局,右边是对应的Layer树结构;我们知道Stack布局中会根据其子组件的加入顺序进行层叠绘制,最先加入的孩子在最底层,最后加入的孩子在最上层。可以设想一下如果绘制 Child3 时复用了 PictureLayer1,则会导致 Child3 被 Child2 遮住,这显然不符合预期,但如果新建一个 PictureLayer 在添加到 OffsetLayer 最后面,则可以获得正确的结果。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。