Flutter 绘制探索 3 | 深入分析 CustomPainter 类 | 七日打卡

2,425 阅读8分钟

零:前言

1. 系列引言

可能说起 Flutter 绘制,大家第一反应就是用 CustomPaint 组件,自定义 CustomPainter 对象来画。Flutter 中所有可以看得到的组件,比如 Text、Image、Switch、Slider 等等,追其根源都是画出来的,但通过查看源码可以发现,Flutter 中绝大多数组件并不是使用 CustomPaint 组件来画的,其实 CustomPaint 组件是对框架底层绘制的一层封装。这个系列便是对 Flutter 绘制的探索,通过测试调试源码分析来给出一些在绘制时被忽略从未知晓的东西,而有些要点如果被忽略,就很可能出现问题。


2.前情回顾

希望在观看此篇前,你已经看过前面文章的铺垫 。上回说到与 CustomPainter 关系最为密切的是 RenderCustomPaint 这个渲染对象。我们都知道,通过 CustomPainter#paint 方法可以获取到 Canvas 对象进行绘制操作,但你有么有想过,这个 Canvas 是从何而来的?CustomPainter#paint 方法又是在哪里回调的?shouldRepaint 到底是在哪里起的作用?这些都会在本文的探索中给出答案。


一、CustomPainter#paint 方法探索

1. 测试代码

为了更方便探索 CustomPainter 的内部机制,这里使用最精简的代码,摒除其余干扰信息。如下代码直接将 CustomPaint 组件传给 runApp 方法,运行效果如下:

void main() => runApp(CustomPaint(
      painter: ShapePainter(color: Colors.blue),
    ));

class ShapePainter extends CustomPainter {
  final Color color;

  ShapePainter({this.color});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()..color = color;
    canvas.drawCircle(Offset(100, 100), 50, paint);
  }

  @override
  bool shouldRepaint(covariant ShapePainter oldDelegate) {
    return oldDelegate.color != color;
  }
}

2. paint 方法的调试分析

想要进行分析,最有效的方式便是 调试,在 paint 方法添加断点,调试信息如下。左侧是程序运行到 paint 时方法栈帧情况,当前 ShapePainter.paint 方法处于栈顶,其下的方法都是在方法栈中还未执行完毕的方法,它们都在等着栈顶的方法退栈。所以可以从这里看出方法依次进栈的顺序,从而很快了解 paint 是如何一步步被调用的。


RenderCustomPaint._paintWithPainterShapePainter.paint 之下,说明 ShapePainter.paint 是在该方法里被调用的。如下所示,点击栈帧中的方法时,会进行跳转。来到 RenderCustomPaint 类中的 _paintWithPainter 方法内,ShapePainter.paint 被调用的那一行,这就是 debug 的强大之处。


通过调试可以看到方法栈的调用情况,但很多方法在一块,会让人觉得很乱,有时走着走着自己就乱了,不知道在干嘛。所以在调试中有件一个很重要的事:就是认清我是谁我在哪里我要干什么,这让你不会迷路。我们可以通过栈帧看到当前方法所处的位置;另外,任何方法调用时,都是一个对象在调用,这个对象便是 this,当我们迷路时,this 会成为指路明灯。通过下面计数器的图标,可以输入表达式和查看对象信息。查看 this 信息如下,当前对象为 RenderCustomPaint 类型,可以看到当前对象的成员信息。

这时我们知道了 ShapePainter.paint 是在 RenderCustomPaint._paintWithPainter 中被调用的,那么 _paintWithPainter 又是在哪调用的呢。同理,可以看下一个栈帧。它是在 RenderCustomPaint.paint 中被触发的。也就是说 RenderCustomPaint 作为一个 RenderObject 本应要处理绘制的任务,但是它将这个任务向外界暴露出去,由用户进行绘制处理。

而暴露给用户的抽象层便是 CustomPianter,可以看出 CustomPianter#paint 回调的出去的 Canvas 是 RenderCustomPaint#paint 方法参数的 PaintingContext 中的 canvas 对象。


3.RendererBinding.drawFrame

runApp 方法中,会执行 WidgetsFlutterBinding#scheduleWarmUpFrame 开始调度绘制帧。

每次帧的回调会触发 RendererBinding#_handlePersistentFrameCallback 。在此方法中会执行 drawFrame。至于 Flutter 框架层如何启动,初始化各个 Binding ,如何添加 _handlePersistentFrameCallback 回调的,本文就不详述了,着重在绘制的点。

