Flutter Canvas 自绘实战:CustomPainter 与性能优化

发布时间:2026/10/28 3:23:38
Flutter Canvas 自绘实战:CustomPainter 与性能优化

Flutter 里的 Canvas,说穿了就是 dart:ui 直接摊在你面前的那一层绘制接口。你在 Widget 层写的 Container、BoxDecoration、ShaderMask,最后都要落到 Canvas 上;而当你需要画一张实时折线图、一个手写签名板、一圈带缺口的进度环、一堆会呼吸的粒子,或者给图片打水印、做圆角异形裁剪,Widget 组合就开始捉襟见肘了,这时候就得把袖子挽起来直接跟 Canvas 打交道。这篇内容面向已经能写页面、但对 CustomPainter 还停留在“抄过一段波形图代码”阶段的开发者,把 CustomPaint 的三层结构、坐标变换、Path 绘制、动画驱动重绘、离屏合成和性能红线拆开讲一遍。里面涉及的参数计算、对象复用策略、坐标系对齐、像素比处理,都是我在图表、手写签名和自定义控件这几类需求里反复踩出来的,代码可以直接拿去改。

1. 先搞清楚 Canvas 在 Flutter 渲染链路里的位置

1.1 从 Widget 到 Skia 的那条链路

很多人一上来就背 API,画是能画出来,但一出问题就完全不知道从哪查。我建议先把链路捋顺:你写的是 Widget,Flutter 把它们塞进 Element 树,Element 再生成 RenderObject 树,RenderObject 完成布局和绘制指令的录制,最终产出的是 Layer 树——文本、图片、颜色块、你画的图形,统统变成 PictureLayer 里的 Picture。这些 Layer 再被合成器丢给底层图形引擎,最后由 GPU 光栅化上屏。CustomPaint 这个 Widget 本质上没有发明任何新东西,它只是把一个 RenderCustomPaint 插进 RenderObject 树,然后在合适的时机调用你提供的 CustomPainter 的 paint 方法,把 dart:ui 的 Canvas 交到你手上。

理解这一点有几个直接好处。第一,你知道 paint 方法被调用的时机不由你控制,它跟着布局、跟随重绘边界、跟着 Layer 失效走,你不能假设它只在你想画的时候跑一次。第二,你知道 Canvas 上录制的指令最终要变成 Picture,Picture 是有成本的,一帧里录了太多指令,光栅化阶段就会吃力。第三,你知道 Canvas 的绘制是“录制”而不是“立即出像素”,所以你在 paint 里做的任何计算都会挤占 UI 线程的时间片,写得越重,掉帧越明显。

还有一个容易忽略的点:CustomPaint 绘制出来的东西和普通 Widget 共用同一个坐标系吗?答案是否定的。paint 方法里拿到的 Canvas,原点已经被平移到这个 CustomPaint 所占区域的左上角,你画的 (0,0) 就是这个区域的左上顶点,不是屏幕左上角。这个细节在很多“为什么我画的图形位置偏了”的问题里是根因。

1.2 什么时候该自己画,什么时候趁早收手

不是所有看起来很“自绘”的需求都值得上 Canvas。我给自己定的判断标准很朴素:能用 BoxDecoration、ClipPath、ShaderMask、Transform 组合出来的,绝不上 CustomPainter;能用成熟图表库覆盖 80% 需求的,先接库,剩余 20% 再考虑自绘;只有当图形形状随数据动态变化、需要逐帧更新、或者需要精确控制每一笔的绘制顺序时,才值得手写 Canvas。

举几个典型场景。环形进度条带渐变色和圆头端点,用 CircularProgressIndicator 改 theme 只能改粗细和颜色,渐变和端点形状做不到,这时候自绘最省事。折线图带阈值区域填充、数据点标记、x 轴刻度,自绘能精确控制每个元素的层级和采样密度。手写签名板要收集触点、做曲线平滑、最终导出位图,这是 Canvas 的主场。粒子背景、水波纹、声波律动这类动效,自绘的性能上限远高于堆叠大量 Transform Widget。反过来,一个带阴影的圆角卡片、一个斜切角的标签、一张圆形头像,用 Widget 组合又快又稳,硬要自绘属于给自己找麻烦。

