零:前言
1. 系列引言
可能说起 Flutter 绘制,大家第一反应就是用 CustomPaint 组件,自定义 CustomPainter 对象来画。Flutter 中所有可以看得到的组件,比如 Text、Image、Switch、Slider 等等,追其根源都是画出来的,但通过查看源码可以发现,Flutter 中绝大多数组件并不是使用 CustomPaint 组件来画的,其实 CustomPaint 组件是对框架底层绘制的一层封装。这个系列便是对 Flutter 绘制的探索,通过测试、调试及源码分析来给出一些在绘制时被忽略或从未知晓的东西,而有些要点如果被忽略,就很可能出现问题。
- Flutter 绘制探索 1 | CustomPainter 正确刷新姿势
- Flutter 绘制探索 2 | 全面分析 CustomPainter 相关类
- Flutter 绘制探索 3 | 深入分析 CustomPainter 类
- Flutter 绘制探索 4 | 深入分析 setState 重建和更新
- Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary
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 对象,该对象前面加了 _ ,并且没有想外界提供 get 、set 方法,就说明该对象无法直接由外界设置或获取。可以看到唯一设置的方式就是过CustomPainter 的构造函数。 这也是为什么子类只能在 super 中设置的原因。
2. CustomPainter#_repaint 添加、移除监听的途径
既然 _repaint 对象没有向外界暴露,那么该对象是如何起作用的呢?CustomPainter 类自身继承了 Listenable ,并重写了 addListener 和 removeListener。也就是李代桃僵,_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#addListener 将 markNeedsPaint 作为监听通知触发的方法。在 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 | 尺寸 | Size | Size.zreo |
| isComplex | 是否非常复杂,来开启缓存 | bool | false |
| willChange | 缓存是否应该被告知内容可能在下一帧改变 | bool | false |
| 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 进行了哪些处理。首先 size 是 CustomPaint 的成员,默认为 Size(0,0) ;
在创建 RenderCustomPaint 对象时,size 被作为 preferredSize 入参,初始化 RenderCustomPaint 中的 _preferredSize 成员。
如下,在画板回调 paint 方法是,回调的是 size 对象,这个 size 是 RenderBox 的成员。RenderCustomPaint 是 RenderBox 的子类,故可用之。在 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 里使用 CustomPaint ,paint 中回调的 size 为 Size(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) 赋值的。Scaffold 的 body 属性的约束为 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-------
这是当 child 为 null 时,如下 加了 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 ~