【Flutter学习笔记】10.4 CustomPaint 与 Canvas

参考资料:《Flutter实战·第二版》 10.4 CustomPaint 与 Canvas


有一些复杂或者形状不规则的UI,我们就无法通过组合组件的方式实现了。有的内容虽然可以通过图片来实现,但是一些动态交互的场景就无法实现,因此就需要自行绘制UI。几乎所有的UI系统都会提供一个自绘UI的接口,这个接口通常会提供一块2D画布CanvasCanvas内部封装了一些基本绘制的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,
  );
}

image

2.绘制性能

采用Canvas进行绘制时应当考虑性能开销,有两点可行的建议:

  • 利用shouldRepaint,在组件树重新构建时,会调用该方法确定是否有必要重绘;如果外部状态对组件没影响,此函数要返回false,否则需要在此函数中判断依赖的状态是否改变,再设置返回值。
  • 尽可能地分层绘制,上面例子中,棋盘始终不变,但是棋子随着操作会发生变化,放在一起绘制,会导致不必要的性能开销。最好将棋盘单独作为一个组件,并设置其shouldRepaint回调值为false,然后将棋盘组件作为背景,而棋子单独放在另外的组件当中。

3.防止意外重绘

假如在上面的例子中添加一个按钮,点击之后什么也不做。我们在上面代码中,已经在CustomPainterpaint()函数中加入了提示语句,提示绘制函数执行过一次:

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("刷新"))
        ],
      ),
    );
  }
}

image

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

RepaintBoundary(
  child: CustomPaint(
    size: Size(300, 300), //指定画布大小
    painter: MyPainter(),
  ),
),

image

posted @ 2024-03-25 13:27  宣负极  阅读(342)  评论(0)    收藏  举报