提示:判断标准可以用一句话概括——图形的“形状”是否依赖运行时数据。形状固定、只有颜色尺寸变化的,用 Widget;形状本身要算出来的,用 Canvas。

2. CustomPainter 的正确打开方式

2.1 CustomPaint 的三层结构和一个常见陷阱

CustomPaint 这个 Widget 有四个关键参数:child、painter、foregroundPainter、size。它们的关系可以想象成一张纸上作画:painter 画在 child 下面,foregroundPainter 画在 child 上面,child 是夹在中间的那一层。size 只在没有 child 的时候生效,一旦传了 child,CustomPaint 的尺寸就由 child 决定,size 参数会被忽略。

新人踩的第一个坑几乎都是“代码没报错,但屏幕上什么都没有”。原因八成是:CustomPaint 没有 child,也没传 size,于是它的约束变成 Size.zero,Canvas 的可绘制区域是零面积,你画的线全部落在区域之外,自然什么都看不到。解决办法很简单,外面套一层 SizedBox 给定宽高,或者直接给 CustomPaint 传 size 参数。

// 有 child 的情况:size 被忽略,尺寸由 child 决定 CustomPaint( painter: WavePainter(color: Colors.blue), child: const SizedBox(width: 300, height: 160), ) // 无 child 的情况:必须给 size 或者被父级约束 CustomPaint( size: const Size(300, 160), painter: WavePainter(color: Colors.blue), )

我的习惯是永远给一个明确的尺寸载体,哪怕是包一层 SizedBox,因为这样布局意图最清晰,也方便后续做响应式——把宽高从 MediaQuery 或者 LayoutBuilder 里取出来传进去,painter 里就能按比例计算所有坐标。

另一个相关的小知识点是命中测试。CustomPaint 没有 child 时,默认的 hit test 行为是“只有绘制区域内才算命中”,但它并不会自动响应手势。你要加点击或者拖拽,得在外面套 GestureDetector 或者 Listener,并且注意 behavior 的取值,否则透明区域的手势会漏到下面的 Widget 上。

2.2 Paint 与 Canvas 的核心 API 速查

Paint 描述的是“怎么画”,Canvas 描述的是“画什么、画在哪”。把这两个对象吃透,剩下的大部分工作就是数学问题。

属性作用常见取值与注意点
color画笔颜色带透明度的颜色要显式写 alpha
strokeWidth线宽只在 stroke 风格下生效
style填充或描边PaintingStyle.fill / stroke
strokeCap线头形状butt / round / square,圆头端点必用 round
strokeJoin拐角形状miter / round / bevel,折线图建议 round
isAntiAlias抗锯齿默认 true,极细线可关掉换清晰度
shader着色器渐变、图片填充都走这个
maskFilter模糊遮罩MaskFilter.blur 有性能代价,慎用于大面积
blendMode混合模式做叠加、擦除、正片叠底时用

Canvas 这边,最常用的是drawLine、drawRect、drawRRect、drawCircle、drawOval、drawPath、drawArc、drawImage、drawImageRect、drawParagraph、drawPoints、drawAtlas。其中drawPath是使用频率最高的一个,因为绝大多数复杂图形最后都归结为构造一条 Path。drawArc画环形进度非常方便,第二个和第三个参数是起始弧度和扫过的弧度,注意单位是弧度不是角度,180 度得写math.pi。

drawImageRect值得单独说一下,它有两个矩形参数:src 是源图上要截取的区域,dst 是画到 Canvas 上的目标区域。图片裁剪、九宫格拉伸、水印平铺都靠它。很多人只用了drawImage,结果图片被拉伸变形,其实就是没用上 src/dst 的分离能力。

还有一个 API 组合经常被低估:canvas.saveLayer。它可以开一个离屏图层,后续绘制先落在这个图层上,最后带混合模式合成回主画布。做“橡皮擦”效果时就是靠它配合 BlendMode.clear,或者用BlendMode.srcOut挖洞。代价是 saveLayer 会申请额外的一块离屏纹理,面积越大开销越大,所以能用 clip 解决的绝不用 saveLayer。

2.3 shouldRepaint 与 repaint:两条不同的重绘通道

CustomPainter 有两个控制重绘的手段,很多人只用了其中一个,结果性能或者正确性总有一头出问题。

