Flutter自定义绘制—Canvas API与CustomPainter深度实战|新宇宙博客Back to listFlutter 自定义绘制与 Canvas 深度解析
Site Owner
Published on 2026-05-22
系统讲解Flutter CustomPainter机制、Canvas绑定API、Path高级操作、图层合成及shouldRepaint优化

Flutter 自定义绘制与 Canvas 深度解析 (Custom Painting)
前言
当内置 Widget 无法满足 UI 需求时——比如绘制折线图、仪表盘、签名画板、粒子效果——就需要走进 Flutter 的绘制层,直接操作 Canvas。
自定义绘制看似简单,但有两个常被忽视的性能陷阱:不合理的 shouldRepaint 和缺少 RepaintBoundary。本文将从 Canvas API 出发,深入到 Layer 合成原理,帮你真正用好自定义绘制。
一、CustomPainter 的基本结构
class MyPainter extends CustomPainter {
final double progress; // 绘制所需数据
final Color color;
MyPainter({required this.progress, required this.color});
@override
void paint(Canvas canvas, Size size) {
// 在这里进行所有绘制操作
final paint = Paint()
..color = color
..strokeWidth = 4.0
..style = PaintingStyle.stroke;
canvas.drawArc(
Rect.fromLTWH(0, 0, size.width, size.height),
-math.pi / 2,
2 * math.pi * progress,
false,
paint,
);
}
@override
bool shouldRepaint(MyPainter oldDelegate) {
// 只有数据变化时才重绘,关键性能优化
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
// 使用:
CustomPaint(
painter: MyPainter(progress: 0.75, color: Colors.blue),
size: const Size(200, 200),
)
二、Canvas API 全景图
2.1 基础图形
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = Colors.blue;
// 矩形
canvas.drawRect(
Rect.fromLTWH(10, 10, 100, 50),
paint,
);
// 圆角矩形
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(10, 80, 100, 50),
const Radius.circular(12),
),
paint,
);
// 圆形
canvas.drawCircle(Offset(size.width / 2, size.height / 2), 40, paint);
// 椭圆
canvas.drawOval(Rect.fromLTWH(10, 160, 100, 60), paint);
// 直线
canvas.drawLine(Offset(0, 0), Offset(size.width, size.height), paint);
}
2.2 Path:复杂图形的核心
void _drawArrow(Canvas canvas, Offset start, Offset end, Paint paint) {
final path = Path();
// 主线
path.moveTo(start.dx, start.dy);
path.lineTo(end.dx, end.dy);
// 箭头头部
const arrowSize = 12.0;
final angle = math.atan2(end.dy - start.dy, end.dx - start.dx);
path.moveTo(end.dx, end.dy);
path.lineTo(
end.dx - arrowSize * math.cos(angle - math.pi / 6),
end.dy - arrowSize * math.sin(angle - math.pi / 6),
);
path.moveTo(end.dx, end.dy);
path.lineTo(
end.dx - arrowSize * math.cos(angle + math.pi / 6),
end.dy - arrowSize * math.sin(angle + math.pi / 6),
);
canvas.drawPath(path, paint);
}
// 贝塞尔曲线
void _drawBezier(Canvas canvas, Size size, Paint paint) {
final path = Path();
path.moveTo(0, size.height / 2);
// 二次贝塞尔曲线
path.quadraticBezierTo(
size.width / 2, 0, // 控制点
size.width, size.height / 2, // 终点
);
// 三次贝塞尔曲线
final path2 = Path();
path2.moveTo(0, size.height / 2);
path2.cubicTo(
size.width * 0.25, 0, // 控制点1
size.width * 0.75, size.height, // 控制点2
size.width, size.height / 2, // 终点
);
canvas.drawPath(path, paint);
canvas.drawPath(path2, paint..color = Colors.red);
}
2.3 弧线与扇形
void _drawPieChart(Canvas canvas, Size size, List<double> values, List<Color> colors) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) / 2 - 10;
final rect = Rect.fromCircle(center: center, radius: radius);
double startAngle = -math.pi / 2; // 从顶部开始
final total = values.reduce((a, b) => a + b);
for (int i = 0; i < values.length; i++) {
final sweepAngle = 2 * math.pi * (values[i] / total);
final paint = Paint()
..color = colors[i]
..style = PaintingStyle.fill;
canvas.drawArc(rect, startAngle, sweepAngle, true, paint);
startAngle += sweepAngle;
}
}
2.4 文字绘制
void _drawText(Canvas canvas, Size size) {
final textPainter = TextPainter(
text: TextSpan(
text: 'Hello Canvas!',
style: const TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: size.width);
// 居中绘制
final offset = Offset(
(size.width - textPainter.width) / 2,
(size.height - textPainter.height) / 2,
);
textPainter.paint(canvas, offset);
}
2.5 图片绘制
class ImagePainter extends CustomPainter {
final ui.Image? image;
ImagePainter({this.image});
@override
void paint(Canvas canvas, Size size) {
if (image == null) return;
// 直接绘制
canvas.drawImage(image!, Offset.zero, Paint());
// 缩放绘制(src 是图片区域,dst 是目标区域)
canvas.drawImageRect(
image!,
Rect.fromLTWH(0, 0, image!.width.toDouble(), image!.height.toDouble()),
Rect.fromLTWH(0, 0, size.width, size.height),
Paint(),
);
// 九宫格绘制(适合气泡等需要拉伸的图)
canvas.drawImageNine(
image!,
const Rect.fromLTWH(20, 20, 10, 10), // 中心区域(可以拉伸)
Rect.fromLTWH(0, 0, size.width, size.height),
Paint(),
);
}
@override
bool shouldRepaint(ImagePainter oldDelegate) => oldDelegate.image != image;
}
2.6 渐变与着色器
void _drawGradient(Canvas canvas, Size size) {
// 线性渐变
final linearGradient = LinearGradient(
colors: [Colors.blue, Colors.purple],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
final paint = Paint()
..shader = linearGradient.createShader(
Rect.fromLTWH(0, 0, size.width, size.height),
);
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
// 径向渐变
final radialShader = ui.Gradient.radial(
Offset(size.width / 2, size.height / 2),
size.width / 2,
[Colors.yellow, Colors.orange, Colors.red],
[0.0, 0.5, 1.0],
);
canvas.drawCircle(
Offset(size.width / 2, size.height / 2),
size.width / 2,
Paint()..shader = radialShader,
);
}
三、坐标变换
void paint(Canvas canvas, Size size) {
// 保存当前变换矩阵
canvas.save();
// 平移
canvas.translate(size.width / 2, size.height / 2);
// 旋转(弧度)
canvas.rotate(math.pi / 4);
// 缩放
canvas.scale(0.5, 0.5);
// 绘制:此时以 (width/2, height/2) 为原点,旋转 45°,缩放 0.5
canvas.drawRect(
Rect.fromCenter(center: Offset.zero, width: 100, height: 100),
Paint()..color = Colors.blue,
);
// 恢复变换矩阵
canvas.restore();
// 使用 saveLayer 实现透明度和混合效果
canvas.saveLayer(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..color = Colors.black.withOpacity(0.5),
);
// ... 绘制内容 ...
canvas.restore();
}
四、ClipPath:自定义裁剪
// 波浪形裁剪
class WaveClipper extends CustomClipper<Path> {
final double waveHeight;
WaveClipper({this.waveHeight = 30});
@override
Path getClip(Size size) {
final path = Path();
path.lineTo(0, size.height - waveHeight);
// 用二次贝塞尔绘制波浪
final firstControlPoint = Offset(size.width / 4, size.height);
final firstEndPoint = Offset(size.width / 2, size.height - waveHeight);
path.quadraticBezierTo(
firstControlPoint.dx, firstControlPoint.dy,
firstEndPoint.dx, firstEndPoint.dy,
);
final secondControlPoint = Offset(size.width * 3 / 4, size.height - waveHeight * 2);
final secondEndPoint = Offset(size.width, size.height - waveHeight);
path.quadraticBezierTo(
secondControlPoint.dx, secondControlPoint.dy,
secondEndPoint.dx, secondEndPoint.dy,
);
path.lineTo(size.width, 0);
path.close();
return path;
}
@override
bool shouldReclip(WaveClipper oldClipper) => waveHeight != oldClipper.waveHeight;
}
// 使用:
ClipPath(
clipper: WaveClipper(waveHeight: 40),
child: Container(
height: 200,
color: Colors.blue,
),
)
五、shouldRepaint 的正确姿势
shouldRepaint 是影响 CustomPainter 性能的关键:
class BadPainter extends CustomPainter {
final List<Offset> points;
BadPainter({required this.points});
@override
void paint(Canvas canvas, Size size) { /* ... */ }
@override
bool shouldRepaint(BadPainter oldDelegate) {
return true; // ❌ 每次都重绘!即使数据没变
}
}
class GoodPainter extends CustomPainter {
final List<Offset> points;
GoodPainter({required this.points});
@override
void paint(Canvas canvas, Size size) { /* ... */ }
@override
bool shouldRepaint(GoodPainter oldDelegate) {
// ✅ 精确比较:只有当 points 内容变化时才重绘
if (oldDelegate.points.length != points.length) return true;
for (int i = 0; i < points.length; i++) {
if (oldDelegate.points[i] != points[i]) return true;
}
return false;
}
}
基于 Listenable 的 CustomPainter
当 Painter 需要响应动画时,使用 repaint 参数而非 setState:
class AnimatedRingPainter extends CustomPainter {
final Animation<double> animation;
AnimatedRingPainter({required this.animation})
: super(repaint: animation); // 当 animation 变化时自动重绘
@override
void paint(Canvas canvas, Size size) {
final progress = animation.value;
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 8;
canvas.drawArc(
Rect.fromLTWH(20, 20, size.width - 40, size.height - 40),
-math.pi / 2,
2 * math.pi * progress,
false,
paint,
);
}
@override
bool shouldRepaint(AnimatedRingPainter oldDelegate) {
return oldDelegate.animation != animation;
}
}
// 使用(无需 setState!):
class RingWidget extends StatefulWidget {
@override
State<RingWidget> createState() => _RingWidgetState();
}
class _RingWidgetState extends State<RingWidget> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: AnimatedRingPainter(animation: _controller),
size: const Size(200, 200),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
六、RepaintBoundary:重绘隔离的核心武器
6.1 Flutter 的重绘传播机制
当一个 Widget 调用 setState 或动画更新时,Flutter 会将该 Widget 所在的 Layer 标记为脏,重新合成。如果没有隔离,一个动画可能导致整个屏幕重绘。
Widget Tree(无 RepaintBoundary):
App(Layer 1)
├── Header(在 Layer 1 中)
├── AnimatedWidget(更新 → Layer 1 整体重绘)
└── StaticList(也在 Layer 1 中,被迫重绘)
6.2 RepaintBoundary 的作用
RepaintBoundary(
child: AnimatedWidget(), // 创建独立 Layer
)
有了 RepaintBoundary,AnimatedWidget 的更新只会重绘自己的 Layer,不影响其他 Layer。
Widget Tree(有 RepaintBoundary):
App(Layer 1)
├── Header(Layer 1,不受影响)
├── RepaintBoundary → AnimatedWidget(Layer 2,独立重绘)
└── StaticList(Layer 1,不受影响)
6.3 何时添加 RepaintBoundary
// ✅ 场景一:频繁重绘的动画
RepaintBoundary(
child: AnimationWidget(), // 60fps 的动画
)
// ✅ 场景二:复杂的自定义绘制
RepaintBoundary(
child: CustomPaint(
painter: ComplexChartPainter(), // 复杂折线图
),
)
// ✅ 场景三:视频播放器、地图等重型 Widget
RepaintBoundary(
child: VideoPlayer(),
)
// ❌ 不要随处添加:每个 RepaintBoundary 都会创建一个新的 Layer,
// 增加 GPU 合成的 Layer 数量,过多 Layer 反而影响性能
6.4 使用 debugRepaintRainbowEnabled 定位
void main() {
debugRepaintRainbowEnabled = true; // 重绘时闪烁彩虹边框
runApp(const MyApp());
}
运行后,如果看到某个区域频繁闪烁,说明它被频繁重绘,可以考虑添加 RepaintBoundary。
七、Layer 合成原理与 GPU 加速
1. Build Phase → 构建 Widget 树
2. Layout Phase → 计算约束和尺寸
3. Paint Phase → 将绘制命令录制到 Layer 树
4. Composite Phase → GPU 合成各个 Layer,输出到屏幕
Layer 的类型
PictureLayer → 普通绘制内容(CustomPaint 的输出)
TransformLayer → 变换(平移、旋转、缩放)
OpacityLayer → 透明度
ClipRectLayer → 矩形裁剪
ClipPathLayer → 路径裁剪
GPU 加速的关键:Texture Cache
// 某些操作会触发 GPU 纹理缓存,避免每帧重新绘制:
// 1. RepaintBoundary → 该区域的绘制结果被缓存为 GPU 纹理
// 2. saveLayer + Opacity → 先绘制到离屏缓冲区,再应用透明度
// ⚠️ saveLayer 的开销很大(需要离屏缓冲区)
// 尽量用 Opacity Widget(会使用 OpacityLayer,由合成器处理)
// 而不是 canvas.saveLayer + paint.color.withOpacity
八、实战:可交互折线图
class LineChart extends StatefulWidget {
final List<double> data;
final Color lineColor;
const LineChart({super.key, required this.data, this.lineColor = Colors.blue});
@override
State<LineChart> createState() => _LineChartState();
}
class _LineChartState extends State<LineChart> {
Offset? _hoveredPoint;
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: (details) {
setState(() => _hoveredPoint = details.localPosition);
},
onPanEnd: (_) => setState(() => _hoveredPoint = null),
child: RepaintBoundary( // 触摸交互频繁更新,需要 RepaintBoundary
child: CustomPaint(
painter: LineChartPainter(
data: widget.data,
lineColor: widget.lineColor,
hoveredPoint: _hoveredPoint,
),
size: const Size(double.infinity, 200),
),
),
);
}
}
class LineChartPainter extends CustomPainter {
final List<double> data;
final Color lineColor;
final Offset? hoveredPoint;
LineChartPainter({
required this.data,
required this.lineColor,
this.hoveredPoint,
});
@override
void paint(Canvas canvas, Size size) {
if (data.isEmpty) return;
final maxVal = data.reduce(math.max);
final minVal = data.reduce(math.min);
final range = maxVal - minVal == 0 ? 1 : maxVal - minVal;
// 计算数据点坐标
List<Offset> points = List.generate(data.length, (i) {
final x = size.width * i / (data.length - 1);
final y = size.height - size.height * (data[i] - minVal) / range;
return Offset(x, y);
});
// 绘制填充区域
final fillPath = Path()..moveTo(0, size.height);
for (final p in points) {
fillPath.lineTo(p.dx, p.dy);
}
fillPath.lineTo(size.width, size.height);
fillPath.close();
canvas.drawPath(
fillPath,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [lineColor.withOpacity(0.3), lineColor.withOpacity(0.0)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height)),
);
// 绘制折线
final linePath = Path()..moveTo(points[0].dx, points[0].dy);
for (int i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(
linePath,
Paint()
..color = lineColor
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round,
);
// 绘制数据点
for (final p in points) {
canvas.drawCircle(p, 4, Paint()..color = lineColor);
canvas.drawCircle(p, 3, Paint()..color = Colors.white);
}
// 绘制悬停提示
if (hoveredPoint != null) {
// 找最近的数据点
int nearest = 0;
double minDist = double.infinity;
for (int i = 0; i < points.length; i++) {
final dist = (points[i] - hoveredPoint!).distance;
if (dist < minDist) {
minDist = dist;
nearest = i;
}
}
if (minDist < 40) {
final p = points[nearest];
// 垂直指示线
canvas.drawLine(
Offset(p.dx, 0),
Offset(p.dx, size.height),
Paint()
..color = Colors.grey.withOpacity(0.5)
..strokeWidth = 1,
);
// 高亮数据点
canvas.drawCircle(p, 6, Paint()..color = lineColor);
canvas.drawCircle(p, 4, Paint()..color = Colors.white);
// 绘制 Tooltip
final value = data[nearest];
final textPainter = TextPainter(
text: TextSpan(
text: value.toStringAsFixed(1),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
textDirection: TextDirection.ltr,
)..layout();
final tooltipRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(p.dx, p.dy - 24),
width: textPainter.width + 16,
height: 24,
),
const Radius.circular(4),
);
canvas.drawRRect(tooltipRect, Paint()..color = Colors.black87);
textPainter.paint(
canvas,
Offset(
tooltipRect.left + 8,
tooltipRect.top + 4,
),
);
}
}
}
@override
bool shouldRepaint(LineChartPainter oldDelegate) {
return oldDelegate.data != data ||
oldDelegate.lineColor != lineColor ||
oldDelegate.hoveredPoint != hoveredPoint;
}
}
九、实战:签名画板(支持笔压模拟)
class SignaturePad extends StatefulWidget {
final Color inkColor;
final double maxStrokeWidth;
const SignaturePad({
super.key,
this.inkColor = Colors.black,
this.maxStrokeWidth = 4.0,
});
@override
State<SignaturePad> createState() => _SignaturePadState();
}
class _SignaturePadState extends State<SignaturePad> {
final List<List<_StrokePoint>> _strokes = [];
List<_StrokePoint> _currentStroke = [];
DateTime? _lastTime;
void _onPanStart(DragStartDetails details) {
_lastTime = DateTime.now();
_currentStroke = [_StrokePoint(details.localPosition, widget.maxStrokeWidth)];
setState(() {});
}
void _onPanUpdate(DragUpdateDetails details) {
final now = DateTime.now();
final dt = _lastTime == null ? 1.0 : now.difference(_lastTime!).inMilliseconds / 16.0;
_lastTime = now;
// 速度越快,线条越细(模拟笔压)
final velocity = details.delta.distance / dt;
final strokeWidth = (widget.maxStrokeWidth - velocity * 0.1).clamp(0.5, widget.maxStrokeWidth);
_currentStroke.add(_StrokePoint(details.localPosition, strokeWidth));
setState(() {});
}
void _onPanEnd(DragEndDetails details) {
if (_currentStroke.isNotEmpty) {
_strokes.add(List.from(_currentStroke));
_currentStroke = [];
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: RepaintBoundary(
child: CustomPaint(
painter: SignaturePainter(
strokes: _strokes,
currentStroke: _currentStroke,
color: widget.inkColor,
),
child: Container(
color: Colors.white,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton(
onPressed: () => setState(() {
_strokes.clear();
_currentStroke.clear();
}),
child: const Text('清除'),
),
TextButton(
onPressed: _exportSignature,
child: const Text('导出'),
),
],
),
],
);
}
Future<void> _exportSignature() async {
// 捕获 RepaintBoundary 内的图像
final boundary = context.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return;
final image = await boundary.toImage(pixelRatio: 2.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
// 使用 byteData...
}
}
class _StrokePoint {
final Offset position;
final double width;
_StrokePoint(this.position, this.width);
}
class SignaturePainter extends CustomPainter {
final List<List<_StrokePoint>> strokes;
final List<_StrokePoint> currentStroke;
final Color color;
SignaturePainter({
required this.strokes,
required this.currentStroke,
required this.color,
});
void _drawStroke(Canvas canvas, List<_StrokePoint> stroke) {
if (stroke.length < 2) {
if (stroke.length == 1) {
canvas.drawCircle(
stroke[0].position,
stroke[0].width / 2,
Paint()..color = color,
);
}
return;
}
for (int i = 0; i < stroke.length - 1; i++) {
canvas.drawLine(
stroke[i].position,
stroke[i + 1].position,
Paint()
..color = color
..strokeWidth = (stroke[i].width + stroke[i + 1].width) / 2
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke,
);
}
}
@override
void paint(Canvas canvas, Size size) {
for (final stroke in strokes) {
_drawStroke(canvas, stroke);
}
_drawStroke(canvas, currentStroke);
}
@override
bool shouldRepaint(SignaturePainter oldDelegate) {
return oldDelegate.strokes != strokes ||
oldDelegate.currentStroke != currentStroke ||
oldDelegate.color != color;
}
}
十、性能调优清单
| 优化点 | 错误做法 | 正确做法 |
|---|
| shouldRepaint | 始终返回 true | 精确比较变化的字段 |
| 动画重绘 | setState 触发整树 rebuild | 使用 repaint: animation 参数 |
| 静态内容 | 放在动态 Layer 中 | 用 RepaintBoundary 隔离 |
| 透明度 | canvas.saveLayer + opacity | 使用 Opacity Widget |
| 文字 | 每帧重新 layout | 缓存 TextPainter |
| 图片 | 每帧 decode | 预加载为 ui.Image 缓存 |
过关自检
Q1:RepaintBoundary 的工作原理是什么?
RepaintBoundary 会为其子树创建一个独立的 PictureLayer(以及对应的 OffsetLayer),相当于在 Layer 树中建立一个隔离层。当子树内部发生绘制更新时,只有这个独立 Layer 需要重新绘制(Paint Phase),其内容作为 GPU 纹理缓存,其他 Layer 不受影响。在 Composite Phase,GPU 只需要重新合成被标记为脏的 Layer。
Q2:如何用 debugRepaintRainbowEnabled 定位不必要的重绘区域?
设置 debugRepaintRainbowEnabled = true 后,Flutter 会在每次重绘时给该区域添加一个变化颜色的边框(彩虹色轮换)。运行应用,观察哪些区域在不必要地闪烁——比如用户只点了按钮,却发现整个列表都在闪烁——那个区域就需要用 RepaintBoundary 隔离。
小结
| 概念 | 要点 |
|---|
| CustomPainter | paint + shouldRepaint,基于数据驱动绘制 |
| Canvas API | drawRect/Circle/Path/Image/Arc,坐标变换 |
| shouldRepaint | 精确比较,避免不必要的重绘 |
| repaint Listenable | 动画驱动绘制的最佳实践,无需 setState |
| RepaintBoundary | 创建独立 Layer,隔离重绘范围 |
| debugRepaintRainbowEnabled | 可视化定位不必要重绘 |
| Layer 合成 | GPU 处理,saveLayer 有离屏缓冲代价 |