---->[RendererBinding#_handlePersistentFrameCallback]----
void _handlePersistentFrameCallback(Duration timeStamp) {
  drawFrame();
  _scheduleMouseTrackerUpdate();
}

这样我们方法 RendererBinding.drawFrame ,它的作用就是绘制帧。在这里触发了PipelineOwner.flushPaint ,从而吹响了绘制的号角。


PipelineOwner 中持有 _nodesNeedingPaint 对象,它是一个 RenderObject 列表,收集需要绘制的 RenderObject。在 PipelineOwner.flushPaint 中,会对收集到需要绘制的 RenderObject 使用 PaintingContext.repaintCompositedChild 静态方法进行绘制。可以看出当前的节点是 RenderView,它的孩子是 RenderCustomPaint 这也就是当前 渲染树 的结构。RenderView 是在 Flutter 框架内部初始化的RenderObject, 它永远都是渲染树的根节点。

PipelineOwner 类中在允许绘制之前还有几个条件,1. 渲染对象的 _layer 属性非空;2. 渲染对象的 _needsPaint 属性为 true ;3.渲染对象持有的 PipelineOwner 为当前对象;4. 渲染对象的 _layer 成员的 _ower 非空。

---->[PipelineOwner#flushPaint]----
assert(node._layer != null);
if (node._needsPaint && node.owner == this) {
  if (node._layer!.attached) {
    PaintingContext.repaintCompositedChild(node);
  } else {
    node._skippedPaintingOnLayer();
  }
}

---->[AbstractNode#attached]----
 bool get attached => _owner != null;


可以回想一下上文中,RenderObject 对象的 markNeedsPaint 方法,就是在向 owner._nodesNeedingPaint 列表中添加渲染对象 。下面是 RenderObject#markNeedsPaint 去除断言后的所有代码。可以看出,自己 在被加入到owner 的待渲染列表前,会有些条件。1. _needsPaint 属性为 false。 2. isRepaintBoundary 为 true。否则就让 父节点执行 markNeedsPaint

所以从这里可以看出:当一个 RenderObject 对象执行 markNeedsPaint 时,如果自身 isRepaintBoundary 为false,会向上寻找父级,直到有 isRepaintBoundary=true 为止。然后该父级节点被加入 _nodesNeedingPaint 列表中。

---->[RenderObject#markNeedsPaint]----
void markNeedsPaint() {
    if (_needsPaint)
      return;
    _needsPaint = true;
    if (isRepaintBoundary) {
      if (owner != null) {
        owner!._nodesNeedingPaint.add(this); //<--- 自己被加入 待渲染列表
        owner!.requestVisualUpdate();
      }
    } else if (parent is RenderObject) {
      final RenderObject parent = this.parent as RenderObject;
      parent.markNeedsPaint();
    } else {
      if (owner != null)
        owner!.requestVisualUpdate();
    }
  }

repaintCompositedChildPaintingContext 的静态方法,没有复杂的逻辑,只是调用了 _repaintCompositedChild

---->[PaintingContext#repaintCompositedChild]----
static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
  assert(child._needsPaint);
  _repaintCompositedChild(
    child,
    debugAlsoPaintedParent: debugAlsoPaintedParent,
  );
}

4. 绘制上下文 PaintingContext 的诞生

_repaintCompositedChild 方法中除去断言后,所有代码如下:可以看到这里创建了 PaintingContext ,也就是 Canvas 的发源地。这里的 child 对象便是根渲染节点 RenderView。可以看出 PaintingContext 类只是用于提供绘制的上下文,最终的绘制还是由 RenderObject 自身完成。

---->[PaintingContext#_repaintCompositedChild]----
static void _repaintCompositedChild(
  RenderObject child, {
  bool debugAlsoPaintedParent = false,
  PaintingContext? childContext,
}) {
  OffsetLayer? childLayer = child._layer as OffsetLayer?;
  if (childLayer == null) {
    child._layer = childLayer = OffsetLayer();
  } else {
    childLayer.removeAllChildren();
  }
  childContext ??= PaintingContext(child._layer!, child.paintBounds);//绘制上下文的创建
  child._paintWithContext(childContext, Offset.zero); // RenderObject 绘制
  childContext.stopRecordingIfNeeded();
}

RenderObject#_paintWithContext 方法中做了很多断言的操作,其本身并没有什么复杂的逻辑,就调用了一下该类的 paint 方法,将上面传来的绘制上下文回调出去。

---->[RenderObject#_paintWithContext]----
void _paintWithContext(PaintingContext context, Offset offset) {
    if (_needsLayout)
      return;
    RenderObject? debugLastActivePaint;
    _needsPaint = false;
    try {
      paint(context, offset); // <--- 调用 paint
    } catch (e, stack) {
      _debugReportException('paint', e, stack);
    }
  }

RenderView.paint 方法中,会触发 PaintingContext.paintChild 方法。然后会触发渲染树下一节点的绘制。我们知道,下一个节点就是 RenderCustomPaint


从这里可以看出,如果 child.isRepaintBoundary 为 true 就不会触发 child 的绘制,而是使用 _compositeChild 进行合成,将 child._layer 添加到 _containerLayer 中,这样可以避免渲染对象的绘制。如果 child.isRepaintBoundary 为 false,会执行 _paintWithContext 方法进行绘制,也就是当前的情况。

---->[RenderObject#paintChild]----
void paintChild(RenderObject child, Offset offset) {
  if (child.isRepaintBoundary) {
    stopRecordingIfNeeded();
    _compositeChild(child, offset);
  } else {
    child._paintWithContext(this, offset);
  }
}

void _compositeChild(RenderObject child, Offset offset) {
  if (child._needsPaint) {
    repaintCompositedChild(child, debugAlsoPaintedParent: true);
  } else {
  }
  final OffsetLayer childOffsetLayer = child._layer as OffsetLayer;
  childOffsetLayer.offset = offset;
  appendLayer(child._layer!); // 添加到 _containerLayer 中
}

@protected
void appendLayer(Layer layer) {
  layer.remove();
  _containerLayer.append(layer);
}

这样一来,一条路就畅通了,现在可以自己回味一下从 RendererBinding.drawFrame 一路过来发生的事情。多调试调试,栈帧,会为你诉说它所经历的 故事


当前的渲染树只有 RenderViewRenderCustomPaint 两个节点。在绘制时 RenderView.paint 先入栈 , RenderCustomPaint.paint 后入栈,这说明在前面的节点会一直等待后面的节点绘制完毕,自己的绘制才算结束。现在让当栈帧依次出栈,当 pipelineOwner.flushPaint() 执行完毕,屏幕上就会出现绘制的图形。这么我们就了解了一下 CustomPainter#paint 是什么时候被调用的,以及 Canvas 对象是何时被创建的。


二、 CustomPainter#shouldRepaint 方法探索

1.源码中对 shouldRepaint 的使用

遇事不决,先看源码,源码中 20 个基于 CustomPainter 绘制的组件,我们可以从其中来看到正规的适用方式。那个简单的 _GridPaperPainter 来看,它在 shouldRepaint 中进行的处理是: 只要属性成员和旧的画板对象有所不同,就返回 true 。 如果完全一致,则返回 false。这基本上是作为画板而言,刻在 DNA 里的操作了。


2. 从源码认识 shouldRepaint

CustomPainter#shouldRepaint 在整个 Flutter 框架中只有两处使用。第一个是在 CustomPaintershouldRebuildSemantics 中,会默认调用它来进行判断。

第二个就是在 RenderCustomPaint#_didUpdatePainter 中 ,这个方法的触发,是在为 RenderCustomPaint 设置新画板 时。这里的 oldPainter 也就是之前的画板。

set painter(CustomPainter? value) {
  if (_painter == value)
    return;
  final CustomPainter? oldPainter = _painter;
  _painter = value;
  _didUpdatePainter(_painter, oldPainter);
}

我们来仔细看一下 _didUpdatePainter 这个方法,入参是新旧两个画板。[1]. 如果新画板为 null ,重新绘制来清除旧画。[2]. 如果新画板为 null 、新旧画板运行时类型不一致、shouldRepaint 返回值为 true ,这三个条件满足其一,就可以通过 markNeedsPaintRenderCustomPaint 加入重绘渲染列表。

void _didUpdatePainter(CustomPainter? newPainter, CustomPainter? oldPainter) {
  if (newPainter == null) {
    markNeedsPaint();
  } else if (oldPainter == null ||
      newPainter.runtimeType != oldPainter.runtimeType ||
      newPainter.shouldRepaint(oldPainter)) {
    markNeedsPaint();
  }
  if (attached) {
    oldPainter?.removeListener(markNeedsPaint);
    newPainter?.addListener(markNeedsPaint);
  }
  if (newPainter == null) {
    if (attached)
      markNeedsSemanticsUpdate();
  } else if (oldPainter == null ||
      newPainter.runtimeType != oldPainter.runtimeType ||
      newPainter.shouldRebuildSemantics(oldPainter)) {
    markNeedsSemanticsUpdate();
  }
}

到这里再来回答,shouldRepaint 返回 false,就一定不会重绘当前画板吗?答案以及很明显了。并非全然,一者 oldPainter == nullnewPainter.runtimeType != oldPainter.runtimeType 两个条件如果满足也是可以的。但不要忽略一个要点,这个方法只是在 set painter 时被触发。还有别的情况可能引起绘制对象重绘,比如父级渲染对象的刷新、_painter 基于监听器的刷新,这些是 shouldRepaint 无法控制的。

@override
void attach(PipelineOwner owner) {
  super.attach(owner);
  _painter?.addListener(markNeedsPaint);
  _foregroundPainter?.addListener(markNeedsPaint);
}

所以 shouldRepaint 并非是一个控制画板刷新的万金油。我们需要根据情况进一步处理,至于怎么处理,在上面我们讲到过 RenderObject 中有一个属性可以控制重绘,它就是 isRepaintBoundary。现在对于 CustomPainter 最核心的两个方法已经介绍完毕,你应该可以回答出本篇一开始的那几个问题了。在下一篇我们将进一步去探索 Flutter 绘制的奥秘,在什么情况下会触发 shouldRepaint 无法控制的刷新,我们又该如何去控制。


@张风捷特烈 2021.01.11 未允禁转
我的公众号:编程之王
联系我--邮箱:1981462002@qq.com -- 微信:zdl1994328
~ END ~