shouldRepaint是 Widget 重建时的决策点。父级 setState 触发了重建,Flutter 会拿旧的 painter 实例和新的 painter 实例做对比,调用你的 shouldRepaint 问一句“需要重画吗”。返回 false 就直接复用上一帧的 Picture,返回 true 才会重新走 paint。这里最常见的错误是无脑返回 true,图省事是省事了,但父级任何一次无关的重建都会连带你的画布重绘一遍。正确做法是逐字段比较:

class ProgressPainter extends CustomPainter { final double progress; final Color trackColor; final Color activeColor; ProgressPainter({ required this.progress, required this.trackColor, required this.activeColor, }); @override void paint(Canvas canvas, Size size) { /* ... */ } @override bool shouldRepaint(covariant ProgressPainter old) => old.progress != progress || old.trackColor != trackColor || old.activeColor != activeColor; }

repaint是另一条通道,它挂在 CustomPainter 的构造函数上,接受一个 Listenable。你可以把 AnimationController、ValueNotifier、ChangeNotifier 直接喂进去,一旦它发出通知,RenderCustomPaint 就会标记需要重绘,完全不经过 Widget 重建这一层。这条路径对 60fps 甚至 120fps 的动画非常关键——用 setState 驱动动画意味着每帧都要执行 build 和 diff,而 repaint 只触发 paint,省掉了整棵子树的构建开销。

