Flutter Canvas 自定义绘制实战:从入门到实战

7 阅读4分钟

Flutter Canvas 自定义绘制实战:从入门到实战

Flutter 的 CustomPaint 提供了强大的自定义绘制能力,是实现复杂 UI 效果的基础。本文从 CustomPainter 的基本使用出发,覆盖常见绘制场景和性能优化要点。


一、CustomPaint 基本使用

1.1 最简单的绘制示例

class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      .. strokeWidth = 3
      ..style = PaintingStyle.stroke; // 描边,fill 为填充
    
    // 在画布中心画一个圆
    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      50,
      paint,
    );
  }
  
  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

// 使用
class MyPage extends StatelessWidget {
  const MyPage({super.key});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomPaint(
        painter: MyPainter(),
        size: const Size(200, 200), // 指定绘制区域大小
      ),
    );
  }
}

1.2 shouldRepaint 的重要性

shouldRepaint 决定是否需要重绘,正确实现可以大幅提升性能:

class MyPainter extends CustomPainter {
  final Color color;
  final double progress;
  
  MyPainter(this.color, this.progress);
  
  @override
  void paint(Canvas canvas, Size size) {
    // 绘制逻辑
  }
  
  @override
  bool shouldRepaint(covariant MyPainter oldDelegate) {
    // ✅ 只有颜色或进度变化时才重绘
    return oldDelegate.color != color ||
           (oldDelegate.progress - progress).abs() > 0.01;
  }
}

二、常用绘制 API

2.1 绘制基本形状

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.blue
    ..style = PaintingStyle.fill;
  
  // 画圆
  canvas.drawCircle(const Offset(100, 100), 50, paint);
  
  // 画矩形
  canvas.drawRect(
    Rect.fromLTWH(50, 50, 200, 100),
    paint,
  );
  
  // 画圆角矩形
  canvas.drawRRect(
    RRect.fromRectAndRadius(
      Rect.fromLTWH(50, 200, 200, 100),
      const Radius.circular(16),
    ),
    paint,
  );
  
  // 画椭圆
  canvas.drawOval(
    Rect.fromLTWH(50, 350, 200, 100),
    paint,
  );
  
  // 画路径
  final path = Path()
    ..moveTo(50, 500)
    ..lineTo(150, 450)
    ..lineTo(250, 550)
    ..close(); // 闭合路径
  canvas.drawPath(path, paint);
}

2.2 绘制文字

void paint(Canvas canvas, Size size) {
  const text = 'Hello, Flutter!';
  final textStyle = TextStyle(
    color: Colors.black,
    fontSize: 30,
    fontWeight: FontWeight.bold,
  );
  
  final textSpan = TextSpan(text: text, style: textStyle);
  final textPainter = TextPainter(
    text: textSpan,
    textDirection: TextDirection.ltr,
  );
  
  textPainter.layout(minWidth: 0, maxWidth: size.width);
  textPainter.paint(canvas, const Offset(50, 50));
}

2.3 绘制图片

Future<void> drawImage(Canvas canvas, Size size, ui.Image image) async {
  final paint = Paint();
  
  // 绘制完整图片
  canvas.drawImage(image, const Offset(50, 50), paint);
  
  // 绘制图片的一部分(九宫格、图片裁剪)
  final src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
  final dst = Rect.fromLTWH(50, 50, 200, 200);
  canvas.drawImageRect(image, src, dst, paint);
}

// 加载图片
Future<ui.Image> loadImage(String assetPath) async {
  final data = await rootBundle.load(assetPath);
  final bytes = data.buffer.asUint8List();
  final codec = await ui.instantiateImageCodec(bytes);
  final frame = await codec.getNextFrame();
  return frame.image;
}

三、实战:绘制手写笔记(笔记应用核心功能)

笔记应用的核心功能之一是手写输入,使用 CustomPaint 实现:

3.1 定义笔画数据模型

class Stroke {
  final List<Offset> points;
  final Color color;
  final double strokeWidth;
  
  const Stroke(this.points, this.color, this.strokeWidth);
}

class NotebookPainter extends CustomPainter {
  final List<Stroke> strokes;
  final Stroke? currentStroke; // 当前正在绘制的笔画
  
  NotebookPainter(this.strokes, this.currentStroke);
  
  @override
  void paint(Canvas canvas, Size size) {
    // 绘制已完成的笔画
    for (final stroke in strokes) {
      _drawStroke(canvas, stroke);
    }
    
    // 绘制当前笔画
    if (currentStroke != null) {
      _drawStroke(canvas, currentStroke!);
    }
  }
  
  void _drawStroke(Canvas canvas, Stroke stroke) {
    if (stroke.points.length < 2) return;
    
    final paint = Paint()
      ..color = stroke.color
      ..strokeWidth = stroke.strokeWidth
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..style = PaintingStyle.stroke;
    
    final path = Path();
    path.moveTo(stroke.points.first.dx, stroke.points.first.dy);
    
    for (int i = 1; i < stroke.points.length; i++) {
      path.lineTo(stroke.points[i].dx, stroke.points[i].dy);
    }
    
    canvas.drawPath(path, paint);
  }
  
  @override
  bool shouldRepaint(covariant NotebookPainter oldDelegate) {
    return true; // 手写场景,每次都需要重绘
  }
}

3.2 GestureDetector 采集笔迹

