【Flutter学习笔记】10.4 CustomPaint 与 Canvas
参考资料:《Flutter实战·第二版》 10.4 CustomPaint 与 Canvas
有一些复杂或者形状不规则的UI,我们就无法通过组合组件的方式实现了。有的内容虽然可以通过图片来实现,但是一些动态交互的场景就无法实现,因此就需要自行绘制UI。几乎所有的UI系统都会提供一个自绘UI的接口,这个接口通常会提供一块2D画布Canvas,Canvas内部封装了一些基本绘制的API,我们可以通过Canvas绘制各种自定义图形。在Flutter中,提供了一个CustomPaint组件,它可以结合画笔CustomPainter来实现自定义图形绘制。
10.4.1 CustomPaint
CustomPaint的构造函数如下:
CustomPaint({
Key key,
this.painter, // 背景画笔,会显示在子节点后面
this.foregroundPainter, // 前景画笔,会显示在子节点前面
this.size = Size.zero, // 当child为null时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child但是想指定画布为特定大小,可以使用SizeBox包裹CustomPaint实现
this.isComplex = false, // 是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销
this.willChange = false, // 和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变
Widget child, //子节点,可以为空
})
1. 绘制边界 RepaintBoundary
如果CustomPaint有子节点,为了避免子节点不必要的重绘并提高性能,通常情况下都会将子节点包裹在RepaintBoundary组件中,将子节点和父节点的绘制层分离,那么子组件的绘制会独立于父组件,当子组件改变时,父组件不需要进行重构。
2. CustomPainter与Canvas
CustomPainter中提定义了一个虚函数paint():
void paint(Canvas canvas, Size size);
paint有两个参数:
- Canvas:一个画布,包括各种绘制方法:
| 方法名 | 功能 |
|---|---|
| drawLine | 画线 |
| drawPoint | 画点 |
| drawPath | 画路径 |
| drawImage | 画图像 |
| drawRect | 画矩形 |
| drawCircle | 画圆 |
| drawOval | 画椭圆 |
| drawArc | 画圆弧 |
- Size:当前画布的大小
3. 画笔Paint
Flutter提供了Paint类来实现画笔,可以配置画笔的各种属性如粗细、颜色、样式等。
10.4.2 实例:五子棋/盘
1.绘制棋盘/棋子
首先需要定义我们的CustomPainter对象,包含一个paint()函数和一个shouldRepaint()函数。其中,paint()函数先根据画布大小绘制一个矩形,棋盘和棋子都在这个矩形的范围内进行绘制。在组件build()函数中,我们会制定画布大小,并实例化定义好的CustomPainter对象:
class CustomPaintRoute extends StatelessWidget {
const CustomPaintRoute({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
),
);
}
}
class MyPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
print('paint');
var rect = Offset.zero & size;
//画棋盘
drawChessboard(canvas, rect);
//画棋子
drawPieces(canvas, rect);
}
// 返回false, 后面介绍
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
绘制棋盘时,包括背景的绘制、棋盘网格两个部分。背景设置为抗锯齿、填充型,颜色为棕黄色,线为黑色1px粗细。绘制线时,先设置偏移量为1/15的画布高度或宽度,根据偏移设置线条初始点和终点:
void drawChessboard(Canvas canvas, Rect rect) {
//棋盘背景
var paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill //填充
..color = Color(0xFFDCC48C);
canvas.drawRect(rect, paint);
//画棋盘网格
paint
..style = PaintingStyle.stroke //线
..color = Colors.black38
..strokeWidth = 1.0;
//画横线
for (int i = 0; i <= 15; ++i) {
double dy = rect.top + rect.height / 15 * i;
canvas.drawLine(Offset(rect.left, dy), Offset(rect.right, dy), paint);
}
for (int i = 0; i <= 15; ++i) {
double dx = rect.left + rect.width / 15 * i;
canvas.drawLine(Offset(dx, rect.top), Offset(dx, rect.bottom), paint);
}
}

棋子的宽高为1/15的画布大小小一点,黑子在棋盘中心的位置偏左一格,白子在棋盘中心偏右一格:
//画棋子
void drawPieces(Canvas canvas, Rect rect) {
double eWidth = rect.width / 15;
double eHeight = rect.height / 15;
//画一个黑子
var paint = Paint()
..style = PaintingStyle.fill
..color = Colors.black;
//画一个黑子
canvas.drawCircle(
Offset(rect.center.dx - eWidth / 2, rect.center.dy - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
//画一个白子
paint.color = Colors.white;
canvas.drawCircle(
Offset(rect.center.dx + eWidth / 2, rect.center.dy - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
}

2.绘制性能
采用Canvas进行绘制时应当考虑性能开销,有两点可行的建议:
- 利用
shouldRepaint,在组件树重新构建时,会调用该方法确定是否有必要重绘;如果外部状态对组件没影响,此函数要返回false,否则需要在此函数中判断依赖的状态是否改变,再设置返回值。 - 尽可能地分层绘制,上面例子中,棋盘始终不变,但是棋子随着操作会发生变化,放在一起绘制,会导致不必要的性能开销。最好将棋盘单独作为一个组件,并设置其
shouldRepaint回调值为false,然后将棋盘组件作为背景,而棋子单独放在另外的组件当中。
3.防止意外重绘
假如在上面的例子中添加一个按钮,点击之后什么也不做。我们在上面代码中,已经在CustomPainter的paint()函数中加入了提示语句,提示绘制函数执行过一次:
class CustomPaintRoute extends StatelessWidget {
const CustomPaintRoute({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
),
//添加一个刷新button
ElevatedButton(onPressed: () {}, child: Text("刷新"))
],
),
);
}
}

此时,点击按钮,会发现日志持续输出很多paint,说明发生了重绘。但是shouldRepaint,返回的是false,并且点击刷新按钮也不会触发页面重新构建。后续会学习到Flutter的绘制原理,CustomPaint的画布和按钮是同一个,点击按钮的水波动画会引起重绘。想要解决这个问题,需要给按钮或者CustomPaint其中包裹RepaintBoundary,这样可以在不同层生成新的画布:
RepaintBoundary(
child: CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
),
),


浙公网安备 33010602011771号