class RipplePainter extends CustomPainter { final Animation<double> wave; final Color color; RipplePainter({required this.wave, required this.color}) : super(repaint: wave); // 关键:把动画挂到 repaint 上 @override void paint(Canvas canvas, Size size) { final t = wave.value; // 按 t 计算半径、透明度并绘制 } @override bool shouldRepaint(covariant RipplePainter old) => old.color != color; }

注意 shouldRepaint 里不需要比较 wave,因为它本身是一个 Listenable 引用,同一个 controller 传进来始终是同一个对象,比较也没意义。这种“动画走 repaint、静态属性走 shouldRepaint”的分工,是我目前用得最顺的组合。

3. 坐标变换与 Path 绘制实战

3.1 save / restore 与变换的三个必知细节

Canvas 的坐标系是可以整体变换的:translate平移、rotate旋转、scale缩放、skew倾斜、transform直接给矩阵。每次变换都会修改当前变换矩阵,后续所有绘制指令都跟着走。问题在于这个状态是累积的,你不还原,后面的绘制就全歪了。save和restore就是一对括号,save 把当前状态压栈,restore 弹回来。

三个细节必须记住。第一,save 和 restore 必须严格成对,写了 save 没写 restore,或者 restore 多调了,都会让状态错乱,而且这种错通常不报错,只是画面诡异,排查起来极其痛苦。第二,canvas.saveLayer也是 save,同样需要 restore 配平,但它比 save 重得多,因为它不仅保存矩阵,还开了一块离屏缓冲。第三,变换是左乘累积的,translate之后再rotate,旋转是绕平移后的原点进行的,顺序反过来结果完全不同。这一点在画钟表、仪表盘、雷达图这类需要“绕中心旋转刻度”的场景里尤其明显。

@override void paint(Canvas canvas, Size size) { final center = size.center(Offset.zero); canvas.save(); canvas.translate(center.dx, center.dy); // 此后原点在画布中心,画一根向上指的线就是 (0,-r) 到 (0,0) for (var i = 0; i < 12; i++) { canvas.save(); canvas.rotate(i * math.pi / 6); // 每 30 度一个刻度 canvas.drawLine( const Offset(0, -80), const Offset(0, -70), Paint()..strokeWidth = 2, ); canvas.restore(); } canvas.restore(); }

缩放还有一个隐蔽的副作用:它会等比放大线宽。canvas.scale(2)之后,一个 strokeWidth 为 1 的线条实际渲染色宽度会变成 2 个逻辑像素。如果你想要的只是坐标空间的缩放而线宽保持不变,就得把 strokeWidth 除以缩放系数,或者放弃 scale 改用手动乘坐标。我在做自适应图表时踩过这个坑:手机上看着正常的网格线,到平板上一缩放就粗得不像话。

注意:rotate的参数单位是弧度。math.pi / 2才是 90 度。这个错误几乎每个人都犯过一次,而且画出来往往是一个“方向不太对”的图形,不容易第一时间发现。

3.2 Path:从直线到贝塞尔再到路径运算

Path 是 Canvas 里表达能力最强的对象。基础命令包括moveTo、lineTo、close这些直来直去的,quadraticBezierTo(二次贝塞尔,一个控制点)和cubicTo(三次贝塞尔,两个控制点)用来做平滑曲线,arcTo和arcToPoint画圆弧,addRect、addRRect、addOval、addPolygon、addPath用来拼接现成形状。

做折线图时,直接对每个数据点lineTo出来的线是硬折角,数据抖动大时像锯齿。我一般用二次贝塞尔做中点平滑:取相邻两点的中点作为曲线端点,原数据点作为控制点。这样曲线一定会经过中点,而数据点只影响弯曲程度,视觉上顺滑很多,也不像样条插值那样容易过冲。

Path buildSmoothPath(List<Offset> points) { final path = Path(); if (points.isEmpty) return path; path.moveTo(points.first.dx, points.first.dy); if (points.length == 1) return path; if (points.length == 2) { path.lineTo(points[1].dx, points[1].dy); return path; } for (var i = 1; i < points.length - 1; i++) { final mid = Offset( (points[i].dx + points[i + 1].dx) / 2, (points[i].dy + points[i + 1].dy) / 2, ); path.quadraticBezierTo(points[i].dx, points[i].dy, mid.dx, mid.dy); } path.lineTo(points.last.dx, points.last.dy); return path; }

Path 还有个容易被忽略的能力:布尔运算。Path.combine(PathOperation.union/difference/intersect/xor/reverseDifference, pathA, pathB)可以把两条路径做并、差、交、异或。做描边字、挖空图形、拼合复杂图标时,这比手动算坐标靠谱得多。比如一个“圆环挖出月牙”的效果,用 difference 一行代码就出来了。

路径的度量信息通过path.computeMetrics()拿,返回一串 PathMetric,每个都有length、getTangentForOffset这些方法。做“沿着路径运动的点”或者“路径描边的进度动画”全靠它——把路径截取到某个长度比例,extractPath(0, length * progress)就能拿到一段,画出来就是逐步画出的效果。

void drawPathProgress(Canvas canvas, Path path, double progress, Paint paint) { for (final metric in path.computeMetrics()) { final partial = metric.extractPath(0, metric.length * progress); canvas.drawPath(partial, paint); } }

extractPath每次调用都会生成一个新的 Path 对象,在动画里逐帧调用会持续产生垃圾。我的做法是提前把 metric 缓存起来,只在进度变化时重新 extract;如果动画很短,也可以接受这点分配,但一定要在真机上跑一遍看有没有 GC 抖动。

3.3 完整案例:一个带平滑和撤销的手写签名板

手写签名板是检验 Canvas 掌握程度的好题目:要收集触点、要做曲线平滑、要支持撤销、要能导出位图。下面这版实现我用了很久,代码不长但覆盖了上面讲的大部分点。

核心思路是维护一个“笔画列表”,每个笔画是一组点。手指按下开始新笔画,移动时往当前笔画里追加点,抬起结束。绘制时遍历所有笔画,用上面那个二次贝塞尔函数生成路径,然后drawPath。撤销就是 pop 掉最后一个笔画。

class SignatureBoard extends StatefulWidget { const SignatureBoard({super.key}); @override State<SignatureBoard> createState() => SignatureBoardState(); } class SignatureBoardState extends State<SignatureBoard> { final List<List<Offset>> _strokes = []; List<Offset> _current = []; final GlobalKey _repaintKey = GlobalKey(); void _onPanStart(DragStartDetails d) { setState(() { _current = [d.localPosition]; _strokes.add(_current); }); } void _onPanUpdate(DragUpdateDetails d) { setState(() { _current.add(d.localPosition); }); } void undo() { if (_strokes.isEmpty) return; setState(() { _strokes.removeLast(); _current = _strokes.isEmpty ? [] : _strokes.last; }); } Future<void> export() async { final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 2.0); final bytes = await image.toByteData(format: ImageByteFormat.png); // bytes 即 PNG 数据,交给上层保存或上传 } @override Widget build(BuildContext context) { return GestureDetector( onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, onPanEnd: (_) => setState(() => _current = []), child: RepaintBoundary( key: _repaintKey, child: CustomPaint( painter: SignaturePainter(strokes: _strokes), size: Size.infinite, ), ), ); } }

这里有两个刻意的选择值得说。第一,CustomPaint用Size.infinite而不是硬编码尺寸,配合父级给约束,这样画板能自适应任何容器大小,触摸点的localPosition天然就在画布坐标系里,不需要额外做偏移换算。第二,整个 CustomPaint 包了一层RepaintBoundary,导出图片时直接拿这个 boundary 的toImage,不用自己去拼 PictureRecorder,也顺便把画板的重绘范围和其他 Widget 隔离开了。

绘制端是这样的:

class SignaturePainter extends CustomPainter { final List<List<Offset>> strokes; SignaturePainter({required this.strokes}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = const Color(0xFF1A1A1A) ..style = PaintingStyle.stroke ..strokeWidth = 3.0 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..isAntiAlias = true; for (final stroke in strokes) { if (stroke.isEmpty) continue; if (stroke.length == 1) { canvas.drawCircle(stroke.first, 1.5, paint..style = PaintingStyle.fill); paint.style = PaintingStyle.stroke; continue; } canvas.drawPath(buildSmoothPath(stroke), paint); } } @override bool shouldRepaint(covariant SignaturePainter old) => true; }

shouldRepaint这里返回 true 是有意为之的:strokes 是一个不断被 append 的 List,引用始终相同,做引用比较永远返回 false,会导致画面不更新。如果想要精准比较,得改成在 setState 时传一个新 List 进去,或者用笔画数量加点数做版本号。相比之下,对于这种每帧都在变的场景,直接 true 反而最不容易出错,性能压力也不大——因为重绘本来就该发生。这也是我前面说“无脑返回 true 是坏习惯”之后要补的一句:判断标准是这个 painter 的输入是否真的变了,变就该重绘,不该重绘才需要拦。

4. 动画驱动、离屏合成与性能红线

4.1 让画布动起来:AnimationController 的正确接法

Canvas 动画的性能分水岭,在于你有没有把重绘通道走对。前面讲过repaint参数,这里给一个可以直接抄的完整骨架:把 AnimationController 传进 painter,动画控制器每帧 tick 时只触发 paint,Widget 树完全不动。

class PulseView extends StatefulWidget { const PulseView({super.key}); @override State<PulseView> createState() => _PulseViewState(); } class _PulseViewState extends State<PulseView> with SingleTickerProviderStateMixin { late final AnimationController _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), )..repeat(); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return CustomPaint( size: const Size(240, 240), painter: PulsePainter(_ctrl), ); } }

