likes
comments
collection
share

Flutter-CustomPaint与Canvas

作者站长头像
站长
· 阅读数 10

对于一些复杂或不规则的UI,无法通过组合其他组件的方式来实现,就需要自己绘制UI。几乎所有的UI系统都会提供一个自绘UI的接口,比如iOS的CoreGraphics,Flutter 中提供一块2D画布Canvas,Canvas内部封装了一些基本的绘制API,开发者可以通过Canvas绘制各种自定义图形,在Flutter中提供了yigeCunstomPaint组件,它可以结合画笔CustomPainter来实现自定义图形绘制。

CustomPaint

CustomPaint({
  Key key,
  this.painter, 
  this.foregroundPainter,
  this.size = Size.zero, 
  this.isComplex = false, 
  this.willChange = false, 
  Widget child, //子节点,可以为空
})
  • painter:背景画笔,显示在子节点后面;
  • foregroundPainter: 前景绘笔,会显示在子节点的前面
  • size:当child为null时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child,但是想指定画布为特定大小,可以使用SizeBox包裹CustomPainter实现。
  • isComplex:是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。
  • willChange:和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。

绘制时需要提供前景或背景画笔,两者也可以同时提供。画笔需要继承CustomPainter类,在画笔类中实现真正的绘制逻辑。

绘制边界RepaintBoundary

如果CustomPainter有子节点,为了避免子节点不必要的重绘并提高性能,通常情况下都会将子节点包裹在RepaintBoundary组件中,这样会在绘制时就会创建一个新的绘制层(Layer),其子组件将在新的Layer上绘制,而父组件将在原来Layer上绘制,也就是说RepaintBoundary子组件的绘制将独立于父组件的绘制,RepaintBoundary会隔离其子节点和CustomPaint本身的绘制边界。

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

CustomPainter与Canvas

CustomPainter中定义了一个虚函数paint

void paint(Canvas canvas, Size size);

paint有两个参数:

  • Canvas:一个画布,包括各种绘制方法: | API 名称 | 功能 | | -- | -- | | drawLine | 画线 | | drawPoint | 画点 | | drawPath | 画路径 | | drawImage | 画图像 | | drawRect | 画矩形 | | drawCircle | 画圆 | | drawOval | 画椭圆 | | drawArc | 画弧 |

  • Size:当前绘制区域大小

画笔Paint

画笔主要是提供各种属性:如颜色、粗细、样式等。

var paint = Paint() //创建一个画笔并配置其属性
  ..isAntiAlias = true //是否抗锯齿
  ..style = PaintingStyle.fill //画笔样式:填充
  ..color=Color(0x77cdb175);//画笔颜色

示例:

  1. 绘制棋盘、棋子
class SSLPainter extends CustomPainter{
  @override
  // TODO: implement painter
  void paint(Canvas canvas, Size size) {
    var rect = Offset.zero & size;
    drawChessboard(canvas, rect);

  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    // TODO: implement shouldRepaint
    return false;
  }
  //绘制棋盘
  void drawChessboard(Canvas canvas, Rect rect){
    var paint = Paint()
        ..isAntiAlias = true
        ..style = PaintingStyle.fill
        ..color = const 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);
    }
  }

}

class SSLForegroundPainter extends CustomPainter{
  SSLForegroundPainter({required this.points});
  Set<Offset> points;
  @override
  void paint(Canvas canvas, Size size) {
    // TODO: implement paint
    var rect = Offset.zero & size;
    drawPieces(canvas, rect);
  }
  void drawPieces(Canvas canvas, Rect rect){
    double width = rect.width / 15;
    double height = rect.height / 15;
    if (points.isNotEmpty){
      var paint = Paint();
      for (int i = 0; i < points.length; i ++){
        Offset point = points.elementAt(i);
        int x = point.dx ~/ width;
        double xC = point.dx % width;
        if (xC >= width/2){
          x += 1;
        }
        int y = point.dy ~/ height;
        double yC = point.dy % height;
        if (yC >= height/2){
          y += 1;
        }
        if (i%2 == 0){
          //画黑子画笔
            paint.style = PaintingStyle.fill;
            paint.color = Colors.black;
        }else{
          //绘制白子
          paint.color = Colors.white;
        }
        // debugPrint("ssl touch pieces $x, $y, $width,$height\n");
        canvas.drawCircle(Offset(x*width , y*height ), min(width/2.0, height/2.0), paint);
      }
    }
  }
  @override
  bool shouldRepaint(covariant SSLForegroundPainter oldDelegate) {
    // TODO: implement shouldRepaint
      return true;
  }
}

绘制性能

绘制是比较昂贵的操作,所以在实现自绘控件时应该考虑到性能开销。

  • 尽可能的利用好shouldRepaint返回值,在UI树重新build时,空间在绘制前都会调用该方法以确认是否必要重绘;假如绘制的UI不依赖外部状态,即外部状态的改变不会影响到自绘的UI外观,那么应该返回false;如果依赖外部状态,那么应该在shouldRepaint中判断依赖的状态是否改变,如果改变则应该返回true来重绘,反之则应该返回false不需要重绘。
  • 绘制尽可能多的分层:在绘制五子棋示例中,将棋盘和棋子分开绘制。因为棋盘只需要绘制一次,但是棋子可能绘制多次,如果放一起,每次都重新绘制,这是没必要的。优化时可以将棋盘单独抽为一个组件,并设置shouldRepaint为false,然后将棋盘组件作为背景,然后将棋子放到另外一个组件中,每次落子只需要绘制棋子即可。减少不必要绘制。

防止意外重绘

在上例中添加一个Button,点击后不做任何操作:

class SSLCustomPaintRouteTest extends StatelessWidget{
  const SSLCustomPaintRouteTest({Key? key}):super(key: key);
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Scaffold(
      appBar: AppBar(
        title: const Text("SSL Custom Paint"),
      ),
      body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              CustomPaint(
                size: const Size(300, 300),
                painter: SSLPainter(),
              ),
              ElevatedButton(onPressed: (){

              }, child: const Text("Refresh")
              ),
            ],
          )
      ),
    );
  }
}

发现控制台输出了很多“ssl painter paint”,也就是点击按钮时发生了多次重绘。但是在自定义CustomPainter时shouldRepaint返回的是false,点击刷新按钮按道理讲应该是不触发重绘的。这个后面再研究,目前简单认为按钮和CustomPaint是同一个画布,点击按钮会执行水波纹动画,水波纹动画执行过程中画布会不停的刷新,导致了CustomPaint不停的重绘。解决方案是给CustomPainter或者按钮任意一个添加RepaintBoundary父组件即可,考虑到实际场景中,可能会有多个组件,所以建议优先考虑给CustomPainter加,这样可以把CustomPaint隔离开来:

class SSLCustomPaintRouteTestState extends State<SSLCustomPaintRouteTest>{

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Scaffold(
      appBar: AppBar(
        title: const Text("SSL Custom Paint"),
      ),
      body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Stack(
                children: [
                  CustomPaint(
                    size: const Size(300, 300),
                    painter: SSLPainter(),
                  ),
                  const RepaintBoundary(
                      child: SSLCustomChessRoute(),
                  ),
                ],
              ),
              ElevatedButton(onPressed: (){

              }, child: const Text("Refresh")
              ),
            ],
          )
      ),
    );
  }
}

优化代码逻辑后,可实现正常的落子,且只会绘制落子部分。