class NotebookPage extends StatefulWidget {
  const NotebookPage({super.key});
  
  @override
  State<NotebookPage> createState() => _NotebookPageState();
}

class _NotebookPageState extends State<NotebookPage> {
  final List<Stroke> _strokes = [];
  Stroke? _currentStroke;
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // 笔触开始
      onPanStart: (details) {
        setState(() {
          _currentStroke = Stroke(
            [details.localPosition],
            Colors.black,
            3.0,
          );
        });
      },
      // 笔触移动
      onPanUpdate: (details) {
        setState(() {
          _currentStroke!.points.add(details.localPosition);
        });
      },
      // 笔触结束
      onPanEnd: (_) {
        setState(() {
          _strokes.add(_currentStroke!);
          _currentStroke = null;
        });
      },
      child: CustomPaint(
        painter: NotebookPainter(_strokes, _currentStroke),
        size: Size.infinite, // 占满整个区域
      ),
    );
  }
}

3.3 性能优化:使用 PictureRecorder 缓存已完成的笔画

手写场景中,已完成笔画不需要每次重绘,使用 PictureRecorder 缓存:

class OptimizedNotebookPainter extends CustomPainter {
  final Picture? cachedPicture;
  final Stroke? currentStroke;
  
  OptimizedNotebookPainter(this.cachedPicture, this.currentStroke);
  
  static Future<Picture> recordStrokes(List<Stroke> strokes, Size size) async {
    final recorder = PictureRecorder();
    final canvas = Canvas(recorder);
    
    for (final stroke in strokes) {
      _drawStroke(canvas, stroke);
    }
    
    return recorder.endRecording();
  }
  
  @override
  void paint(Canvas canvas, Size size) {
    // 直接绘制缓存的图片(不需要重新绘制每个笔画)
    if (cachedPicture != null) {
      canvas.drawPicture(cachedPicture!);
    }
    
    // 只绘制当前笔画
    if (currentStroke != null) {
      _drawStroke(canvas, currentStroke!);
    }
  }
  
  @override
  bool shouldRepaint(covariant OptimizedNotebookPainter old) {
    // 只有当前笔画变化时才重绘
    return currentStroke != old.currentStroke;
  }
}

四、实战:绘制图表(折线图)

class LineChartPainter extends CustomPainter {
  final List<double> data;
  final List<String> labels;
  
  LineChartPainter(this.data, this.labels);
  
  @override
  void paint(Canvas canvas, Size size) {
    if (data.isEmpty) return;
    
    final paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;
    
    final path = Path();
    final maxValue = data.reduce(math.max);
    final minValue = data.reduce(math.min);
    final range = maxValue - minValue;
    
    // 计算点的坐标
    for (int i = 0; i < data.length; i++) {
      final x = (i / (data.length - 1)) * size.width;
      final y = size.height -
          ((data[i] - minValue) / range) * size.height * 0.8;
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    
    canvas.drawPath(path, paint);
    
    // 绘制圆点
    final dotPaint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill;
    
    for (int i = 0; i < data.length; i++) {
      final x = (i / (data.length - 1)) * size.width;
      final y = size.height -
          ((data[i] - minValue) / range) * size.height * 0.8;
      canvas.drawCircle(Offset(x, y), 4, dotPaint);
    }
  }
  
  @override
  bool shouldRepaint(covariant LineChartPainter old) {
    return !listEquals(old.data, data);
  }
}

五、性能优化建议

5.1 使用 RepaintBoundary 隔离重绘区域

RepaintBoundary(
  child: CustomPaint(
    painter: MyPainter(),
    size: size,
  ),
)

5.2 避免在 paint() 中创建对象

// ❌ 错误:每次重绘都创建新对象
@override
void paint(Canvas canvas, Size size) {
  final paint = Paint(); // 每次都创建
  // ...
}

// ✅ 正确:在构造函数中创建
class MyPainter extends CustomPainter {
  final Paint _paint = Paint(); // 复用
  
  @override
  void paint(Canvas canvas, Size size) {
    // 直接使用 _paint
  }
}

5.3 使用 shouldRepaint 精确控制重绘

@override
bool shouldRepaint(covariant MyPainter old) {
  // 精确判断:只有真正需要变化时才返回 true
  return color != old.color || size != old.size;
}

六、常见问题 FAQ

Q:CustomPaint 和 Flutter 自带 Widget 如何选择?

A:优先使用 Flutter 自带的 Widget(性能更好、适配性更强)。只有在以下场景才使用 CustomPaint:

  • 需要绘制复杂图形(手写、图表、特效)
  • 需要实现特殊的动画效果
  • 自带 Widget 无法满足 UI 需求

Q:如何在 Canvas 中实现缩放和平移?

A:使用 GestureDetector + Matrix4

class _ZoomablePainterState extends State<ZoomablePainter> {
  double _scale = 1.0;
  Offset _offset = Offset.zero;
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onScaleUpdate: (details) {
        setState(() {
          _scale = details.scale;
          _offset += details.focalPointDelta;
        });
      },
      child: CustomPaint(
        painter: MyPainter(scale: _scale, offset: _offset),
        size: Size.infinite,
      ),
    );
  }
}

七、参考资源


如果本文对你有帮助,欢迎点赞收藏。如有疑问,欢迎在评论区交流。