painter 里用wave.value取 0 到 1 的进度,算半径、透明度、缩放系数都行。想更讲究一点可以套一条CurvedAnimation,把线性进度映射成缓动曲线,出来的呼吸感会自然得多。

有一点必须提前说清楚:动画控制器的dispose千万别漏。忘记释放会导致 ticker 一直挂着,页面被销毁后还在跑,不仅是内存泄漏,还会持续消耗电量。我见过在真机上表现为“页面退了手机还发烫”的案例,根因就是这种没释放的控制器。

还有一类动画不走 controller,而是跟着数据变。比如音频波形、实时心率曲线,数据源是外部流。这种情况更适合用ValueNotifier或者自定义的 Listenable 包一层,收到数据就 notify,painter 里读最新数据。比起每来一个数据点就 setState 一次,这种方式能避免高频重建,数据量大的时候差别非常明显。

4.2 离屏渲染:PictureRecorder、toImage 与缓存策略

当某部分图形每帧都不变、但同一帧里要画几十上百次时,就该考虑离屏录制了。PictureRecorder给你一个独立的 Canvas,你把指令录进去,endRecording()得到一个 Picture,之后用canvas.drawPicture把它贴到任何位置,甚至重复贴。好处是复杂的绘制逻辑只算一次,后续每次贴图只是一次指令。

ui.Picture buildGridPicture(Size size, double step) { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); final paint = Paint() ..color = const Color(0x22000000) ..strokeWidth = 1; for (var x = 0.0; x <= size.width; x += step) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (var y = 0.0; y <= size.height; y += step) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } return recorder.endRecording(); }

