可能说起 Flutter 绘制,大家第一反应就是用 CustomPaint
组件,自定义 CustomPainter
对象来画。Flutter 中所有可以看得到的组件,比如 Text、Image、Switch、Slider 等等,追其根源都是画出来
的,但通过查看源码可以发现,Flutter 中绝大多数组件并不是使用 CustomPaint
组件来画的,其实 CustomPaint
组件是对框架底层绘制的一层封装。这个系列便是对 Flutter 绘制的探索,通过测试
、调试
及源码分析
来给出一些在绘制时被忽略
或从未知晓
的东西,而有些要点如果被忽略,就很可能出现问题。
我们知道完成动画需求可以使用 AnimationController
,它是会在每 16.6 ms
左右出发一次回调。每次回调都会将其持有的数字从 0~1 均匀变化。可以通过各种 Tween 实现进行插值,通过 Curve 设定动画曲线,来调节变化。 对于动画这种,触发频率很高的绘制,不建议使用外层的 State#setState
或 局部组件刷新
。 这点在 Flutter 绘制探索 1 | CustomPainter 正确刷新姿势 一文中,已经说得很清楚,Listenable
对象可以用来通知画布重绘,而不需要任何的 element
重建。本文就来在之前几篇的基础上,看一下使用 repaint 触发刷新的原理。之前一直围绕着 CustomPainter 来探索的,本文会对 CustomPaint
组件的各属性进行分析。
测试效果如上图,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),
),
),
);
}
}
复制代码
唯一一点特殊的是,这里将 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;
}
}
复制代码
CustomPainter 是一个抽象类,其持有一个 Listenable
类型的 _repaint
对象,该对象前面加了 _
,并且没有想外界提供 get
、set
方法,就说明该对象无法直接由外界设置或获取。可以看到唯一设置的方式就是过CustomPainter 的构造函数
。 这也是为什么子类只能在 super
中设置的原因。
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);
// 略...
}
复制代码
在 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();
}
在 Flutter 绘制探索 2 | 全面分析 CustomPainter 相关类 中说过,RenderObjectWidget
一族的组件,会在 RenderObjectElement#mount
中创建 RenderObject
。如下调试中,在 RenderCustomPaint#attach
前添加断点,可以看到,在创建完 RenderObject
之后,便会通过 attachRenderObject
将新创建的渲染对象
关联到 渲染树
中。RenderObject#attach
就是在这个过程中被调用的。
首先我们要认清 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 |
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;
}
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);
}
}
这两个参数估计很少人知道,它们都是布尔值,默认为 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;
}
可能你在使用 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 --
~ END ~