首页 / 资讯中心 / 文章详情

Flutter Canvas 绘制链路与 CustomPainter 性能优化实战

Flutter Canvas 绘制链路与 CustomPainter 性能优化实战 ★ FEATURED ARTICLE
做列表页那会儿遇到过一个特别典型的问题一个商品卡片列表滚动起来总有一点点不跟手帧率看着也不低就是那种说不清的黏滞感。翻来覆去查了半天最后定位到卡片右上角那个小小的折扣徽章——它是个 CustomPaint每帧都在paint里重建Path对象一屏十几个卡片滚动时每帧新建几十个 Path 再交给引擎UI 线程的耗时曲线就此被抬起来了。把 Path 提到构造函数里只建一次之后那股黏滞感当场消失。这件事让我重新审视了 Flutter 里的 Canvas 能力它不像 Widget 那样有现成的布局、约束、重组机制帮你兜底一切都得自己算、自己管、自己负责释放。这篇文章就围绕 Flutter 的 Canvas 绘制能力展开从绘制链路、CustomPainter 的生命周期、Path 的高级用法一路聊到坐标系换算、动画驱动、导出图片和实测踩过的坑。适合已经写过几个页面、想把界面细节和性能抠得更细一些的开发者也适合刚接触 CustomPainter 但被shouldRepaint搞得一头雾水的朋友。文中涉及的参数取值、优化手法和取舍逻辑都是我在实际项目里反复验证过的可以照着改。1. Canvas 在 Flutter 里到底是个什么东西很多人第一次用 CustomPainter会把它当成一个画板组件觉得跟浏览器里的 canvas 元素差不多。这个理解方向对但粗糙。Flutter 的 Canvas 不是组件它是一层指令接口你在paint方法里调用的每一个drawRect、drawPath最终都会变成一条记录在图层上的绘制指令等整棵树绘制完毕这批指令再统一交给底层渲染引擎做光栅化。换句话说你写的是描述不是结果。理解这一点后面关于性能、关于缓存、关于为什么有时候改了数据画面没变的疑问就都能自己推导出来了。1.1 从 Widget 树到一条绘制指令的完整链路Flutter 的一次渲染大致走这么几步Widget 描述配置Element 维护树的身份RenderObject 负责布局和绘制。当某个 RenderObject 需要绘制时它会拿到一个PaintingContext从中取到Canvas然后调用自己的paint方法往上面写指令。CustomPaint 这个组件背后是RenderCustomPaint它做的事很朴素布局阶段按size或者子节点尺寸确定自己的大小绘制阶段先画painter背景再画 child最后画foregroundPainter。有一点必须记住这个 Canvas 是共享的、累积的。你拿到的 Canvas 不是你的画布而是当前图层正在被填充的指令流。你在上面调save、restore、clipRect影响的是后续指令的上下文一旦你忘了restore后面同图层里别人的绘制也会被你裁掉。我见过有人的裁剪莫名其妙影响到了兄弟节点排查半天最后发现是canvas.save()之后提前return了restore没执行。养成习惯save和restore成对出现中间的逻辑尽量别用多层嵌套的 early return。再往下指令流被打包成图层树交给引擎。引擎侧的光栅化线程把指令翻译成像素这一步才是真正花 GPU 时间的地方。所以定位性能问题时要分清楚是 UI 线程在拼指令拼得慢通常是代码逻辑重、对象疯狂重建还是光栅化线程在填像素填得慢通常是过度绘制、大面积极 clip、复杂 shader。1.2 Canvas、Paint、Path 三者的分工与常见误用这三个类的关系用画画打比方最直白Canvas 是纸和笔的动作Paint 是笔的属性粗细、颜色、笔触、混合模式Path 是你要画的那条轨迹。三者的生命周期管理方式完全不同误用也集中在各自的盲区。Paint是最容易被滥用的。很多人在paint方法里Paint()..color xxx现场 new 一个逻辑上没问题但在高频重绘场景下就是持续的分配压力。Paint 对象内部要初始化颜色、样式、混合模式、抗锯齿等一堆字段虽然单个开销不大但一帧几十个、一秒六十帧累积起来就是可见的 GC 抖动。正确做法是把 Paint 提到 State 里作为字段颜色变了改字段值而不是换对象。Path的误区更深一层。Path 有两个身份一是几何描述包含哪些点、哪些曲线二是它内部的当前点状态moveTo之后你在哪。后者意味着 Path 是有状态的你不能拿一个已经画完的 Path 去做增量修改而不清楚它现在的游标位置。需要动态变化的图形时我一般准备两个方案要么每次重建简单但贵要么把不变量预先算好、只重建变化的那段。具体怎么选看变化频率和图形复杂度。Canvas的问题主要是上下文污染。clipPath、clipRect、transform、saveLayer这些都会改变后续指令的执行环境。saveLayer尤其要小心它会触发一次离屏渲染把当前内容先画到一个新的缓冲区再合成回来。这东西在需要做整体透明度、整体混合模式的时候是必需品的但用多了性能掉得很快。我的经验是能用一个带 alpha 的 Paint 解决就别用 saveLayer实在需要整组统一效果再考虑。1.3 哪些东西不该用 Canvas 画这一点容易被忽略。Canvas 给了你几乎无限的表达能力但它也绕过了 Flutter 的整套布局和语义系统。有三类东西我强烈建议不要用 Canvas 硬画。第一类是文字排版。TextPainter能用但你要自己算换行、自己处理字体回退、自己处理 RTL 语言的方向、自己处理无障碍语义。一段两行的说明文字用 Canvas 画等于把框架帮你做的十几件事全都接回自己手上。除非是图表里的轴标签、数值标注这类短文本否则老老实实用 Text。第二类是交互元素。Canvas 画出来的东西没有命中测试区域GestureDetector包在外面只能拿到整个 CustomPaint 矩形的事件你要做精确点击就得自己写坐标判断。按钮、开关、可点击的图表节点用 Widget 组合明显更划算。第三类是频繁变化的结构。Canvas 的强项是一帧内一次性画完一组图形弱项是结构化增量更新。如果你画的是一个可以增删改查的复杂列表用 Widget 树让框架做 diff 才合理。用 Canvas 画列表等于自己实现一遍 Flutter 的 diff 算法吃力不讨好。2. CustomPainter 的正确打开方式CustomPainter 是你和 Canvas 之间的桥。这个类只有两个方法必须实现paint和shouldRepaint。绝大多数 Canvas 相关的性能问题和画面不更新的诡异现象根子都在第二个方法上。2.1 shouldRepaint 的返回值到底该怎么写这个方法的签名是bool shouldRepaint(covariant CustomPainter oldDelegate)。它在父组件重建、CustomPaint 拿到新 painter 时被调用返回 true 表示旧画面作废重画一次返回 false 表示新 painter 和旧的一样沿用旧图层。三种常见写法各有风险。返回true恒定重绘最安全也最慢适合图形极简、或者根本不清楚变化来源的场景。返回false恒定不重绘只有当你不传新 painter 实例时才成立一旦外部状态变了就会画面卡死这是新手最容易踩的坑——改了数据界面纹丝不动还以为是自己paint写错了。正确做法是比较所有会影响绘制的字段任何一个不同就返回 true。我习惯的写法是这样class RingPainter extends CustomPainter { const RingPainter({ required this.progress, required this.ringColor, required this.strokeWidth, }); final double progress; final Color ringColor; final double strokeWidth; override bool shouldRepaint(covariant RingPainter old) { return old.progress ! progress || old.ringColor ! ringColor || old.strokeWidth ! strokeWidth; } override void paint(Canvas canvas, Size size) { /* ... */ } }关键是别漏字段。加了新参数忘了加比较就会得到一个间歇性不刷新的神秘 bug而且往往在特定路径下才复现调试成本极高。我的做法是在类注释里写明新增字段必须同步更新 shouldRepaint虽然靠人自觉不够优雅但确实救过几次。还有一个细节如果 painter 依赖了外部可变对象比如一个 List 的颜色数组用比较是不起作用的因为 List 的比较是引用比较。这种情况下要么做深比较列表短的话可以要么在外部维护一个版本号计数器painter 里比较版本号。我倾向后者简单可靠。2.2 Size 到底是什么以及为什么你的图总是画歪CustomPaint 的尺寸来源有两个如果传了 child尺寸由 child 决定如果没传则由size参数决定默认是Size.zero。忘了设 size 是一个高频低级错误表现为代码写得没问题但啥也看不见因为画布是零尺寸画什么都看不见。拿到Size之后要建立一个心理模型(0, 0)在左上角size.width和size.height是右下角所有绘制坐标都落在这个盒子里。需要居中或者按比例布局的时候别硬编码像素值。比如画一个居中的圆半径用size.shortestSide / 2圆心用Offset(size.width / 2, size.height / 2)这样不管是方盒子还是长方盒子都居中。硬编码 50、100 这类数字的代码换个屏幕尺寸就露馅。设备像素比也是一个容易忽略的点。逻辑像素和物理像素之间有devicePixelRatio的换算1 逻辑像素在 2 倍屏上是 2 个物理像素。所以画一条1 像素细线时你要想清楚指的是逻辑像素还是物理像素。要真正锐利的物理 1 像素线条strokeWidth得设成1 / devicePixelRatio同时还要处理坐标对齐详见第 7 节。2.3 图层缓存的三个开关isComplex、willChange 和 RepaintBoundaryCustomPaint 有两个参数常被忽略但它们能决定一个复杂图形是丝滑还是卡顿。isComplex: true告诉引擎这个图形光栅化成本不低值得缓存起来。引擎可能会把光栅化的结果存成一张纹理下次相同参数下直接贴图省掉指令执行。适合的场景是图形复杂大量 Path、渐变、模糊但不常变。反例是每帧都在变的动画缓存反而增加额外开销。willChange: true是反向提示这个东西马上要变别费劲缓存了。两个都设成 false默认就是让引擎自己估通常也没问题。我的经验是静态复杂图形开isComplex动画图形开willChange其余按默认。别两个都开那是自相矛盾的指令。RepaintBoundary是组件层面的边界作用是把一块子树独立成一个图层它重绘时不会连累父级和兄弟。这个在动画场景里价值极大一个页面里有个持续旋转的加载图标如果没给它包 RepaintBoundary整个页面都会跟着重绘包上之后重绘范围被限制在一小块区域内。但要注意它是有成本的每个边界都要额外的合成与内存包得太细碎反而得不偿失。我的判断标准是这个区域是否在高频变化且周围是否相对静态。两个条件都满足才包。3. Path 是被低估的主角Canvas 里能画直线、圆、矩形、图片、文字但这些都只能算基本功。真正把设计稿还原出味道的基本都是 Path。曲线的手感、图标的描边、图表的折线、进度形的轨迹动画全靠它。3.1 从 moveTo 到 cubicTo曲线的手感从哪来Path 的基本动词不多moveTo抬笔移动lineTo画直线quadraticBezierTo二次贝塞尔一个控制点cubicTo三次贝塞尔两个控制点arcTo圆弧close闭合。二次和三次的区别在于可调的自由度。二次只有两个控制点起点终点算端点加一个控制点曲线只能朝一个方向弯适合简单的碗形、拱形。三次有两个控制点可以让曲线先往一个方向弯再往另一个方向弯形成 S 形这就是为什么几乎所有设计工具里画的平滑过渡都是三次贝塞尔。控制点怎么调出好看的效果我的经验是不要把控制点放在你要经过的位置它是引力点而不是途径点。曲线不会穿过控制点只会朝着它弯曲。很多人第一次调曲线把控制点放在想要经过的地方结果曲线只弯了一半然后就一直加控制点越加越乱。正确的心智模型是控制点离端点连线的距离决定了弯曲程度控制点相对连线的偏移方向决定了弯曲方向。想画一条从左下到右上、带一点弧度的曲线起点(0, h)终点(w, 0)两个控制点分别设成(w * 0.3, h * 0.9)和(w * 0.7, h * 0.1)这是我最常用的缓和曲线配方看着舒服不会太夸张。真要精确匹配设计稿就把设计工具里的路径点数据直接抄过来别凭感觉试。3.2 PathMetric让线条长出来的特殊动画有一类效果我特别喜欢用 PathMetric 做加载进度、勾选动画、地图路线绘制——线条从起点一点点延伸出来。常规做法是用虚线模拟或者用裁剪框住一个矩形慢慢放大但都有瑕疵虚线会有断裂感裁剪框在曲线路线上会露出直角边。PathMetric 的思路干净利落先把完整路径切出前面一段比例再把这段画出来。核心 API 是Path.extractPath(start, end)参数是按长度计的起止位置从path.computeMetrics()拿到一串 metric每个 metric 有length属性和extractPath方法。final metrics path.computeMetrics().toList(); final partial Path(); for (final m in metrics) { partial.addPath( m.extractPath(0, m.length * progress), Offset.zero, ); } canvas.drawPath(partial, paint);这里有个细节值得说为什么是循环而不是取第一个 metric因为一个 Path 里可能有多个子路径比如描边文字、多段折线computeMetrics会返回多个 metricextractPath只作用于单个 metric。只处理第一个的话多段图形就会只动画一段。这个坑我在做描边文字动效时踩过排查了好一会儿。另一个细节是extractPath的第二个参数是截断长度超过末端时怎么处理一般传 0 或者保持默认即可。还有一点如果路径特别长、特别复杂extractPath本身也有开销在 60fps 的动画里每帧都执行的话最好提前把路径预处理成较少的段数或者干脆用PathMetric预先算出采样点做插值比每帧重新裁剪便宜。3.3 路径运算与 clipPath 组合出的高级裁切Path.combine提供了四种布尔运算union 并集、difference 差集、intersect 交集、xor 异或。这几个操作的实用价值比想象中高。差集最典型的用法是做镂空。比如一个圆形的头像中间要掏一个透明的播放按钮孔洞或者做一个遮罩层要让某个区域透出来。做法是用外层大形状减去内层小形状再配合drawPath或者clipPath。这比用saveLayerblendMode的方案好理解也更容易调。还有一个不太常见但很好用的场景用交集做圆角不规则图形。有时候设计给的是一个复杂形状但你只想显示它在某个圆角矩形内部的部分用 intersect 一算就得到裁剪后的路径比clipRRect灵活因为你可以对任意路径做裁剪。要注意的是路径运算是有成本的尤其是两个都很复杂的路径做布尔运算。静态图形里随便用动态图形里就要斟酌——每一帧都算一次布尔运算帧预算很容易被吃光。我的一般做法是运算结果不随时间变化就缓存起来只在尺寸变化时重算。4. 手势与坐标系Canvas 上最容易被忽视的一环画出来好看是一回事能正确响应交互是另一回事。Canvas 画的内容没有内置的命中区域所有坐标计算都得自己来。这一节讲的都是踩过坑之后总结的换算套路。4.1 全局坐标到局部坐标的换算GestureDetector的回调给你的globalPosition是屏幕全局坐标而你在 Canvas 里画东西用的是局部坐标。两者之间差了整条祖先链上的偏移量。最稳的做法是用RenderBox的globalToLocalvoid onPanUpdate(DragUpdateDetails details) { final box context.findRenderObject() as RenderBox; final local box.globalToLocal(details.globalPosition); setState(() _pointer local); }这个方法会沿着祖先链把所有偏移都算进去包括 padding、滚动偏移、变换比手动减renderBox.localToGlobal(Offset.zero)可靠得多尤其是页面在有滚动的情况下。手动减偏移的做法我在早期项目里用过页面套了个 Sliver 之后就错了差了滚动距离。还有个更简单的路径如果你不需要跨组件比较坐标很多手势回调其实提供了localPosition。GestureDetector的onTapDown就给的是details.localPosition直接就是相对当前组件的坐标省去一次换算。但要确认一下这个local是不是相对于你想要的那个盒子有嵌套的时候容易搞混。4.2 Transform 之后坐标怎么反算一旦你对 Canvas 做了canvas.translate、canvas.scale、canvas.rotate后面的绘制坐标就是变换后的坐标系里的。这时候你从手势拿到的坐标还是屏幕坐标系直接比较就对不上了。正确做法是用Matrix4做逆变换。变换矩阵存在一个Matrix4对象里matrix.inverse()拿到逆矩阵用MatrixUtils.transformPoint(inverse, point)把点投回去。完整套路是把变换参数存到 State 里绘制时用它构造 Matrix4命中测试时用同一个 Matrix4 求逆再投点。final matrix Matrix4.identity() ..translate(center.dx, center.dy) ..rotateZ(angle) ..translate(-center.dx, -center.dy); // 命中测试 final inverse Matrix4.inverted(matrix); final localPoint MatrixUtils.transformPoint(inverse, globalPoint);这套写法在做可拖拽旋转的图形编辑器时是必须的。容易被忽略的是变换顺序先平移后旋转再平移回来和先旋转后平移得到的结果完全不同。矩阵乘法不满足交换律顺序就是语义。我的习惯是在纸上把变换想成对图形连续做的几步操作按执行顺序写进链式调用出错率最低。4.3 命中测试的几种做法与取舍判断点击是否落在某个 Canvas 图形上常见有三条路。第一条几何判断。矩形用Rect.contains圆用点到圆心距离与半径比较多边形用射线法。优点是快、无分配缺点是每种图形都要单独写逻辑复杂的 Path 很难精确判断。第二条Path.contains。Path 自带命中测试把点传进去就返回是否在内部。这个接口简单直接代价是对于复杂路径有计算开销而且它按填充规则判断PathFillType描边线没有面积的开放路径判断不出点在线上。所以适合判断点在填充图形内部用来判断点在线条上就不合适了。要判断点是否靠近线条得自己算点到线段的距离配合PathMetric的getTangentForOffset做采样或者把线条加粗成带状区域再判断。第三条把图形包成 Widget 用框架的命中测试。这等于放弃了 Canvas 的性能优势只在图形数量很少、交互逻辑复杂时考虑。我的取舍标准是图形数量少几个到十几个就每次点击时遍历做几何判断代码直观图形数量多几十上百个图表节点就先做一次粗筛比如先判断在不在边界框内再做精确判断把开销压在少数候选上。5. 动画驱动的 Canvas别让每帧都重新计算Canvas 和动画结合几乎是必然的进度条、加载态、图表过渡、粒子效果全是这个组合。但很多实现写出来不够流畅原因往往不在画上而在算上。5.1 AnimationController 与 CustomPainter 的桥接方式最直接的写法是在AnimatedBuilder里创建 painter把动画值传进去让shouldRepaint每次返回 true。这样能跑但每次构建都新建一个 painter 对象分配压力不小。稍微好一点的做法是把 painter 提到 State 里作为字段动画帧里只更新它的progress字段再触发重建。因为字段变了shouldRepaint比较后返回 true能动起来同时也少了一次对象分配。再进一步是让 painter 自己去监听动画而不是靠 UI 重建。继承CustomPainter的同时混入Listenable或者自己实现addListener/removeListener在动画变化时调用notifyListeners。CustomPainter 的基类支持话repaint参数可以传一个 Listenable框架会自动在它变化时触发重绘。这个方式的优势是绕开了组件重建动画帧里只有绘制没有 build、没有 element 更新开销低一截。class ParticlePainter extends CustomPainter { ParticlePainter({required this.animation}) : super(repaint: animation); final Animationdouble animation; // ... }一个小细节用这种方式时shouldRepaint依然要正确实现因为父组件重建时还是会调到它。这两个机制是并行的不冲突。5.2 把计算挪出 paint预计算与缓存的实用手法paint方法应该只做把已知数据变成绘制指令一旦里面出现了循环里计算三角函数、每次重新构建 Path、字符串拼接就该警惕了。这些活都是可以在别处算好的。举个具体的例子。画一个带刻度的仪表盘刻度线的位置涉及角度计算60 根刻度就是 120 次三角函数。如果每帧在paint里算一秒钟就是七千多次。但实际上刻度的位置只依赖尺寸跟动画值毫无关系。正确的做法是尺寸变化时didUpdateWidget或者布局回调里算一次把 Offset 数组缓存起来paint里只做循环drawLine。同理Path 的构建也一样。前面开头提到的那个折扣徽章的案例就是我把它从每帧重建挪到了构造时建一次。判断标准很简单这段几何逻辑是否依赖动画值不依赖就缓存。依赖动画值的部分也不是没救。如果是从 A 点运动到 B 点这类线性插值直接Offset.lerp就行没必要调完整的物理模拟。如果是曲线运动可以预先把采样点算成一个数组比如 100 个点动画时按进度索引取值再插值比每帧求值便宜得多视觉效果也几乎看不出差别。5.3 粒子、拖尾与噪声的几个实用配方Canvas 上做效果有几套配方我反复用可以直接抄。粒子系统维护一个ListParticle每个粒子有位置、速度、生命值。每帧更新位置位置加每帧速度与 dt 的乘积生命值减 dt为 0 时移除。绘制时循环drawCircle颜色按生命值做透明度插值。要注意的是别在每帧重建整个 List用removeWhere或者原地压缩避免分配。如果粒子多几百个以上drawCircle会有瓶颈换成drawRawPoints或者预渲染一张小圆图用drawAtlas批量画性能差异很明显。拖尾常见做法是用saveLayer加一个半透明的黑色矩形覆盖让之前的画面逐渐淡出。这个方法视觉效果好但每帧都要离屏合成代价不低。轻量替代是维护一个固定长度的历史位置数组画的时候按索引递减透明度画成一条渐隐的折线。后者在移动端明显更省。噪声抖动做手绘感、故障风的图形需要伪随机。别用Random()每帧取新值画面会剧烈闪烁。正确做法是用确定性的伪随机固定种子按索引取值或者用sin函数的叠加做平滑抖动。这样每一帧的抖动位置是稳定的只有你希望变化的部分才变化。这个细节决定了画面是呼吸感的抖动还是电子噪声。6. 把 Canvas 画的东西导出去有些需求是要把画面变成一张图片生成海报、分享卡片、导出图表。这时候就需要PictureRecorder。6.1 PictureRecorder 与 toImage 的完整流程流程固定三步final recorder PictureRecorder(); final canvas Canvas(recorder); // ... 在这里画 final picture recorder.endRecording(); final image await picture.toImage(width, height); final bytes await image.toByteData(format: ImageByteFormat.png);拿到Uint8List之后存文件或者直接传给分享组件。toImage是异步的里面的 width 和 height 决定了输出分辨率注意它是物理像素还是逻辑像素——这个参数实际上按物理像素理解更稳。一个关键点这个 Canvas 是独立创建的跟屏幕上那个没有任何关系。你在里面画的东西用的是它自己的坐标系不需要考虑父组件的尺寸想画多大画多大。这反而方便可以画一张远超屏幕尺寸的高清图。toByteData的format参数可选 PNG 和 raw RGBA。PNG 编码本身有耗时如果是大图这一步会卡主线程几百毫秒。建议放到 isolate 里做或者改成 raw 格式自己配编解码库。小图比如几百像素可以直接在主线程处理感知不到。6.2 导出时的像素密度与缩放陷阱最常见的坑是导出的图糊。原因通常是尺寸算小了。你在屏幕上看到的是一个 200x200 的逻辑尺寸但用户要的是高清图导出时就应该按200 * 3之类的方式给一个大尺寸而不是照抄逻辑尺寸。正确处理是先canvas.scale(scaleFactor)把绘制坐标系放大再用放大后的尺寸作为toImage参数。顺序不能反先 toImage 大尺寸再缩放绘制内容会得到一张大尺寸但内容居中的图。这个顺序问题我第一次做时搞反了导出的图留了一圈白边查了半小时才反应过来。另一个坑是文字。TextPainter的layout是在某个具体的文本缩放系数下做的导出时如果 scale 了 canvas字体大小会跟着放大但如果你传的 fontSize 是按逻辑像素算的视觉比例可能不对。稳妥起手是先把整张图的绘制逻辑写在一个接受缩放比参数的方法里屏幕绘制传 1导出传目标倍数让所有尺寸都走同一个乘法不走 canvas 全局缩放。7. 实测中踩过的几个坑7.1 抗锯齿与 1 像素线的模糊用strokeWidth: 1画一条横线如果坐标是整数出来往往是 2 像素宽的灰色糊线而不是清晰的 1 像素。原因在于线的中心落在整数坐标上时它恰好横跨两个像素的中心各占一半抗锯齿把两边都染了半透明。解法是对半分偏移从(0, y 0.5)画到(width, y 0.5)。同理竖线偏移x 0.5矩形描边如果也想锐利drawRect的矩形要整体内缩 0.5 或者外扩 0.5。如果要的是一条真正物理 1 像素的线在高分屏上极细的那种还要把strokeWidth设成1 / devicePixelRatio。这个值需要从MediaQuery或者View.of(context).devicePixelRatio取。表格类界面的分隔线用这个技巧效果最好比默认的 Divider 更贴合设计稿。7.2 文字绘制的基线偏移TextPainter画文字时paint方法的 offset 参数是文字的左上角不是基线。这个认知偏差导致很多人的文字位置总是偏上或偏下。居中对齐文字的正确做法是分两步先layout用painter.width和painter.height计算居中偏移再paint。final tp TextPainter( text: TextSpan(text: 42%, style: style), textDirection: TextDirection.ltr, )..layout(); tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));textDirection必须给不给直接抛异常。这个参数看起来多余中文英文都是从左往右但框架要求显式声明因为阿拉伯语之类的语言是反的。还有一个隐蔽的问题文字的实际视觉中心不等于height / 2。字体本身有升降部ascender/descender留白数字和中文的视觉重心往往偏上一点。要精确居中可以用字体的computeLineMetrics拿基线位置手动调或者用一个经验偏移量比如整体下移 1 到 2 逻辑像素。完美主义者的做法是画一个小十字标记在正中心然后肉眼对比着调调到舒服为止。7.3 离屏资源与图片的释放ui.Image是持有原生内存的对象用完必须dispose()。Picture在完成toImage之后也应该释放。这在导出图片、缓存贴图这类场景里很容易漏。症状是内存缓慢上涨久用之后出现内存告警或者卡顿。排查时看 DevTools 的内存曲线如果每次操作都有一小段锯齿上升再回不去基本就是资源没释放。养成习惯凡是toImage、instantiateImageCodec、decodeImageFromList拿到的 Image用完之后立刻 dispose放进 try/finally 里最保险。还有一个更隐蔽的ImageShader持有了 Image 的引用。即使你 dispose 了 Image如果 shader 还在被使用画面可能出问题或者崩溃。要先确保 shader 不再被任何 Paint 使用再释放。8. 性能归因怎么判断是 Canvas 慢还是别的慢看到掉帧别急着优化绘制代码先确认瓶颈在哪。我见过不少人在paint里来回优化最后发现是列表项的 build 里做了耗时计算白忙一场。8.1 用 DevTools 的帧分析定位瓶颈打开 DevTools 的 Performance 面板录一段滚动或者动画然后看帧耗时条。关键看两条轨道UI 线程和 Raster 线程。UI 线程耗时高说明问题在 Dart 侧build 太重、布局太复杂、paint里的逻辑太重。这时候优化方向是减少重建、缓存计算、简化 Path 构建。Raster 线程耗时高UI 线程反而轻说明指令已经送下去了但光栅化填像素填不动。这种情况通常是过度绘制同一个区域被画了很多层、大面积的模糊或阴影、复杂 clip、超大尺寸的图片缩放。优化方向是减少图层叠加、缩小裁剪区域、降低模糊半径、避免无损缩放。两条都高就是混合问题得两边分头看。还有一种情况是两条都不高但就是掉帧那要看是不是在等 GPU 或者等图片解码这类异步等待在帧分析里往往表现为空白间隙。8.2 常见优化手法与收益对比下面这张表是我在实际项目里积累的粗估具体收益跟场景强相关但相对高低基本一致优化手段典型收益适用场景注意点缓存 Path 与 Paint 对象中到高每帧重建图形的场景只缓存不变量正确实现 shouldRepaint高状态变化不频繁的图形别漏字段动画图形加 RepaintBoundary高局部高频动画别包太碎静态复杂图形开 isComplex中图标、复杂插画动画图形别开用 drawAtlas 批量贴图高大量重复小图粒子、图标需预先准备图集减少 saveLayer 使用中到高用了大量图层合成的页面改用带 alpha 的 Paint缩小 clip 区域中大面积裁剪裁剪本身也有成本降低 maskFilter 模糊半径高阴影、发光效果视觉可能受影响补充一个不在表里但收益很大的把绘制结果缓存成 Image 贴图。当某个复杂图形在相当长一段时间内完全不变比如一个复杂的地图底图、一张精心绘制的装饰插画可以一次性画进PictureRecorder转成ui.Image之后每帧只用drawImage贴上去。这个做法的收益是数量级的代价是内存占用和需要管理失效时机。我在一个图表项目里用过复杂网格线和坐标轴从每帧绘制改成贴图之后帧耗时降了一个很明显的量级。需要提醒的是优化之前一定要先量化。我给自己定的规矩是不做没有帧分析依据的优化。凭感觉改代码改完看着好像流畅了一点但实际可能只是心理作用反而把可读性搞坏了。9. 几个可以直接拿去用的实现片段前面聊的偏方法和思路这一节给几个能直接复制的片段省去从零拼装的时间。9.1 通用圆环进度从 0 到 1 的平滑过渡圆环进度是最常见的 Canvas 需求实现要点有三个起始角要从 -90 度开始12 点方向弧线长度按进度算描边端点用圆头。class RingPainter extends CustomPainter { RingPainter({required this.progress, required this.color}); final double progress; final Color color; override void paint(Canvas canvas, Size size) { final center size.center(Offset.zero); final radius size.shortestSide / 2 - 8; final rect Rect.fromCircle(center: center, radius: radius); final bg Paint() ..style PaintingStyle.stroke ..strokeWidth 8 ..color color.withOpacity(0.15); canvas.drawCircle(center, radius, bg); final fg Paint() ..style PaintingStyle.stroke ..strokeWidth 8 ..strokeCap StrokeCap.round ..color color; canvas.drawArc( rect, -math.pi / 2, math.pi * 2 * progress.clamp(0.0, 1.0), false, fg, ); } override bool shouldRepaint(covariant RingPainter old) old.progress ! progress || old.color ! color; }几个容易错的点drawArc的第三个参数是弧度扫过的量不是终点角度也不是百分比。useCenter传 false 才能画成开放式弧线传 true 会连到圆心变成扇形。strokeCap只在style: stroke下生效strokeWidth是线的宽度实际绘制时圆环的厚度会向内外各扩一半所以半径要预先减掉一半线宽否则会被裁掉。9.2 折线图的坐标映射封装图表类的绘制核心是把数据值映射到画布坐标。建议封装两个小函数避免到处都是魔法数字。double mapY(double value, double minV, double maxV, Rect area) { final t (value - minV) / (maxV - minV); return area.bottom - t * area.height; } Offset mapPoint(int i, double v, int count, Rect area, double minV, double maxV) { final x area.left (i / (count - 1)) * area.width; return Offset(x, mapY(v, minV, maxV, area)); }注意count - 1这个除数如果只有一个数据点会除零。我一般会在外面判断数据点少于两个就不画折线改画一个圆点或者留空。最小值最大值相等的情况也要处理否则maxV - minV为零t会变成无穷大。这类边界在 Demo 里很难触发上线之后遇到空数据、单点数据就是崩溃。绘制顺序也有讲究先画网格线浅色再画填充区域渐变最后画折线和数据点深色。顺序反了会被覆盖看着就是图没画出来。9.3 一个最小可用的刮刮卡结构刮刮卡是 Canvas 交互的经典练习题把几个知识点串在一起图片绘制、混合模式、手势坐标、命中区域。思路是两层叠放底层是真实内容用普通 Widget 就行上层是 CustomPaint 画一层灰色遮罩。手指划过的地方在遮罩上擦除露出底层。class ScratchPainter extends CustomPainter { ScratchPainter({required this.points, required this.size, required this.repaint}) : super(repaint: repaint); final ListOffset points; final Size size; final Listenable repaint; override void paint(Canvas canvas, Size s) { canvas.saveLayer(Offset.zero s, Paint()); canvas.drawRect(Offset.zero s, Paint()..color const Color(0xFFBDBDBD)); final erase Paint() ..blendMode BlendMode.clear ..style PaintingStyle.stroke ..strokeWidth 40 ..strokeCap StrokeCap.round ..strokeJoin StrokeJoin.round; final path Path(); for (var i 0; i points.length; i) { i 0 ? path.moveTo(points[i].dx, points[i].dy) : path.lineTo(points[i].dx, points[i].dy); } canvas.drawPath(path, erase); canvas.restore(); } override bool shouldRepaint(covariant ScratchPainter old) false; }BlendMode.clear必须配合saveLayer才能生效因为清除操作需要有独立的合成层直接在主图层上清会清掉底层内容。这一点如果不理解写出来的效果就是整块变透明而不是擦出路径。strokeJoin设成 round 是为了让转折处的擦除边缘圆润不设的话直角处会缺一块。手势部分用onPanUpdate累加localPosition每次加点后notifyListeners触发重绘。真实场景还要加一层已擦除比例的统计超过 60% 就自动揭示全部这个用离屏画布读像素算或者按轨迹覆盖的网格近似估计都行。10. 写在最后的个人体会Canvas 这门手艺我觉得最难的从来不是 API 多难记而是坐标系和生命周期的双重心智负担。你要在脑子里同时维护两套模型一套是画布坐标如何随变换而移动另一套是哪些对象该复用、哪些该释放、哪些变化要触发重绘。这两套模型没建立起来之前写出来的代码能跑但很脆改一个地方崩另一个地方。我的建议是前期多用可视化验证。在画布上画几个标记点把关键坐标打印出来对一下把shouldRepaint里临时加一行日志确认它到底被调用了多少次把变换矩阵的输入输出都画出来看一眼。这些笨办法看着低效但能帮你快速建立起对坐标系的直觉比看十篇文档都管用。另外一个体会是别急着上 Canvas。每次想用 CustomPaint 之前先问自己一句用 Widget 组合能不能做到。圆角、阴影、渐变、描边、旋转、缩放这些 Flutter 的组件和 Effect 都能做而且做得比手写绘制更稳、更适配无障碍。真正该上 Canvas 的是那些需要在一帧里批量画几十上百个图形、需要精确控制每一条曲线、需要做路径动画的场景。判断标准清楚之后你会发现自己写 CustomPainter 的次数少了但每次写出来的质量高了。最后留一个小技巧调试复杂绘制时给每类图形用不同的高饱和颜色把描边加粗到 5 像素以上先确认几何位置全对再换回设计稿的颜色和线宽。这个先看结构再看细节的两步法帮我省下的排查时间比我学会过的任何优化技巧都多。
阅读完成 · 觉得有帮助?
咨询建站