录制出来的 Picture 是矢量指令,不是位图,所以缩放贴出去依然清晰,这一点比toImage有优势。真正需要位图的时候才用toImage,比如导出、上传、或者要把结果交给 Image Widget 显示。

toImage的使用有几个坑。它返回的是 Future,因为光栅化是异步的;还有一个toImageSync的同步版本,但同步版本在某些平台和渲染后端上有兼容性限制,正式项目里我倾向于用异步的。第二个坑是pixelRatio参数,默认是 1.0,导出的图分辨率等于逻辑尺寸,放到高清屏上看着发虚。做签名导出时我给 2.0 或者 3.0,但要清楚这是平方级的成本:一张 400×200 的逻辑尺寸,pixelRatio 给 3 就是 1200×600,每个像素 4 字节,单张就是接近 2.9MB 的内存占用。连续导出多张不释放,内存曲线会非常难看。

第三个坑是时机。如果对RenderRepaintBoundary调toImage,必须确保这一帧已经绘制完成。在 initState 里调会拿到空白图,因为那时还没渲染。稳妥的做法是放到WidgetsBinding.instance.addPostFrameCallback里,或者用用户触发的时机(比如点击导出按钮)。

提示:Picture 对象也需要释放。在长生命周期的页面里反复录制 Picture 而不 dispose,会占用原生内存,Dart 的 GC 感知不到这部分压力。

4.3 性能红线:那些让画布从 60fps 掉到 20fps 的写法

我把这些年遇到的性能问题归成三类,基本能覆盖九成以上的掉帧场景。

第一类是每帧创建对象。paint 方法里Paint()、Path()、TextPainter反复 new,一秒钟几百次分配,GC 压力上来了就会看到规律的卡顿。正确做法是把这些对象提升成 painter 的字段,在构造函数里初始化,paint 里只改属性值。Paint 是可变的,改 color、strokeWidth 完全没问题,不需要每次重建。

第二类是重绘范围失控。一个画布放在页面中间,外面套了一个每秒更新的倒计时,如果没做隔离,倒计时每帧都会让整棵树重建,连带画布重绘。加一层 RepaintBoundary 就能把重绘区域切开——它的作用是让这个子树的绘制产物独立成一个 Layer,上层变化时不必重新录制这里。反过来说,不是所有地方都该加 RepaintBoundary,加太多会导致 Layer 数量膨胀,合成阶段反而变慢。判断依据是这块内容是否频繁更新、是否和周围更新频率不同。

第三类是绘制指令本身太贵。大面积使用MaskFilter.blur、大面积的saveLayer、超长路径的drawPath、每帧都重新生成的ImageFilter,都是重灾区。模糊效果尤其明显,因为它需要多次采样。如果只是想做一个柔和的光晕,用径向渐变(RadialGradient 转成 shader)通常比 blur 便宜一个数量级。裁剪优先用clipRect而不是clipPath,因为矩形裁剪的数学判断简单得多。clipPath还带一个 antialias 参数,关掉能省一点,但边缘会有锯齿,要不要关得看视觉要求。

还有一个经常被忽略的点:Canvas 的绘制尺寸。如果画布逻辑尺寸是 1000×1000,但实际显示区域只有 200×200,你画的东西有 80% 都被裁掉了却依然参与了光栅化计算。这时候应该在外层用ClipRect或者干脆缩小绘制范围,别指望系统帮你省。

5. 常见问题排查实录

5.1 问题速查表

下面这张表是我这几年回答别人问题、以及自己 debug 时攒下来的,按“现象 → 可能原因 → 处理方式”组织,遇到问题先扫一遍,能省不少时间。

