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

发布时间:2026/10/28 3:23:37
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画是能画出来但一出问题就完全不知道从哪查。我建议先把链路捋顺你写的是 WidgetFlutter 把它们塞进 Element 树Element 再生成 RenderObject 树RenderObject 完成布局和绘制指令的录制最终产出的是 Layer 树——文本、图片、颜色块、你画的图形统统变成 PictureLayer 里的 Picture。这些 Layer 再被合成器丢给底层图形引擎最后由 GPU 光栅化上屏。CustomPaint 这个 Widget 本质上没有发明任何新东西它只是把一个 RenderCustomPaint 插进 RenderObject 树然后在合适的时机调用你提供的 CustomPainter 的 paint 方法把 dart:ui 的 Canvas 交到你手上。理解这一点有几个直接好处。第一你知道 paint 方法被调用的时机不由你控制它跟着布局、跟随重绘边界、跟着 Layer 失效走你不能假设它只在你想画的时候跑一次。第二你知道 Canvas 上录制的指令最终要变成 PicturePicture 是有成本的一帧里录了太多指令光栅化阶段就会吃力。第三你知道 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 的时候生效一旦传了 childCustomPaint 的尺寸就由 child 决定size 参数会被忽略。新人踩的第一个坑几乎都是“代码没报错但屏幕上什么都没有”。原因八成是CustomPaint 没有 child也没传 size于是它的约束变成 Size.zeroCanvas 的可绘制区域是零面积你画的线全部落在区域之外自然什么都看不到。解决办法很简单外面套一层 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画笔颜色带透明度的颜色要显式写 alphastrokeWidth线宽只在 stroke 风格下生效style填充或描边PaintingStyle.fill / strokestrokeCap线头形状butt / round / square圆头端点必用 roundstrokeJoin拐角形状miter / round / bevel折线图建议 roundisAntiAlias抗锯齿默认 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 Animationdouble 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(ListOffset 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 StateSignatureBoard createState() SignatureBoardState(); } class SignatureBoardState extends StateSignatureBoard { final ListListOffset _strokes []; ListOffset _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; }); } Futurevoid 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 ListListOffset 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 时只触发 paintWidget 树完全不动。class PulseView extends StatefulWidget { const PulseView({super.key}); override StatePulseView createState() _PulseViewState(); } class _PulseViewState extends StatePulseView 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 包一层收到数据就 notifypainter 里读最新数据。比起每来一个数据点就 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。草图省下来的时间远比画错之后再回来找坐标要划算。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询