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 的场景同样明确。凡是能用Container+BoxDecoration+ClipRRect稳定表达的东西,不要自绘——因为 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。一旦设置了 shader,color就会被完全忽略——这个坑我踩过不止一次,明明设了红色结果画出来是渐变色,查了半天才发现是上面某处给同一个 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。尤其是 Shader,ui.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. 文字、图片与Shader:Canvas的进阶三件套
前面三节覆盖了"几何图形"这一类绘制,但真实需求里,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 与图片裁剪
画图片有两个 API:drawImage和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 上的内容变成一张真正的图片",比如分享卡片生成、绘图工具导出、图表保存。
基本流程是四步:录制、绘制、结束、光栅化。
Future<ui.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 熟练度,而是有没有在一开始就把"变化的部分"和"不变的部分"分开。绘制逻辑、几何计算、状态管理,这三样东西混在一起的代码,无论画得多漂亮,都撑不过第二次需求变更。