阅读 925

Flutter 绘制探索 6 | 深入分析 CustomPaint 组件 | 七日打卡

零:前言

1. 系列引言

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


2. CustomPainter 与可监听对象

我们知道完成动画需求可以使用 AnimationController,它是会在每 16.6 ms 左右出发一次回调。每次回调都会将其持有的数字从 0~1 均匀变化。可以通过各种 Tween 实现进行插值,通过 Curve 设定动画曲线,来调节变化。 对于动画这种,触发频率很高的绘制,不建议使用外层的 State#setState局部组件刷新。 这点在 Flutter 绘制探索 1 | CustomPainter 正确刷新姿势 一文中,已经说得很清楚,Listenable 对象可以用来通知画布重绘,而不需要任何的 element 重建。本文就来在之前几篇的基础上,看一下使用 repaint 触发刷新的原理。之前一直围绕着 CustomPainter 来探索的,本文会对 CustomPaint 组件的各属性进行分析。


一、测试案例说明

1. 组件类

测试效果如上图,AnimationController 是一个 Listenable 对象,在 HomePage 中将 AnimationController 对象传递给画板 RunningPainter。这里未做任何 setState 的操作,但画板可以进行重绘。

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin {
  AnimationController spread;
  @override
  void initState() {
    super.initState();
    spread =
        AnimationController(vsync: this, duration: Duration(milliseconds: 2000))
          ..repeat();
  }
  
  @override
  void dispose() {
    spread.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: CustomPaint(
          size: Size(120, 120),
          painter: ShapePainter(spread: spread),
        ),
      ),
    );
  }
}
复制代码

2.绘制类

唯一一点特殊的是,这里将 spread 对象传给了 super 构造 ,用于初始化 _repaint 成员。绘制操作非常简单,画个小圆,和使用动画器绘制半径逐渐变化、颜色透明度逐渐减小的圆。

class ShapePainter extends CustomPainter {
  final Animation<double> spread;

  ShapePainter({this.spread}) : super(repaint: spread);

  @override
  void paint(Canvas canvas, Size size) {
    final double smallRadius = size.width / 6;
    final double spreadFactor = 2;

    Paint paint = Paint()..color = Colors.green;
    canvas.translate(size.width / 2, size.height / 2);
    canvas.drawCircle(Offset(0, 0), smallRadius, paint);
    
    if (spread.value != 0) {
      paint..color = Colors.green.withOpacity(1 - spread.value);
      canvas.drawCircle(
          Offset(0, 0), smallRadius * (spreadFactor * spread.value), paint);
    }
  }

  @override
  bool shouldRepaint(covariant ShapePainter oldDelegate) {
    return oldDelegate.spread != spread;
  }
}
复制代码

二、探索监听 Listenable 时的回调

1.CustomPainter 与 Listenable

CustomPainter 是一个抽象类,其持有一个 Listenable 类型的 _repaint 对象,该对象前面加了 _ ,并且没有想外界提供 getset 方法,就说明该对象无法直接由外界设置或获取。可以看到唯一设置的方式就是过CustomPainter 的构造函数。 这也是为什么子类只能在 super 中设置的原因。


2. CustomPainter#_repaint 添加、移除监听的途径

既然 _repaint 对象没有向外界暴露,那么该对象是如何起作用的呢?CustomPainter 类自身继承了 Listenable ,并重写了 addListenerremoveListener。也就是李代桃僵,_repaint 被封装到类内部,由 CustomPainter 自身作为可监听对象,提供监听和移除监听的方法。

abstract class CustomPainter extends Listenable {
  const CustomPainter({ Listenable? repaint }) : _repaint = repaint;

  final Listenable? _repaint;

  // 监听
  @override
  void addListener(VoidCallback listener) => _repaint?.addListener(listener);
  
  // 移除监听
  @override
  void removeListener(VoidCallback listener) => _repaint?.removeListener(listener);
  // 略...
}
复制代码

3.CustomPainter 被监听的时机

Flutter 绘制探索 2 | 全面分析 CustomPainter 相关类 中说过 RenderCustomPaint 渲染对象会持有 CustomPainter ,并在 attach 方法中调用 _painter#addListenermarkNeedsPaint 作为监听通知触发的方法。在 detach 方法中会执行 _painter#removeListener 移除监听。

