
1. 先把Canvas放回Flutter的渲染链路里聊Flutter Canvas这个话题我习惯先泼一盆冷水绝大多数人第一次用 Canvas都是在做一个看起来很酷但完全可以用 Container 和 ClipPath 拼出来的效果结果代码写完发现没人维护得动。为什么因为 Canvas 属于那种上手门槛极低、精通门槛极高的 API——drawCircle谁都会写但真正决定一个 Canvas 控件好不好用、卡不卡的是绘制之外的那些东西重绘边界、对象复用、坐标系变换、命中检测、离屏渲染的内存账。这篇东西不讲入门讲的就是从能画出来到画得对、画得稳、画得不掉帧之间那段路。适合谁看我假设你已经能写基础的CustomPainter知道paint()和shouldRepaint()是干嘛的但遇到过下面这些情况里的任意一种环形进度条拖拽时手指和进度对不上、明明只改了一个数字结果整页都在重绘、加了渐变之后低端机掉到 40 帧、想做个保存为图片的功能结果 OOM、或者用saveLayer做出来的发光效果在部分设备上糊成一片。这些坑我基本都踩过下面按渲染链路理解 → 原语细节 → 完整实战 → 性能与排查 → 进阶能力的顺序捋一遍。1.1 从Widget到Canvas一次绘制到底发生了什么很多人把 Canvas 当成一块白板其实它更准确的定位是一串绘制指令的记录器。你调用的每一个drawXxx本质上是在往一个指令列表里追加一条命令而不是立刻把像素涂上去。这个认知差异非常关键因为它直接解释了后面所有的性能现象。完整的链路大致是这样Widget 树经过 Element 和 RenderObject 的构建最终落到RenderCustomPaint这个渲染对象上它在 performLayout 阶段确定自己的尺寸然后在 paint 阶段拿到一个PaintingContext把 Canvas 和一个偏移量交给你的CustomPainter.paint()。你的绘制指令被记录下来Flutter 引擎再把整棵渲染树的指令合成、提交给 GPU或者 Skia 的软件光栅化路径去真正光栅化。也就是说paint 被调用不等于像素被重绘中间还有录制、合成、光栅化几道关。理解这一层之后几个常见困惑就通了。比如为什么我shouldRepaint返回 false改了数据界面还是没变——因为你的绘制指令压根没重新录制。又比如为什么saveLayer特别贵——因为它强制引擎在合成阶段插入一张离屏纹理这张纹理的创建、清空、回读都是要花钱的跟你在 Canvas 上画多少个圆完全不是一个量级的开销。还有一点容易被忽略paint()里的Size是逻辑像素不是物理像素。你在 3x 屏的机器上画一条strokeWidth 1的线实际占 3 个物理像素。这解释了为什么同样的代码在不同设备上看起来粗细不一样也解释了为什么抗锯齿在某些低倍屏上看起来像糊了一层灰。注意不要把 Canvas 当成离屏缓冲区来用。它更像一个指令流所以你在 paint 里创建的对象Path、Paint、TextPainter每帧都会被重新构造一遍这是最常见的隐性开销来源。1.2 该用Canvas和不该用Canvas的分界线这几年我见过太多为了炫技而上 Canvas的代码也见过一些该上却没上的场景。总结下来有一条比较硬的判断标准看这个 UI 是否需要连续的、由数据驱动的、非矩形或非标准组件的几何表达。该上 Canvas 的典型场景有三类。第一类是数据可视化比如折线图、环形进度、仪表盘、热力图这些图形的形状和数量都随数据变化用 Widget 堆叠既写不出来也跑不动。第二类是高频刷新的自绘组件比如手写板、签名板、绘图工具、实时波形图这类场景的核心诉求是每帧只重绘这一小块画布Widget 体系的重建成本在这里是纯粹的浪费。第三类是需要像素级控制的效果比如自定义渐变遮罩、不规则裁剪、粒子动画、实时滤镜预览这些用ClipPath加ShaderMask拼也能拼但组合层数一多性能反而比一次自绘更差。不该上 Canvas 的场景同样明确。凡是能用ContainerBoxDecorationClipRRect稳定表达的东西不要自绘——因为 Widget 体系免费帮你处理了 RTL 布局、无障碍语义、状态恢复、主题切换、热重载这些琐碎但有价值的东西自绘全部要自己补。凡是需要大量文本排版的界面也别自绘TextPainter在复杂文本布局上的能力远不如Text组件而且你没有免费的文字选择、复制、无障碍朗读。还有就是只在启动页出现一次的装饰性图形为一个静态效果引入一个 Painter 类属于给自己挖坑。我一般的做法是先用 Widget 拼一版如果拼出来的层级超过 5 层、或者每帧都要用setState触发大量重建再考虑下沉到 Canvas。这个顺序反过来的成本往往更高——你已经写了两千行自绘代码才发现有个地方需要无障碍支持那就很难补了。2. 绘制原语Canvas、Paint、Path 三件套的正确用法Canvas 的 API 名字都很直白但每个参数背后的默认值和副作用才是真正要命的地方。这一节挑几个我实际被坑过、且文档里往往一句话带过的点讲透。2.1 Paint那些年被忽略的六个属性Paint是绘制状态的容器绝大多数人只会设color和style实际上它身上至少还有六个属性会显著影响最终效果和性能。第一个是isAntiAlias默认 true。听起来无害但在大量小图元比如几百个半径 2px 的点的场景下抗锯齿会明显增加光栅化开销。我做过一个散点图两万个点关掉抗锯齿之后帧率从 45 涨到接近 60代价是点在缩放时会有点毛边。这个取舍要看具体场景图元越小越密关掉越划算。第二个是strokeCap和strokeJoin。默认都是butt和miter翻译过来就是平头和尖角。做环形进度条的时候如果你不设strokeCap StrokeCap.round进度条的起止两端就是刀切一样的平面视觉上会很僵硬而miter在锐角路径上会产生很长的尖刺做折线图的时候必须改成StrokeJoin.round或者bevel否则尖角会戳出去一大截。第三个是blendMode默认srcOver也就是正常叠加。这个东西的坑在于它跟saveLayer是强绑定的很多混合模式比如srcIn、dstOut、multiply如果不配合saveLayer使用作用范围会是整块画布而不是你以为的局部区域结果就是整页颜色被莫名其妙地改掉。第四个是maskFilter用来做模糊和发光。MaskFilter.blur(BlurStyle.normal, sigma)里的 sigma 不是像素半径而是高斯模糊的标准差实际扩散范围大约是 3 倍 sigma。它的性能开销跟模糊半径基本成正比而且在新一代渲染后端上的表现和旧后端差异较大做发光效果时最好在目标设备上实测不要照着网上的参数抄。第五个是shader。一旦设置了 shadercolor就会被完全忽略——这个坑我踩过不止一次明明设了红色结果画出来是渐变色查了半天才发现是上面某处给同一个 Paint 对象设过 shader。所以我的习惯是Paint 对象不要跨用途复用一个用途一个实例宁可多建几个。第六个是filterQuality只对图片绘制有效。默认low缩放图片时会有明显锯齿。做图片裁剪预览时改成FilterQuality.medium会好很多但high在低端机上的开销非常可观非必要不用。2.2 Path几何描述的唯一入口Path是 Canvas 里唯一能表达任意形状的东西但它的 API 设计有几个反直觉的地方。moveTo和lineTo的区别是抬笔和落笔。连续lineTo会形成折线中间插一个moveTo就会断成两条独立的子路径。子路径这个概念在填充时非常重要默认的填充规则是PathFillType.nonZero多条子路径重叠时按环绕方向决定填充区域改成evenOdd之后重叠区域会被挖空。做那种甜甜圈形状或者文字镂空效果靠的就是这个规则切换比用saveLayer加混合模式简单得多。曲线部分quadraticBezierTo是二次贝塞尔只有一个控制点适合做对称的弧线cubicTo是三次贝塞尔两个控制点能做出 S 形曲线。很多人以为贝塞尔曲线的控制点在曲线上其实不是——曲线只是被拉向控制点永远不会真正经过它除了端点。我早期做图表平滑曲线的时候把数据点直接当控制点用结果曲线完全穿越不到数据点上画出来像一条飘着的蛇。正确的做法是用 Catmull-Rom 转贝塞尔的公式根据前后两个点算出控制点这样曲线才会平滑地穿过每个数据点。arcTo和arcToPoint的差别也值得说一句。arcTo(rect, startAngle, sweepAngle, forceMoveTo)的第四个参数如果传 true等价于先moveTo到弧的起点再画弧会断开当前子路径传 false 则会用一条直线把当前点和弧起点连起来。做圆角矩形的时候如果用错这个参数角上会多出一条莫名其妙的斜线。还有一个特别好用但少有人提的工具是PathMetric。path.computeMetrics()返回的是路径的测量信息包含总长度、每一段的切线角度等等。它最典型的用途是路径动画比如让一个点沿着路径匀速移动或者让一条线逐渐生长出来。后者的实现非常优雅——取metric.extractPath(0, metric.length * progress)就能拿到路径的前百分之多少直接drawPath即可完全不用自己算插值。2.3 变换与裁剪save/restore的栈式思维save()和restore()是 Canvas 里最像编程的一对 API它们的本质是状态栈。save()把当前的变换矩阵、裁剪区域、Paint 相关的部分状态压栈restore()弹栈还原。所有translate、rotate、scale、skew、clipXxx都是在修改栈顶的状态。这个机制带来的最重要的一条纪律是成对出现且粒度要细。我见过最典型的坏代码是这样写的——在paint()开头save()中间画了十个元素最后restore()。这样写不会出错但一旦中间某一步需要独立变换你就得手动把它再包一层代码很快会变成俄罗斯套娃。更好的习惯是每个需要独立坐标系的绘制单元自己包一对 save/restore谁的变换谁负责互不干扰。translate和rotate的顺序不能随便换这是线性代数决定的。canvas.rotate(θ)之后再translate(dx, 0)效果是沿旋转后的 x 轴平移反过来先 translate 再 rotate效果是沿原始 x 轴平移后再绕新原点旋转。做钟表刻度、环形排列的图标时几乎都要用到先平移到圆心再旋转再沿半径平移再画这个组合顺序错一步结果就完全不对。裁剪方面clipRect、clipRRect、clipPath都是求交集的语义——裁剪区域只会越来越小永远不会变大。这意味着如果你连续裁了两次不同的区域得到的是它们的交集而不是后者覆盖前者。这一点跟很多人直觉相反我第一次做多层裁剪的时候就栽在这里以为是重新设置裁剪区结果画出来的东西越来越少。saveLayer需要单独拎出来讲。它不是保存状态而是新建一张离屏纹理后续的绘制先画到这张纹理上restore的时候再整体合成回主画布。它的用途是做局部的混合模式、局部透明度、局部模糊。代价是内存和带宽一张跟画布等大的离屏纹理在 3x 屏的 1080p 设备上就是 3240×5760×4 字节接近 75MB。所以使用原则很明确——能用裁剪解决的不用混合模式能用混合模式解决的不用 saveLayer实在要用就把 saveLayer 的矩形范围压到最小。saveLayer(rect, paint)的第一个参数就是控制这个范围的很多人直接传 null等于全画布开一张离屏纹理这是非常奢侈的写法。注意裁剪虽然比 saveLayer 便宜但clipPath在部分渲染后端上仍然需要生成遮罩高频调用比如每帧对几十个元素裁剪会有可感知的开销。静态形状的裁剪建议用clipRRect而不是clipPath。3. 实战手写一个可拖拽的环形进度控件光讲 API 没意思拿一个真实需求走一遍完整流程。这个需求很典型一个环形进度条支持点击和拖拽直接改数值要求在不同尺寸容器里自适应且拖拽跟手不卡顿。3.1 需求拆解与尺寸自适应先把需求拆成可执行的点。视觉上它由三部分组成底部的完整圆环轨道、上层的部分圆弧进度、中间的文字。交互上它需要响应按下和拖动把触点位置映射成 0 到 1 的进度值。性能上拖拽时每一帧都在改值必须做到只重绘这个环不惊动页面上其他任何东西。尺寸自适应是第一个要解决的问题。环形控件常见于卡片里父容器可能是正方形也可能是个扁长方形。我的处理原则是以短边为准算半径这样无论容器什么比例环都不会被裁掉radius (size.shortestSide - strokeWidth) / 2这里减掉strokeWidth是因为描边是以路径为中心向两侧各扩一半如果不减圆环外侧会正好贴着容器边缘看起来像被切了一刀。如果你想留点内边距就再减一个 padding 值。圆心当然取size.center(Offset.zero)这样容器是扁的时环会居中视觉上不会偏。还有一个容易忘的点绘制弧线用的Rect必须是正方形。drawArc的rect参数如果传了非正方形画出来的是椭圆弧不是圆弧。所以正确写法是Rect.fromCircle(center: center, radius: radius)而不是直接用Offset.zero size。3.2 分层绘制与代码实现代码结构上我把 Painter 拆成三个私有方法分别画轨道、画进度、画文字。这样每一块都能单独测试、单独优化改一个不影响另一个。class RingProgressPainter extends CustomPainter { RingProgressPainter({ required this.progress, required this.trackColor, required this.progressColor, required this.strokeWidth, required this.labelStyle, Listenable? repaint, }) : super(repaint: repaint); final double progress; final Color trackColor; final Color progressColor; final double strokeWidth; final TextStyle labelStyle; override void paint(Canvas canvas, Size size) { final center size.center(Offset.zero); final radius (size.shortestSide - strokeWidth) / 2; if (radius 0) return; final rect Rect.fromCircle(center: center, radius: radius); _drawTrack(canvas, center, radius); _drawProgress(canvas, rect); _drawLabel(canvas, center); } void _drawTrack(Canvas canvas, Offset center, double radius) { final paint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color trackColor ..isAntiAlias true; canvas.drawCircle(center, radius, paint); } void _drawProgress(Canvas canvas, Rect rect) { final value progress.clamp(0.0, 1.0); if (value 0) return; final paint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color progressColor ..isAntiAlias true; // 从 12 点方向开始顺时针扫过 canvas.drawArc(rect, -math.pi / 2, 2 * math.pi * value, false, paint); } void _drawLabel(Canvas canvas, Offset center) { final text ${(progress.clamp(0.0, 1.0) * 100).round()}%; final painter TextPainter( text: TextSpan(text: text, style: labelStyle), textDirection: TextDirection.ltr, )..layout(); painter.paint( canvas, center - Offset(painter.width / 2, painter.height / 2), ); } override bool shouldRepaint(covariant RingProgressPainter old) { return old.progress ! progress || old.trackColor ! trackColor || old.progressColor ! progressColor || old.strokeWidth ! strokeWidth || old.labelStyle ! labelStyle; } }几个细节值得单独说明。drawArc的起始角度传-math.pi / 2因为 Canvas 的角度零点在 3 点方向顺时针为正减掉 90 度正好挪到 12 点方向。useCenter参数传 false这样画出来的是弧线而不是扇形——如果误传 true你会得到一个带两条半径连线的披萨切片。clamp那句不能省进度数据来自外部一旦超出 0 到 1 的范围sweepAngle超过 2π 会多画一圈取负值则会反向画视觉上非常诡异。文字居中的写法是先layout()拿到实际尺寸再用圆心减去一半宽高作为绘制原点。这一点跟 Widget 体系的Align完全不同Canvas 没有居中对齐这种概念一切都要自己算。layout()必须在paint()之前调用否则width和height都是 0文字会画到左上角去。3.3 手势命中检测别用方形热区糊弄人这是环形控件最容易翻车的地方。默认情况下如果你的控件在一个GestureDetector里整个矩形区域都会响应点击包括环中间那一大块空白。用户点在圆心上进度突然跳到某个值体验非常糟糕。解决办法是自己做命中检测。判断点是否落在圆环上其实很简单算触点到圆心的距离跟半径比较落在strokeWidth / 2的容差范围内就算命中。容差我一般会额外加 8 到 12 个逻辑像素因为手指有粗细严格贴着环边缘反而不好点。bool hitTestRing(Offset local, Size size) { final center size.center(Offset.zero); final radius (size.shortestSide - strokeWidth) / 2; if (radius 0) return false; final distance (local - center).distance; final tolerance strokeWidth / 2 12; return (distance - radius).abs() tolerance; }把触点映射成进度值用的是极坐标反算。先求触点相对圆心的向量用atan2拿到角度再把它平移旋转到12 点方向为 0、顺时针递增的约定上double valueFromOffset(Offset local, Size size) { final center size.center(Offset.zero); final v local - center; // atan2 的零点在 3 点方向逆时针为正这里转换到 12 点、顺时针 var angle math.atan2(v.dy, v.dx) math.pi / 2; while (angle 0) { angle 2 * math.pi; } while (angle 2 * math.pi) { angle - 2 * math.pi; } return angle / (2 * math.pi); }这里有两个坑。第一atan2(dy, dx)的返回值范围是 -π 到 π加完 π/2 之后范围变成 -π/2 到 3π/2必须归一化到 0 到 2π否则会出现整段负值。第二参数的顺序是(dy, dx)而不是(dx, dy)写反了角度会整体镜像表现为顺时针拖拽数值反而变小。还有一个体验细节拖拽的时候如果手指稍微往圆心方向偏一点按严格的距离判断就会判定为离开圆环从而中断手势。我的处理是在onPanStart判定命中之后就锁定状态后续的onPanUpdate不再做半径判断只更新角度。这样手指即使滑出环外进度依然跟手松手时才结束。这个细节决定了控件跟不跟手比任何绘制优化都更重要。至于动画驱动千万不要在拖拽的回调里setState。正确姿势是给 Painter 的构造函数传一个Listenable也就是CustomPainter的repaint参数——它的语义是这个 Painter 的绘制内容会随这个 Listenable 变化引擎会直接把变化通知到渲染对象上触发重绘跳过 Widget 重建和布局两个阶段。用AnimationController或者自定义的ChangeNotifier都行实测下来在 60fps 拖拽场景下这条路径的 CPU 占用比setState低了不止一个档次。4. 让Canvas跑满帧性能优化与重绘控制Canvas 的性能问题九成不是画得太多而是重绘的范围太大或者每帧都在造对象。这一节讲三个层面的控制。4.1 shouldRepaint 和 repaint 参数的真实差别这两个东西经常被混为一谈但职责完全不同。shouldRepaint(oldDelegate)回答的是一个问题当父组件重建、生成了一个新 Painter 实例时我要不要重新记录绘制指令返回 false 意味着引擎认为新旧绘制结果一致直接复用上一帧录制的指令。注意它只在Painter 实例被替换时才有机会被调用如果你用repaint传 Listenable 的方式驱动走的是另一条不经过shouldRepaint的快捷路径。这里有个流传很广的误解shouldRepaint返回 false 就永远不会重绘。这不成立因为尺寸变化会强制重绘。RenderCustomPaint在布局阶段发现 size 变了会markNeedsPaint此时无论shouldRepaint返回什么都不影响结果。这也是为什么很多人在做响应式布局时发现shouldRepaint的优化失效了——不是失效是你触发了尺寸变化这条独立路径。repaint参数则是一个性能开关。传入一个Listenable之后这个 Listenable 每次notifyListeners引擎会直接标记渲染对象需要重绘整个过程不碰 Widget、不碰 Element、不重跑 build。对于一个每秒变化几十次的数值型绘图这个差别非常明显走setState要重建整棵子树走repaint只影响一个渲染对象。我的一般组合是与绘制相关的连续变化动画值、拖拽进度、实时光标位置走repaint结构性变化图层数量、数据源增删、样式大改走正常的 Widget 重建并在shouldRepaint里做精确比较。两边各司其职不要指望一个机制解决所有问题。还有一个细节shouldRepaint里的比较一定要逐字段比不要偷懒写 true。写true等于放弃优化每次父组件重建都重新录制指令写 false更危险数据变了界面不动属于最难排查的 bug 之一。如果字段特别多比如十几个配置项可以维护一个不可变的配置对象直接比较对象引用前提是这个对象每次变化都重新创建。4.2 RepaintBoundary、saveLayer与离屏开销RepaintBoundary是另一条独立的优化路径。它做的事是给这个子树开一个独立的图层子树重绘时父级和兄弟节点不受影响。反过来父级重绘时这个子树的图层可以直接复用前提是它自己没有变化。什么时候该加判断标准是这个子树是否高频、独立地变化。一个每帧都在转的加载动画、一个实时刷新的进度环、一个手写板画布都适合包一层RepaintBoundary。反过来如果这个子树本身很少变包了反而多一层合成开销还会增加显存占用。这里有个很隐蔽的坑RepaintBoundary在CustomPaint外面还是里面效果不一样。放在外面整个CustomPaint的绘制都在独立图层里放在里面作为CustomPaint的 child只隔离了 child 部分Painter 自己的绘制还是跟着父级走。搞不清这个关系的时候最靠谱的办法是打开性能分析工具里的重绘彩虹条repaint rainbow看看哪块在闪。saveLayer前面说过是离屏纹理这里补一个具体的成本估算。假设一块 300×300 逻辑像素的区域在 3x 屏上就是 900×900 物理像素RGBA 每像素 4 字节一张纹理接近 3.2MB。如果这个saveLayer在动画里每帧都创建60 帧就是每秒钟 190MB 的显存吞吐中低端机基本扛不住。所以对于发光、阴影这类效果我的优先顺序是能用maskFilter单独给某个图元加模糊就绝不用 saveLayer必须整体模糊时把 saveLayer 的 rect 精确卡在内容边界上并考虑降低分辨率再放大。另外模糊半径也不是越大越好。MaskFilter.blur的 sigma 超过某个阈值之后视觉收益急剧下降开销却线性上涨。做发光时我通常把 sigma 控制在 8 以内需要更强的光晕就用多层叠加每层 sigma 小一点效果反而更自然。4.3 对象复用与常见性能陷阱paint()每帧都会被调用所以里面每一行new都是在给 GC 添负担。我把这条当作硬纪律除了极少数确实需要每帧变化的临时对象其他全部提到字段里缓存。需要缓存的东西包括Paint对象按用途各建一个改属性比重建便宜得多、静态的Path比如图标轮廓、装饰图形、TextPainter前提是文字内容和样式没变、ui.Image、ui.Gradient生成的 Shader。尤其是 Shaderui.Gradient.linear每次调用都会创建新的原生对象在动画里每帧创建会触发大量跨语言调用实测这一项就能吃掉 10 帧左右。需要提醒的是缓存的失效时机。颜色变了要重建 Paint尺寸变了要重建依赖尺寸的 Path 和 Shader字体或内容变了要重新layout()TextPainter。我一般的做法是在 Painter 里维护一组私有字段加脏标记在shouldRepaint里比对字段决定是否失效而不是简单地每次 paint 都重建。还有几个隐蔽的陷阱值得一提。避免在 paint 里做Size之外的测量比如读MediaQuery、读Theme这些应该在 Widget 层拿好再传进来否则一旦外部依赖变化重绘时机完全不可控。避免在 paint 里调用异步方法paint 是同步的异步拿到的数据只能等下一帧。注意Canvas的坐标系是相对当前图层的如果你的CustomPaint外面有一层Transform那么localPosition和 Canvas 坐标之间还要多做一次转换用RenderBox.globalToLocal才能对齐。最后是一个个人经验给每个复杂控件写一个静态截图对比的测试。用PictureRecorder把绘制结果录成图片跟基准图做像素比对任何一次重构导致视觉回归都能立刻发现。这比肉眼盯着模拟器看靠谱得多尤其在做跨版本升级或渲染后端切换时。5. 文字、图片与ShaderCanvas的进阶三件套前面三节覆盖了几何图形这一类绘制但真实需求里Canvas 上面通常还要放文字、放图片、放渐变。这三样东西各有各的坑。5.1 TextPainter 的正确缓存姿势TextPainter的用法看起来很简单构造、layout()、paint()。但它在 Canvas 里的开销远比你想象的大因为layout()背后是一整套文本排版流程——字体匹配、字形布局、断行计算、字形缓存查找。做文字量大的图表标签时如果不缓存帧率会掉得非常难看。缓存的条件是三不变文本内容不变、样式不变、约束宽度不变。任何一个变了都要重新layout()。所以在 Painter 里我会这样组织TextPainter? _cachedText; String? _cachedString; TextPainter _getText(String text, TextStyle style, {double? maxWidth}) { if (_cachedText ! null _cachedString text) { return _cachedText!; } _cachedText TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, maxLines: 1, ellipsis: …, )..layout(maxWidth: maxWidth ?? double.infinity); _cachedString text; return _cachedText!; }注意textDirection是必填的不传会直接抛异常。这个参数看起来多余因为 Canvas 本身不关心方向但TextPainter在断行和标点位置的处理上依赖它省略不掉。layout(maxWidth:)的默认值是无穷大也就是不换行。如果你的文字需要限定宽度内自动折行必须显式传值。传值之后painter.height才是折行后的真实高度用它做垂直居中才准确。还有一个容易出问题的地方文字基线。paint()传的Offset是文字绘制框的左上角不是基线。如果你要跟一条水平线对齐必须用painter.computeDistanceToActualBaseline(TextBaseline.alphabetic)拿到基线偏移量再去减否则视觉上总会偏那么几个像素。做坐标轴标签的时候这个偏差特别明显。5.2 drawImageRect 与图片裁剪画图片有两个 APIdrawImage和drawImageRect。前者是原尺寸贴在指定位置后者可以在源矩形和目标矩形之间做任意映射是更常用也更该用的那个。做头像、缩略图这类需求时标准的流程是先算出源图里的最大内接正方形或者你需要的比例再用drawImageRect把这块源区域映射到目标区域。这样做的好处是不需要额外解码或裁剪原图一次调用完成缩放和裁剪void drawCover(Canvas canvas, ui.Image image, Rect dst) { final srcRatio image.width / image.height; final dstRatio dst.width / dst.height; late final Rect src; if (srcRatio dstRatio) { // 源图更宽按高度对齐左右裁掉 final w image.height * dstRatio; src Rect.fromLTWH((image.width - w) / 2, 0, w, image.height.toDouble()); } else { final h image.width / dstRatio; src Rect.fromLTWH(0, (image.height - h) / 2, image.width.toDouble(), h); } final paint Paint()..filterQuality FilterQuality.medium; canvas.drawImageRect(image, src, dst, paint); }ui.Image的获取有两种路径。如果是从资源包加载的本地图先decodeImageFromList或instantiateImageCodec拿到ui.Image之后缓存起来如果是网络图需要先下载字节再解码并且要处理图片还没加载完的情况——这时候 Canvas 上什么都不画或者画个占位。这两件事都必须在 Widget 层完成paint()里绝对不能发起异步请求。内存方面有个经验值一张ui.Image占用的内存大约是width × height × 4字节。一张 4000×3000 的手机照片解码出来就是 48MB如果同时缓存十几张很容易触发内存警告。正确做法是解码时就按需下采样用instantiateImageCodec的targetWidth和targetHeight参数让解码器直接输出小尺寸图比解码后再缩放省下大量内存。5.3 Shader渐变、扫光与纹理Shader 是 Canvas 里最能把效果拉开档次的东西。常用的有三种ui.Gradient.linear、ui.Gradient.radial、ui.Gradient.sweep分别对应线性渐变、径向渐变和角度渐变。线性渐变的两个端点决定了渐变方向颜色数组和停止点数组必须等长。如果停止点传 null会自动均分。这里有个小技巧把两个端点设置得比图形本身更大可以让渐变只显示中间的一段避免首尾颜色在边缘处出现生硬的截断。角度渐变sweep是做环形进度条的高级模式利器。思路是给进度弧线设一个以圆心为原点的 sweep shader色标跟着进度走就得到了一个颜色随进度流动的效果。做加载转圈时配合一层半透明的遮罩能做出很有质感的扫光。ui.ImageShader则用来做纹理填充比如给文字或图形填上图案。它的参数里有个matrix是 4×4 变换矩阵用Matrix4构造后转成Float64List传进去控制纹理的缩放和平铺。平铺模式选TileMode.repeated时记得纹理本身的尺寸要能被识别我第一次用时忘了设 matrix纹理贴着左上角画只填了一小块查了半天才反应过来。需要提醒的是 Shader 的跨后端差异。新一代渲染后端对复杂 shader 的处理路径和旧后端不完全一致特别是涉及到局部坐标系和裁剪叠加时边界表现可能不同。我的做法是所有依赖 shader 的效果至少在两类设备上实测——一台中低端安卓、一台较新的 iOS 设备两边都过了再合入。参数不要照搬网上教程宁可自己从保守值开始试。6. 离屏渲染用PictureRecorder把画布存下来PictureRecorder这个东西用好了能解锁一大类功能用不好就是内存灾难。6.1 截图、缩略图与图片导出的实现它的核心用途是把绘制指令录制成一个Picture然后把它光栅化成一张ui.Image。这条路径最有价值的场景是把 Canvas 上的内容变成一张真正的图片比如分享卡片生成、绘图工具导出、图表保存。基本流程是四步录制、绘制、结束、光栅化。Futureui.Image snapshot({ required Size size, required double pixelRatio, required CustomPainter painter, }) async { final recorder ui.PictureRecorder(); final canvas Canvas(recorder); // 用 scale 把逻辑坐标换算成物理像素 canvas.scale(pixelRatio); painter.paint(canvas, size); final picture recorder.endRecording(); final image await picture.toImage( (size.width * pixelRatio).round(), (size.height * pixelRatio).round(), ); picture.dispose(); return image; }几个要点。canvas.scale(pixelRatio)这步是做高清导出的关键——如果你不缩放直接按物理像素尺寸调toImage画出来的内容会挤在左上角一小块。缩放之后你在paint里用逻辑像素写的所有坐标都能原样复用不用改一行绘制代码。toImage的宽高参数是物理像素所以要做size × pixelRatio的换算并取整。取整这一步别省传浮点数在某些平台上会有精度问题。导出 PNG 字节流用image.toByteData(format: ui.ImageByteFormat.png)这个方法是异步的返回的ByteData可以直接写文件或上传。如果只是要在界面上显示用RawImage包一下ui.Image就行比转成字节再解码回来快得多。还有一点picture.dispose()和image.dispose()该调就调尤其是批量生成缩略图的场景不手动释放很容易在长时间使用后内存持续上涨。6.2 导出时的像素比与内存坑导出功能最容易出问题的地方是像素比的选择。用户想导出一张高清图片你把 pixelRatio 设成 4尺寸 1080×1920算一下4320×7680 像素乘 4 字节一张图 132MB。在中低端机上这个分配直接导致崩溃而且崩的时候往往没有有用的报错信息。我的做法是加一道尺寸上限保护先算出目标物理尺寸如果任一边超过一个阈值比如 4096就按比例把 pixelRatio 降下来。这个阈值不是拍脑袋定的很多设备的 GPU 纹理上限就是 4096 或 8192超过之后要么失败要么走软件路径性能断崖式下跌。double safePixelRatio(Size size, double desired, {double maxSide 4096}) { final longest math.max(size.width, size.height); final limit maxSide / longest; return math.min(desired, limit); }另一个坑是导出的图片和屏幕上看到的颜色不一致。原因通常是背景透明度——屏幕上你的画布下面是页面背景色看起来是白的导出时背景透明保存成 PNG 在某些看图软件里会显示成黑底。解决办法很简单在paint之前先canvas.drawRect(Offset.zero size, Paint()..color backgroundColor)涂一层底色。如果导出的是带文字的卡片还要注意文字的渲染差异。某些字体的字重和字距在离屏渲染时和屏幕渲染略有不同尤其是中文字体。做严格的视觉交付时建议导出后人工比对一次不要完全信任自动化测试的像素比对——两边用的渲染路径可能不同。7. 常见问题与排查实录这一节把我这些年被问得最多、以及自己踩得最惨的问题整理出来。排查这类问题有个通用思路先分清是没画还是画错了还是画慢了三类问题的定位路径完全不同。7.1 从现象到根因的排查路径现象一改了数据界面纹丝不动。九成是shouldRepaint返回了 false或者 return 的比较条件没覆盖到你改的那个字段。排查方法很土但很有效临时把shouldRepaint改成 true如果界面动了问题就锁定在这里再逐个字段加回去定位。如果改成 true 还是不动那就不是重绘问题往上找——数据是不是压根没传进来或者 Painter 实例被缓存了没重建。现象二拖拽时手指和图形对不上越拖越偏。优先怀疑坐标系。GestureDetector给的localPosition是相对这个 Widget 的但如果你的CustomPaint外面包了Padding、Transform、SafeArea两者的原点就不一样了。最稳的写法是用context.findRenderObject() as RenderBox然后globalToLocal(details.globalPosition)这样无论外面套多少层都能对齐。另一个可能原因是角度公式的参数顺序写反表现为镜面翻转而不是偏移。现象三动画掉帧但 CPU 占用不高。这种情况多半在 GPU 侧。优先查三样东西有没有滥用saveLayer离屏纹理的创建和合成是 GPU 开销、有没有每帧创建 Shader跨语言调用加资源分配、页面上是不是叠了太多RepaintBoundary合成层过多同样有代价。用性能分析工具看一下光栅化耗时超过一帧预算就说明确实是 GPU 瓶颈。现象四低端机上图形边缘有明显的锯齿或模糊。两个方向。锯齿通常是isAntiAlias被关了或者图片的filterQuality太低模糊则往往是设备像素比适配没做好——你用逻辑像素算的坐标在 3x 屏上换算后落在半像素位置抗锯齿算法会把它抹开。解决办法是把关键坐标对齐到1 / devicePixelRatio的整数倍或者干脆用strokeWidth是偶数、位置在整数上的画法。现象五导出的图片模糊。检查toImage的宽高是不是用了逻辑像素而不是物理像素以及有没有做canvas.scale(pixelRatio)。这两个地方漏掉任何一个导出的图都只有屏幕分辨率的一半。7.2 速查表现象最可能的原因快速验证处理方式数据变了界面不动shouldRepaint返回 false 或比较漏字段临时改成 true逐字段精确比较或改用不可变配置对象比引用尺寸变化后不重绘误以为 shouldRepaint 控制一切拖动窗口改变尺寸尺寸变化走独立路径不用处理拖拽偏移坐标系未转换打印 local 和 global 坐标用globalToLocal统一拖拽方向反了atan2参数顺序写反换个象限点击看数值走向改成atan2(dy, dx)动画掉帧但 CPU 不高saveLayer 或 Shader 每帧创建看光栅化耗时缓存 Shader收窄 saveLayer 矩形边缘锯齿明显抗锯齿关闭或像素比未适配放大截图看边缘开isAntiAlias坐标对齐半像素圆环外侧被切半径未减去描边宽度的一半量一下边缘radius (shortestSide - strokeWidth) / 2导出图模糊toImage用了逻辑像素检查导出尺寸加canvas.scale(pixelRatio)导出图背景变黑未绘制底色PNG 透明用看图软件看透明通道绘制前先铺一层背景矩形文字跑偏未按 layout 尺寸居中打印 painter.width/height用center - Offset(w/2, h/2)文字折行高度不对未传maxWidth或未读折行后高度长文本试一下显式传maxWidth用painter.height发光效果糊成一片sigma 过大或后端差异减小 sigma 对比sigma 控制在 8 以内多层叠加内存持续上涨ui.Image或Picture未释放反复进出页面观察手动dispose()解码时下采样8. 几个容易被忽略但很值钱的实践细节写到这里前面讲的都是怎么把东西画出来最后补几条偏工程侧的经验这些在文档里基本找不到但真正决定代码能不能长期维护。第一条给 Painter 的构造参数做一次扁平化。我见过最夸张的一个 Painter 有 23 个构造参数改一个样式要翻半天。更好的做法是把视觉相关的配置聚合成一两个不可变的数据类Painter 只接收这个类shouldRepaint直接比引用。这样既减少了参数爆炸又天然获得了配置不变就不重绘的优化还方便做主题切换——换一个配置实例就整块换肤。第二条把几何计算和绘制分开。命中检测、角度换算、路径生成这些逻辑不要塞在paint()里提成独立的纯函数或者单独的类。好处有三可以单独写单元测试不需要跑起整个 Widget可以在手势回调里复用同一套计算保证看到的和点到的永远一致出问题时能快速二分定位是算错了还是画错了。我现在的习惯是每个复杂自绘控件都配一个XxxGeometry类专门负责坐标和尺寸的换算Painter 只负责调用。第三条注意新旧渲染后端的差异但别过度恐慌。渲染引擎在迭代同一个效果的实现细节可能变。我遇到过的情况包括某些混合模式在离屏图层上的表现有细微差异、大半径模糊的边缘过渡略有不同、clipPath在高复杂度路径下的抗锯齿程度不一致。应对策略不是避开所有新特性而是在 CI 里保留一组渲染基准图升级 SDK 或切换渲染后端时跑一遍肉眼比对差异确认是可接受的视觉变化再放行。这比上线后被用户截图反馈要好得多。第四条做复杂图形时先画调试层。我习惯在开发阶段给 Painter 加一个debug开关打开时把关键几何元素画出来——圆心画个十字、半径画个虚线圆、命中区域画个半透明色块、触点的角度画条射线。这个调试层在排查为什么点不中为什么偏了这类问题时效率比打日志高十倍。上线前用常量关掉零成本。第五条控制单次绘制的图元数量。Canvas 在几千个图元的量级上还能扛到几万个就会明显掉帧因为每个drawXxx都是一次独立的指令和状态切换。真要画海量图元出路只有两条合并成少量Path或者drawRawPoints这类批量 API以及做视口裁剪——只画当前可见区域内的东西。后者在图表场景里特别有效数据有十万个点屏幕上只显示一百个那就只算这一百个。第六条不要迷信自绘一定比 Widget 快。这条我特意放在最后因为见过太多反向优化的案例。一个只有三层结构的卡片用 Widget 拼是零成本的因为 Flutter 的布局和绘制都有缓存改成自绘之后你反而要自己处理文字排版、圆角裁剪、阴影、点击反馈代码量翻三倍性能还不一定更好。自绘真正的优势场景是高频局部刷新和非矩形几何其他情况下先试试 Widget 组合通常不会让你失望。我个人在实际项目里的体会是Canvas 的能力边界不在于能画出什么而在于能不能用一套可维护的结构把它管住。同样是画一个仪表盘一个人写了两百行、三个月后自己都改不动另一个人写了四百行、拆成了几何层、绘制层、交互层半年后加一个指针动画只花了二十分钟。中间差的不是 API 熟练度而是有没有在一开始就把变化的部分和不变的部分分开。绘制逻辑、几何计算、状态管理这三样东西混在一起的代码无论画得多漂亮都撑不过第二次需求变更。