现象常见原因处理方式
画布完全空白CustomPaint 无 child 且未设 size给 size 或包一层 SizedBox
画了但看不见颜色 alpha 为 0、绘制区域超出边界检查颜色值和坐标范围
图形位置偏移忘了 Canvas 原点在区域左上角用 size 计算相对坐标
部分图形消失save/restore 不配对导致状态错乱逐层检查 save 次数
线条发虚模糊1px 线画在整数坐标上跨了两个物理像素坐标偏移 0.5 并对齐像素比
动画卡顿setState 驱动逐帧重建改用 repaint 参数挂 Listenable
数据变了画面不变shouldRepaint 返回 false 或比较了引用比较值或返回 true
缩放后线宽变粗canvas.scale 会等比放大 strokeWidth反向除以缩放系数
导出图片是空白toImage 调用时机太早放到帧回调或用户操作后
导出图片内存暴涨pixelRatio 过高、未释放 image控制倍率并及时 dispose
点击不响应未包 GestureDetector 或 hitTest 不命中检查 behavior 与绘制区域
圆角/裁剪边缘锯齿clipPath 抗锯齿关闭打开 antialias 或改用 mask
高频数据流导致掉帧每个数据点都触发重建合并数据、用 ValueNotifier
模糊效果特别卡MaskFilter.blur 面积过大改用渐变 shader 模拟
文字位置不对TextPainter 未 layout 就 paint先 layout 再计算偏移

5.2 三个让我印象最深的坑

第一个坑是 1 像素线的清晰度问题。刚接触 Canvas 时我画的网格线总是灰蒙蒙的,一开始以为是颜色设浅了,调深了颜色还是虚。后来才想明白:一条宽度为 1 的线,如果起点在整数坐标上,它实际会跨越两个物理像素的边界,每个像素只分到半个线的覆盖,抗锯齿一平均就成了两条淡淡的灰线。解决方式是把坐标加 0.5,让线正好落在像素中心。更严谨的做法是结合设备像素比做对齐,把坐标 round 到1 / devicePixelRatio的整数倍。这个细节在 Retina 屏和高刷屏上尤其明显,同一份代码在不同设备上的观感差异很大。

第二个坑是文字绘制。Canvas 本身没有直接的 drawText,得靠 TextPainter。而 TextPainter 必须先layout才能paint,不 layout 直接调用要么报错要么画出空内容。而且它的对齐方式和普通 Widget 不一样——paint的偏移量默认按左上角算,如果你想让文字居中在一个圆里,得自己算(size.width - tp.width) / 2这样的偏移。我见过有人直接用 TextAlign.center,发现不管用,那是因为 TextAlign 只影响文本内部行的对齐,不影响整块文本在画布上的位置。

第三个坑是导出图与屏幕显示不一致。屏幕上看着好好的签名,导出来是白底黑字,但比例不对、或者边框被截断。原因通常是两处:一是RepaintBoundary的尺寸只覆盖了 CustomPaint,如果外面还有 padding 或者装饰,导出会丢掉那部分;二是toImage的 pixelRatio 和屏幕的 devicePixelRatio 不一致,视觉上就会有缩放差异。我的处理方式是导出前先确认边界元素的尺寸,把 pixelRatio 显式传成 2.0 或 3.0,并且在 UI 上明确告诉用户导出尺寸,避免他们拿着一张 300px 的图去打印。

还有一个小经验:调试 Canvas 时,如果实在看不出哪里画错了,可以在 paint 的开头先画一个大红矩形铺满整个区域,确认画布尺寸和位置是对的,再逐步替换成真实内容。这个方法粗暴但有效,比对着代码空想快得多。

5.3 关于分组和层级的一点补充

多个图形叠在一起时,谁在上面谁在下面完全取决于 draw 调用的顺序,后画的盖住先画的。这个规则简单,但需求一复杂就容易翻车,比如折线图里线要盖住网格、数据点要盖住线、标签要盖住数据点。我的做法是在 paint 里把绘制逻辑拆成几个私有方法,按层级顺序调用,每个方法内部只管自己那一层,注释写清楚“这一层在最下面”。这样以后加元素时不会乱插。

透明度的叠加也是个细节。多个半透明图形重叠时,重叠区域会变深,这在画热力图或者散点图时可能不是想要的效果。如果需要整体统一透明度,正确做法是用saveLayer开一个图层,把一组图形画进去,用整体 alpha 合成回来,而不是给每个图形单独设透明度。代价嘛,前面说过了,saveLayer 有成本,只在必要的时候用。

到这里,关于 Flutter 里 Canvas 的这套东西,从链路位置、CustomPainter 的结构、坐标变换、Path 技巧、动画驱动到离屏合成和排错,基本都覆盖到了。我自己的习惯是:每接一个新的自绘需求,先在纸上把坐标系和层级画出来,算出关键坐标的表达式,然后再动手写 paint。草图省下来的时间,远比画错之后再回来找坐标要划算。