---->[RenderCustomPaint#attach]----
@override
void attach(PipelineOwner owner) {
  super.attach(owner);
  _painter?.addListener(markNeedsPaint);
  _foregroundPainter?.addListener(markNeedsPaint);
}

@override
void detach() {
  _painter?.removeListener(markNeedsPaint);
  _foregroundPainter?.removeListener(markNeedsPaint);
  super.detach();
}
复制代码

4. RenderObject#attach 时机

Flutter 绘制探索 2 | 全面分析 CustomPainter 相关类 中说过,RenderObjectWidget 一族的组件,会在 RenderObjectElement#mount 中创建 RenderObject 。如下调试中,在 RenderCustomPaint#attach 前添加断点,可以看到,在创建完 RenderObject 之后,便会通过 attachRenderObject 将新创建的渲染对象 关联到 渲染树 中。RenderObject#attach 就是在这个过程中被调用的。


三、CustomPaint 组件分析

1. 认识 CustomPaint 组件

首先我们要认清 CustomPaint 的地位,它继承自 SingleChildRenderObjectWidget 是一个 Widget,就说明它是一个配置信息,其所有的成员都是为 final 。其次它是一个 RenderObjectWidget ,就需要创建和维护 RenderObject 。如下,CustomPaint 除了 painter 还有四个成员。

属性介绍类型默认值
painter背景画板CustomPainter?null
foregroundPainter前景画板CustomPainter?null
size尺寸SizeSize.zreo
isComplex是否非常复杂,来开启缓存boolfalse
willChange缓存是否应该被告知内容可能在下一帧改变boolfalse
child子组件Widget?null

2.维护 RenderCustomPaint

CustomPaint 这个类,就是属性的搬运工,主要就是创建 RenderCustomPaint ,并在 updateRenderObject 时更新渲染对象。所以 CustomPaint 这个组件的本身并不复杂,它会在 RenderCustomPaint 实例化的时候用成员属性作为入参,这些属性最终还是被用于 RenderCustomPaint 中。

@override
RenderCustomPaint createRenderObject(BuildContext context) {
  return RenderCustomPaint(
    painter: painter,
    foregroundPainter: foregroundPainter,
    preferredSize: size,
    isComplex: isComplex,
    willChange: willChange,
  );
}

@override
void updateRenderObject(BuildContext context, RenderCustomPaint renderObject) {
  renderObject
    ..painter = painter
    ..foregroundPainter = foregroundPainter
    ..preferredSize = size
    ..isComplex = isComplex
    ..willChange = willChange;
}

@override
void didUnmountRenderObject(RenderCustomPaint renderObject) {
  renderObject
    ..painter = null
    ..foregroundPainter = null;
}
复制代码

3. CustomPaint 的 painter、foregroundPainter 和 child

CustomPaint 中有两个画板对象: painter 和 foregroundPainter ,分别用于背景和前景的绘制。由于他是 SingleChildRenderObjectWidget 的子类,所以可以包裹一个 child 组件,而 背景和前景 就是相对于孩子而言的。如下图,在 CustomPaint 中 child 是 一个图标,前景使用蓝圈,背景使用红圈,可以看到绘制时三者的层级关系。

---->[画板使用]----
CustomPaint(
  size: Size(200, 200),
  painter: ShapePainter(color: Colors.red,offset: Offset(50,50)),
  foregroundPainter: ShapePainter(color: Colors.blue),
  child: Icon(Icons.android_rounded,size: 50,color: Colors.green,),
),

class ShapePainter extends CustomPainter {
  final Color color;
  final Offset offset;

  ShapePainter({this.color, this.offset = Offset.zero});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()..color = color;
    canvas.drawCircle(offset, 20, paint);
  }

  @override
  bool shouldRepaint(covariant ShapePainter oldDelegate) {
    return oldDelegate.color != color || oldDelegate.offset != offset;
  }
}
复制代码

之前对背景画板 _painter 的介绍,应该是淋漓尽致了。 _foregroundPainter 也是类似,可以看到在 RenderCustomPaint#paint 方法中,是先画背景 _painter 、再使用 super.paint 绘制 child 、最后用 _foregroundPainter 绘制前景,这就是上面三个属性层级关系的原理。

---->[RenderCustomPaint#paint]----
@override
void paint(PaintingContext context, Offset offset) {
  if (_painter != null) {
    _paintWithPainter(context.canvas, offset, _painter!);
    _setRasterCacheHints(context);
  }
  super.paint(context, offset);
  if (_foregroundPainter != null) {
    _paintWithPainter(context.canvas, offset, _foregroundPainter!);
    _setRasterCacheHints(context);
  }
}
复制代码

4. CustomPaint 的 isComplex 和 willChange

这两个参数估计很少人知道,它们都是布尔值,默认为 false 。看一下源码文档中对它们的介绍:

  • isComplex
合成器包含一个光栅缓存,它保存层的 bitmaps,以避免在每一帧上重复渲染这些层的消耗。
如果没有设置这个标志,那么合成器将会用它自己的触发器来决定这个层是否足够复杂,
是否可以从缓存中获益。

如果 [painter] 和 [foregroundPainter] 都为 null,此标志不能设置为true,
因为在这种情况下该标志将被忽略。
复制代码
  • willChange
栅格缓存是否应该被告知这幅画是否可能在下一帧中改变。如果没有设置这个标志,那么 compositor 将会用它自己的heuristics 来决定当前层是否可能在将来被重用。

如果 [painter] 和 [foregroundPainter] 都为 null,此标志不能设置为 true,
因为在这种情况下该标志将被忽略。
复制代码

我们知道 CustomPaint 中的成员,都会在传入到 RenderCustomPaint 中进行使用。在上面的绘制之后,会调用 _setRasterCacheHints 方法来设置绘制上下文中的属性,最后属性被设置给 _currentLayer。总的来看,这两个布尔值在不设置时,框架内部都会自己处理。

---->[RenderCustomPaint#_setRasterCacheHints]----
void _setRasterCacheHints(PaintingContext context) {
  if (isComplex)
    context.setIsComplexHint();
  if (willChange)
    context.setWillChangeHint();
}

---->[PaintingContext#setIsComplexHint]----
void setIsComplexHint() {
  _currentLayer?.isComplexHint = true;
}
---->[PaintingContext#setWillChangeHint]----
void setWillChangeHint() {
  _currentLayer?.willChangeHint = true;
}
复制代码

5. CustomPaint 的 size

可能你在使用 CustomPainter#paint 方法内回调的 size 对象时,有些困惑,为什么有时候会是 Size(0,0),那么这里来一起探索一下回调的 size 进行了哪些处理。首先 sizeCustomPaint 的成员,默认为 Size(0,0)

在创建 RenderCustomPaint 对象时,size 被作为 preferredSize 入参,初始化 RenderCustomPaint 中的 _preferredSize 成员。


如下,在画板回调 paint 方法是,回调的是 size 对象,这个 sizeRenderBox 的成员。RenderCustomPaintRenderBox 的子类,故可用之。在 performResize 中,size 被赋值为 constraints.constrain(preferredSize)

---->[RenderCustomPaint#performResize]----
@override
void performResize() {
  size = constraints.constrain(preferredSize);
  markNeedsSemanticsUpdate();
}

void _paintWithPainter(Canvas canvas, Offset offset, CustomPainter painter) {
  late int debugPreviousCanvasSaveCount;
  canvas.save();
  if (offset != Offset.zero)
    canvas.translate(offset.dx, offset.dy);
  painter.paint(canvas, size); // <----
复制代码

比如,直接在 Scaffold 里使用 CustomPaintpaint 中回调的 sizeSize(0,0)

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: CustomPaint(
        painter: ShapePainter(color: Colors.red),
      ),
    );
  }
}
复制代码

调试一下,可用发现 size 由 constraints.constrain(preferredSize) 赋值的。Scaffoldbody 属性的约束为 BoxConstraints(0.0<=w<=411.4, 0.0<=h<=603.4) 。当前 preferredSize 由于未设置,默认为 Size(0,0),那接下来看一下 constrain 方法做了什么。

代码进入 BoxConstraints.constrain 方法,创建一个 Size,其中宽高入参如下:

然后会使用 clamp 函数对传入的宽根据 minWidth, maxWidth 进行计算。

那这个函数作用是什么呢?简单来说就是目标值 t ,和目标范围 [a,b] 。当 t 在 [a,b] 内,则返回 t ;当 t < a, 则返回 a ; 当 t > b ,则返回 b。可见如果不设置 size 属性,在 BoxConstraints(0.0<=w<=411.4, 0.0<=h<=603.4) 的约束下就会得到 Size(0,0) 。当指定 size 时,在约束范围内,就会使用指定的 size。

main(){
  print('--0.clamp(3, 6):-------${0.clamp(3, 6)}-------');
  print('--1.clamp(3, 6)-------${1.clamp(3, 6)}-------');
  print('--4.clamp(3, 6)-------${4.clamp(3, 6)}-------');
  print('--7.clamp(3, 6)-------${7.clamp(3, 6)}-------');
}
日志: 
--0.clamp(3, 6):-------3-------
--1.clamp(3, 6)-------3-------
--4.clamp(3, 6)-------4-------
--7.clamp(3, 6)-------6-------
复制代码

这是当 childnull 时,如下 加了 child 属性,你会发现 有尺寸了。如果不知道内部原理,你就会觉得这个 Size 太准,就会害怕使用它。但当你认识到了原理,就可以在使用时多几分底气,这就是看源码的好处,一切奇怪的行为,背后都会有其根源。

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: CustomPaint(
        painter: ShapePainter(color: Colors.red),
        child: Icon(Icons.android_rounded),
      ),
    );
  }
}
复制代码

如下代码, performResize 触发的条件,是在 chid = null 时,如果 child ! =null ,会使用孩子的size 。这就是所谓的 约束自上而下传递,尺寸自下而上设置

这样,CustomPaint 的所有属性,就已经介绍完毕,当了解完其内部原来,在使用时就会游刃有余。当遇到动态绘制和确定画板尺寸时,这些知识会让你有一个最明智的决策,而不是乱用setState刷新,或不敢用回调的 size 进行处理。


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

文章分类
Android
文章标签