资讯详情 Flutter自定义画板:Canvas渲染、手势采集与导出优化
📅 2026/10/26 13:17:42
1. 需求拆解一块自定义画板到底要解决哪几个问题做 Flutter 自定义画板这件事几乎每个接手的项目都会走一遍同样的弯路先找现成的库装上去跑个 Demo看着挺好等真正往业务里塞的时候发现缺一个橡皮擦要能穿透背景为透明的需求或者导出图片要 300 DPI或者画笔要跟手延迟不能超过一帧然后就只能推倒重来。我自己经手过的画板需求从手写签名、作业批注、户型草图到简单的涂鸦分享形态差别很大但底层要处理的问题其实是同一批手势采集、数据建模、实时渲染、导出。这四件事任何一件没设计好后面加功能都会很痛苦。所谓 Flutter 自定义画板说白了就是在Canvas上按顺序回放一组笔画数据同时把用户手指的移动轨迹实时追加进去。听起来简单但它的特殊性在于它是一个高频重绘 持续累积数据的场景。普通页面一秒钟可能重绘几次画板在手指按下的时候是一秒钟 60 到 120 次全量重绘而且数据只增不减。这种场景下任何一点性能浪费都会被放大成肉眼可见的卡顿任何一点数据结构设计不当都会在笔画数上千之后爆发。这篇东西适合谁看我的定位是这样的你已经会写 Flutter 页面知道CustomPaint和CustomPainter是干什么的但真动手写画板的时候被重绘、断线、锯齿、导出模糊、撤销后画面不刷新这些具体问题卡住过。如果你完全没接触过Canvas前两节也能看懂因为我尽量用生活化的方式解释。下面我把整套方案从选型到落地拆开讲包括我踩过的坑和最后定下来的参数。1.1 现成方案为什么常常不够用Flutter 生态里做画板的包不少签名、涂鸦、白板类的都有快速验证原型完全够用。但只要业务稍微复杂一点就会碰到三类硬墙。第一类是渲染策略不可控。很多包的实现思路是每次onPanUpdate就往列表里塞一个点然后shouldRepaint返回true笔画一多整块画布每帧全量重绘中低端机上直接掉帧。你想优化但代码在别人包里改不动。第二类是橡皮擦语义不匹配。绝大多数轻量实现用的是画一条和背景同色的线来模拟擦除。这个做法在纯白背景上没问题但只要你的需求里有导出透明底 PNG 叠加到别的图上或者用户能切换背景颜色擦除立刻失效——因为它擦出来的其实是白色笔画不是真正的透明。真擦除要靠BlendMode.clear配合离屏图层这是一个需要在架构层面预留的设计事后补起来要动渲染管线。第三类是数据不可控。很多包把内部状态封起来只暴露一个clear()。你需要撤销一步回放到某个时间点做动画把笔画数据存到后端做协作全都没法做。我印象最深的一次是某项目要求用户画完的轨迹要能二次编辑把某一笔挪位置包里的数据结构根本拿不到笔画粒度的对象只能整块重来。我的判断标准很简单如果这个需求里出现了导出撤销重做图层擦除缩放就别犹豫自己写。反之如果只是用户签个名截个图上传用现成的包最快。1.2 四层架构与职责边界自己写画板第一件事是把职责切干净。我习惯切成四层层次之间只通过数据通信不让它们互相知道对方的存在。手势层负责把原生指针事件翻译成画板坐标系里的点并做采样过滤、设备类型过滤。它不关心画什么只负责这里发生了一个有效的触摸点。数据层持有笔画列表、重做栈、当前正在画的笔画。所有状态变更都走这里渲染层只读。这一层要能序列化因为导出和存盘都靠它。渲染层是一个或多个CustomPainter输入是笔画数据输出是像素。它必须是纯函数式的给同样的数据画出同样的结果。导出层独立于屏幕上的渲染层。这一点很多人会忽略但如果导出直接复用屏幕上的RepaintBoundary你会发现导出尺寸被画布尺寸锁死了想要高清图只能靠pixelRatio硬拉内存一下子爆掉。这四层分清楚之后加功能就变成很机械的事加橡皮擦 数据层多一个笔画类型 渲染层多一个分支加撤销 数据层多一个栈加导出 导出层多一个入口。1.3 关键选型对照表下面这张表是我在几个项目里反复对比后形成的结论可以直接拿去用。需要决策的点方案 A方案 B我的选择与理由手势采集GestureDetector的onPanUpdateListener的onPointerMove选 B。A 会参与手势竞技场在有滚动父容器时会丢事件B 能拿到全部指针还能读kind、pressure、size单次绘制容器一个CustomPaint画全部分层多个CustomPaint选 B。正在画的笔画每帧变不能拖着已完成的几百条一起重绘已完成笔画每帧遍历重画缓存成ui.Picture笔画数超过 200 条之后选 B收益非常明显平滑直线连接采样点二次贝塞尔中点法选 B成本几乎为零观感差别巨大橡皮擦背景色覆盖BlendMode.clearsaveLayer看需求。要透明底就必须 BA 只适合固定纯色背景导出RepaintBoundary.toImagePictureRecorder重放选 B。尺寸、背景色、缩放都能精确控制不受屏幕布局影响把这些定下来之后后面的坑基本都在细节里了。2. 数据模型把一条笔画拆到什么粒度数据模型是画板的地基改起来最贵。我见过最省事的写法是ListListOffset能用但等你需要给每一笔加颜色、宽度、透明度、笔刷类型、时间戳的时候就得全项目改。所以我建议一开始就定一个Stroke类字段宁可多留几个。2.1 Stroke 对象的字段设计我的基本字段是这样点列表、颜色、基础宽度、每点的宽度因子、笔刷类型普通/橡皮、创建时间。其中宽度因子这个字段是可选的如果你不打算做笔锋效果可以直接省掉。enum StrokeKind { pen, eraser } class Stroke { Stroke({ required this.points, required this.color, required this.baseWidth, this.kind StrokeKind.pen, Listdouble? widthScale, }) : widthScale widthScale ?? List.filled(points.length, 1.0); final ListOffset points; final Color color; final double baseWidth; final StrokeKind kind; final Listdouble widthScale; bool get isSinglePoint points.length 1; }设计上多说两句。points我用ListOffset而不是ListPoint或自定义结构因为Offset本身提供了减法、distance这些运算写起来最省事。widthScale和points等长是为了让这一笔的第 N 个点画多粗能对得上虽然这让内存占用翻了一倍左右但如果你不做压感/笔锋可以直接不填默认全 1.0。要注意的是不要在这一层存Path。Path是渲染产物一旦存进数据模型序列化就没法做了而且每次修改点都要重建Path缓存反而变成负担。我的做法是渲染层按需构建数据层只存点。一个容易忽略的点Offset存的是画板本地坐标不是屏幕全局坐标。如果画板能缩放或者有偏移手势层必须先把坐标反算成画板坐标再入模型否则用户缩放一下再画笔迹位置全歪。这个在 4.3 节细讲。2.2 采样点抽稀与压缩比例计算手指移动时系统给的PointerMoveEvent频率和屏幕刷新率相关一般是 60Hz 到 120Hz某些设备更高。用户画一条两秒的长线很容易积到 150 到 250 个点。这些点里相当一部分彼此距离不到 1 个逻辑像素画出来完全看不出区别纯属浪费。我用的过滤规则很简单新点与上一个已接受点的距离小于阈值就丢弃。阈值我一般取 1.0 到 1.5 逻辑像素具体看画布尺寸——小屏签名板取 1.0大屏批注板可以取 1.5。ListOffset simplifyByDistance(ListOffset src, double minDist) { if (src.length 2) return List.of(src); final out Offset[src.first]; for (var i 1; i src.length - 1; i) { if ((src[i] - out.last).distance minDist) { out.add(src[i]); } } out.add(src.last); return out; }这里有个小设计首尾两点永远保留。因为用户按下和抬起的位置是有语义的比如签名板的起笔和收笔如果被过滤掉笔画会短一截看起来没画到头。这个细节我在早期版本里漏了被反馈过好几次。压缩比到底有多少我实测下来在 1.0 像素阈值下普通手写场景能砍掉 40% 到 60% 的点快速划动时采样点稀疏可能只能砍 20%慢速精细描线时点密能砍 60% 以上。这个数字不算夸张但它带来的收益是双份的内存减半而且后面构建Path的耗时也减半因为段数少了。更激进的做法是用道格拉斯-普克Douglas-Peucker做曲线简化能在保持形状的前提下砍到原来的 10% 以下。但我不建议在实时绘制路径上用一是它需要回看整条线段实时增量做不了二是它的容差参数调不好会让线条变形尤其是圆润的转角和签名里的连笔。折中方案是绘制时用距离过滤落笔结束后onPointerUp再对这条笔画做一次离线简化这样既能实时流畅又能把存档数据压得很小。2.3 撤销/重做栈的两种实现代价撤销重做有两种思路快照式和命令式。快照式是每次操作前把整个笔画列表复制一份压栈撤销时直接回滚。命令式是记录加了一条笔画这个动作撤销时移除。快照式的优点是实现极其简单五次改完就能跑缺点是内存随操作次数 × 笔画数平方级增长。用户画了 500 笔再撤销 50 次你就存了 50 份 500 笔的列表Offset对象大量重复。虽然 Dart 的列表复制是浅拷贝Offset是复用的但列表本身的指针数组还是要占空间几百次操作就是几 MB。我最后用的是命令式实现起来也就十几行class BoardController extends ChangeNotifier { final ListStroke _strokes []; final ListStroke _redoStack []; ListStroke get strokes List.unmodifiable(_strokes); bool get canUndo _strokes.isNotEmpty; bool get canRedo _redoStack.isNotEmpty; void commit(Stroke s) { _strokes.add(s); _redoStack.clear(); // 新操作会清空重做栈这是标准行为 notifyListeners(); } void undo() { if (_strokes.isEmpty) return; _redoStack.add(_strokes.removeLast()); notifyListeners(); } void redo() { if (_redoStack.isEmpty) return; _strokes.add(_redoStack.removeLast()); notifyListeners(); } }_redoStack.clear()这一行很多人会忘。用户画三笔、撤销两笔、再画一笔正确的行为是重做按钮变灰因为原来的分支已经被新操作覆盖了。忘了清空的话用户点重做会把旧的分支接上来画面上出现莫名其妙的笔迹这类反馈我收到过不止一次。还有一点撤销粒度是一笔还是一段。手写签名场景一笔就是一笔没问题。但批注场景里用户可能长按拖出一条很长的标注线中间手指抬起过这时候撤销一次只删一小段会很烦。如果你的业务有这个需求可以在数据层加一个groupId同一组内的笔画一起撤销。这个扩展不难但要在模型设计时就留字段。3. 渲染CustomPainter 里那些容易写错的细节渲染层是画板性能和观感的主战场。这一节讲五个点每一个我都见过写错的版本。3.1 shouldRepaint 的正确写法CustomPainter.shouldRepaint的返回值决定 Flutter 要不要重新调用paint。新手最容易犯的错是直接return true觉得反正要重绘。在小画板上看不出问题笔画一多就卡成 PPT因为哪怕你只是切换了一下主题色、滚动了一下页面整块画布都在全量重绘。反过来另一种错误是return false之后发现画面不刷新了。原因通常是 painter 每次都被重新创建但内部的笔画列表是同一个引用shouldRepaint比较引用相等返回了false于是新的数据画不出来。我的做法是给数据加一个递增的revisionpainter 只比这个字段class StrokeLayerPainter extends CustomPainter { StrokeLayerPainter({required this.strokes, required this.revision}); final ListStroke strokes; final int revision; override void paint(Canvas canvas, Size size) { for (final s in strokes) { paintOneStroke(canvas, s); } } override bool shouldRepaint(covariant StrokeLayerPainter old) old.revision ! revision; }数据层每次commit就revision。这样语义清晰数据没变就不重绘数据变了就重绘不依赖对象引用这种容易出错的判断。还有一个坑shouldRepaint不影响父级 layout。如果画板尺寸变了比如旋转屏幕、弹出键盘导致高度变化paint会被重新调用size参数也会更新。所以绘制逻辑不能把size缓存在 painter 的构造参数里必须用paint回调传进来的size。3.2 分层绘制与 RepaintBoundary 的真实收益画板在手指按下时正在画的那一条笔画每帧都在变而已完成的几百条笔画完全没变。如果放在一个CustomPaint里每帧都要把几百条重新走一遍drawPath这就是最常见的卡顿来源。我的分层是这样Stack( children: [ // 第一层背景网格、底图基本不变 CustomPaint(painter: BackgroundPainter(grid: grid), size: Size.infinite), // 第二层已完成笔画被 RepaintBoundary 隔离 RepaintBoundary( child: CustomPaint( painter: StrokeLayerPainter(strokes: done, revision: doneRevision), size: Size.infinite, ), ), // 第三层正在画的笔画每帧更新 CustomPaint( painter: LiveLayerPainter(current: living, revision: liveRevision), size: Size.infinite, ), ], )关键是RepaintBoundary的作用它把自己的绘制结果栅格化成一张独立的图层。上层的LiveLayerPainter每帧重绘时Flutter 只需要把这张缓存图层贴上去不需要重新执行里面那几百条drawPath。这一步在笔画数上到两三百条之后帧率差别是肉眼可见的。不过有两点要提醒。第一RepaintBoundary不是免费的它多占一张纹理内存。画板本身尺寸大比如全屏 1080×2400一张 ARGB 纹理就是 10MB 左右如果同时叠了好几层内存会很快上去。所以别滥用只包真正需要隔离的那一层。第二Stack里的顺序决定了合成顺序也决定了RepaintBoundary能不能真正起作用。如果你的 live 层和 done 层在同一个CustomPaint里画只是代码上分成两段那RepaintBoundary包在哪都没用因为绘制还是发生在同一次paint调用里。3.3 用贝塞尔把折线抹平直接把采样点用lineTo连起来画出来的线是折线。高速划动时采样点稀疏肉眼能明显看出棱角慢速画圆弧时又会有细微的锯齿感。解决办法是用二次贝塞尔以相邻两点的中点为终点以中间那个采样点为控制点。Path buildSmoothPath(ListOffset pts) { final path Path(); if (pts.isEmpty) return path; if (pts.length 1) { return path..addOval(Rect.fromCircle(center: pts.first, radius: 0.5)); } path.moveTo(pts[0].dx, pts[0].dy); for (var i 1; i pts.length - 1; i) { final mid Offset( (pts[i].dx pts[i 1].dx) / 2, (pts[i].dy pts[i 1].dy) / 2, ); path.quadraticBezierTo(pts[i].dx, pts[i].dy, mid.dx, mid.dy); } path.lineTo(pts.last.dx, pts.last.dy); return path; }为什么用中点法而不是 Catmull-Rom 或其他插值因为中点法的计算量极小一次加法和一次除法而且它天然保证曲线经过每两个采样点的中点不会产生过冲。Catmull-Rom 更平滑但它需要计算切线而且参数没调好会在急转弯处甩出去一段签名上看起来像写错了。中点法在笔迹场景里是性价比最高的选择。单点的情况要单独处理。用户点一下不移动就抬手points.length 1用lineTo画不出任何东西线段长度为 0必须画一个圆点。圆点半径我取的是笔画宽度的一半。这个细节不做的话用户点一下发现没反应会以为画板坏了。还有一个性能细节buildSmoothPath不应该在paint里对整个已完成笔画列表调用。已完成笔画的Path应该在onPointerUp那一刻构建一次缓存在Stroke对象或者一个旁挂的 map里paint时直接复用。只有正在画的那一条需要每帧重建。3.4 线宽随速度变化做出笔锋感真实的笔写出来的线条不是等宽的起笔重、行笔快的地方细、收笔顿一下。想做出这种手感最实用的信号是速度单位时间内的位移越大线越细。速度可以从PointerMoveEvent里算double velocityOf(PointerMoveEvent e, Duration? lastTs) { final prev lastTs ?? e.timeStamp; final dtMs (e.timeStamp - prev).inMicroseconds / 1000.0; if (dtMs 0) return 0; return e.delta.distance / dtMs; // 逻辑像素 / 毫秒 }拿到速度后做一次映射double widthScaleOf(double v) { // v 的经验区间0 到 3 px/ms final t (v / 3.0).clamp(0.0, 1.0); return 1.0 - 0.6 * t; // 最细到基础宽度的 40% }这里有个工程上的现实问题drawPath不支持变宽。一条Path只有一个strokeWidth。想做出变宽变细只能把笔画切成若干段每段用不同的宽度分别drawPath。切得越细越平滑但drawPath次数也越多。我的折中是按宽度档位分段把宽度量化成 3 到 5 档比如 1.0、0.85、0.7、0.55、0.4相邻同档的点合并成一个子路径。实际画下来一条笔画通常只有三到五段性能可接受视觉上也能看出粗细变化。void paintSegmented(Canvas canvas, Stroke s, Paint paint) { if (s.points.length 1) { canvas.drawCircle(s.points.first, s.baseWidth / 2, paint..style PaintingStyle.fill); return; } final levels [1.0, 0.85, 0.7, 0.55, 0.4]; var start 0; var curLevel _levelOf(s.widthScale[0], levels); for (var i 1; i s.points.length; i) { final lv i s.points.length ? _levelOf(s.widthScale[i], levels) : -1; if (lv ! curLevel) { final chunk s.points.sublist(start, i); if (chunk.length 2) { paint ..style PaintingStyle.stroke ..strokeWidth s.baseWidth * levels[curLevel]; canvas.drawPath(buildSmoothPath(chunk), paint); } start i; curLevel lv; } } }要注意的是档位切换处会有轻微的粗细跳变如果档位太少比如只有 3 档跳变能看出来。5 档是我试下来比较平衡的值。关于压感PointerEvent有pressure字段理论上能拿到真实压力。但实际设备差异很大很多触屏设备的pressure恒为 1.0 或者不可靠。我的建议是先打日志统计一下你目标设备上的实际取值分布再决定要不要用不要盲目相信这个字段。用速度做近似在绝大多数场景下观感足够好。3.5 用 Picture 缓存已完成笔画RepaintBoundary解决的是live 层重绘时不要带上 done 层但当 done 层自身需要更新时用户刚画完一笔要合并进 done 层它仍然要把几百条全部重画一遍。如果笔画数量到了上千条这一步就会出现明显的卡顿。更彻底的办法是把 done 层缓存成一个ui.Picture。Picture是录制好的绘制指令列表canvas.drawPicture会直接回放比重新执行 Dart 层的循环和路径构建快很多。ui.Picture _appendStroke(ui.Picture? old, Stroke s) { final recorder ui.PictureRecorder(); final canvas Canvas(recorder); if (old ! null) { canvas.drawPicture(old); old.dispose(); // 重要否则会泄漏原生资源 } paintOneStroke(canvas, s); return recorder.endRecording(); }这是增量方案每次新增一笔把旧 picture 画进新 recorder再画上新的一笔。这样每次只需要多画一条而不是全部重画。但这里有个限制Picture的绘制结果依赖录制时的画布尺寸。如果画板尺寸变了旋转屏幕、键盘弹出缓存的 picture 需要作废重建。所以要在didUpdateWidget或size变化时清掉缓存。还有一个更隐蔽的问题ui.Picture是原生资源不会自动释放必须手动dispose()。它的生命周期和 Dart 对象不一样不会随 GC 回收。我见过有项目每帧都重建 picture 但从不 dispose跑几分钟就内存爆掉。用Picture的话务必把 dispose 写成固定套路。4. 手势采集从 Pointer 事件到稳定笔迹手势这一层最容易出问题的不是拿不到事件而是拿到了不该要的事件和丢了该要的事件。这一节讲三个具体问题。4.1 为什么用 Listener 而不是 GestureDetectorGestureDetector的onPanUpdate用起来最顺手但它有两个硬伤。第一个是参与手势竞技场。如果你的画板放在ListView、PageView或者InteractiveViewer里纵向拖动会被父级识别成滚动画板收不到onPanUpdate用户画竖线画不出来。这个在批注、签名场景里是致命的。第二个硬伤是它只跟踪一个手势。onPanUpdate是一个拖动的抽象多指同时画的时候你没办法区分哪条线是哪根手指。而Listener的指针事件是带pointer标识的能天然支持多指。Listener( behavior: HitTestBehavior.opaque, onPointerDown: _onDown, onPointerMove: _onMove, onPointerUp: _onUp, onPointerCancel: _onCancel, child: CustomPaint(painter: layerPainter, size: Size.infinite), )behavior: HitTestBehavior.opaque是必须的。默认的deferToChild在子级是CustomPaint没有实际子 widget 覆盖时可能收不到事件表现就是点了没反应。用了Listener之后父级的滚动和画板的手势会同时收到事件。这时候通常需要配合RawGestureDetector或者在外层GestureDetector里对onVerticalDragStart做判断来控制谁赢。这块比较绕下一篇可以专门聊这里只提一句如果你只是画板不想被父级抢事件Listener配合外层的NeverScrollableScrollPhysics是最简单的解法。4.2 多指、手掌误触与设备类型过滤Listener会收到所有指针包括你不想要的。我的过滤规则有这么几条。按设备类型过滤。桌面端调试时鼠标右键也触发onPointerDown要排除void _onDown(PointerDownEvent e) { if (e.kind PointerDeviceKind.mouse e.buttons ! kPrimaryButton) return; // 只接受触摸和触控笔 if (e.kind ! PointerDeviceKind.touch e.kind ! PointerDeviceKind.stylus e.kind ! PointerDeviceKind.invertedStylus) return; ... }手掌误触过滤。平板画画的时候手掌侧面压到屏幕会产生一大片接触如果当成笔画处理画面上会出现一团墨点。PointerEvent.size是接触面尺寸可以拿来过滤bool isPalm(PointerDownEvent e) e.size 80; // 逻辑像素经验值这个阈值我测下来 80 到 100 之间比较合适但不同设备偏差确实存在建议做成可配置。另一个更强的信号是多指按下如果按下时已经有其他指针在活动_activePointers.isNotEmpty大概率是手掌或误触直接忽略。多指绘制的处理。如果业务真的需要两指同时画比如两个人协作、或者左右手同时标注就按e.pointer维护一个Mapint, Stroke每个指针一条独立笔画。onPointerUp时把对应那条提交到数据层。这个不难难的是渲染层要支持同时有多条 live 笔画LiveLayerPainter里遍历这个 map 就行。不过实际情况是绝大多数画板业务只需要单指。这时候的规则是第一个按下的指针独占画板其他指针一律忽略直到第一个抬起。这个策略最符合直觉也避免了用户两只手扶着平板画画时的误触。4.3 缩放画布下的坐标反算画板支持缩放平移用InteractiveViewer包一层几乎是一个必然会来的需求——用户要写小字或者要看清细节修改。但缩放会给坐标带来一个大坑。PointerMoveEvent.localPosition是相对于监听它的那个 widget的坐标。如果你把Listener放在InteractiveViewer外面拿到的是变换前的坐标如果放在里面拿到的是变换后的坐标。两种都可能不是你想要的。我的推荐做法是Listener放在InteractiveViewer的child内部也就是画板本身。这样localPosition直接就是画板坐标系里的点数据层拿到的坐标天然正确缩放平移的时候笔迹也会跟着一起缩放符合直觉。但如果你的架构不允许这样比如画板内容本身尺寸非常大超出屏幕必须用变换矩阵来算就要手动反算Offset toCanvasSpace(Offset globalPos, TransformationController ctrl) { final matrix Matrix4.inverted(ctrl.value); return MatrixUtils.transformPoint(matrix, globalPos); }MatrixUtils.transformPoint这个工具方法很实用比手写矩阵乘法清楚得多还能处理齐次坐标的 w 分量。这里还有一个连带问题线宽。画布缩放 2 倍后如果线宽还是按画板坐标算视觉上线条会变粗 2 倍。如果用户期望不管缩放多少线条视觉粗细不变就要把线宽除以当前缩放比final scale ctrl.value.getMaxScaleOnAxis(); paint.strokeWidth stroke.baseWidth / scale;反过来如果用户期望线条是画在纸上的缩放后应该跟着变粗就不要除。这两种语义都合理取决于你的产品定位。签名板通常是前者线条是标注不是内容绘图板通常是后者线条是内容的一部分。这个必须在需求阶段就定下来否则做完了再改所有存量数据的观感都会变。5. 橡皮擦、背景与导出这三件事看起来不相关其实是一件事画布的合成模型。想明白画布最终是怎么合成出来的这三个问题就都有答案了。5.1 橡皮擦的两种实现与各自代价前面提过橡皮擦有两种做法。方案 A背景色覆盖。橡皮笔画就是一条和背景色一样的普通笔画。// 橡皮 颜色为背景色的笔画 Stroke(points: pts, color: backgroundColor, baseWidth: 24, kind: StrokeKind.eraser)优点是实现成本几乎为零走的是已有的绘制路径性能一样。缺点是只对固定纯色背景有效。背景是图片、是渐变、是透明的全都翻车。而且导出的 PNG 上会有一条白色的擦除痕迹叠到别的颜色背景上会露馅。方案 BBlendMode.clear。真擦除擦掉的地方是真的透明。void paintEraser(Canvas canvas, Stroke s, Size size) { canvas.saveLayer(Offset.zero size, Paint()); // 先画所有普通笔画 for (final stroke in normalStrokes) { paintOneStroke(canvas, stroke); } // 再画橡皮用 clear 模式 final paint Paint() ..blendMode BlendMode.clear ..style PaintingStyle.stroke ..strokeWidth s.baseWidth ..strokeCap StrokeCap.round ..strokeJoin StrokeJoin.round; canvas.drawPath(buildSmoothPath(s.points), paint); canvas.restore(); }关键在于saveLayer。BlendMode.clear的作用是把目标像素的 alpha 置为 0但如果直接画在屏幕图层上它会把底下的背景层也清掉露出黑色或者下层内容。saveLayer创建了一个独立的离屏图层所有的绘制都在这个层里发生restore时才把这个层合成到下层。这样透明只在层内生效。方案 B 的代价是性能。每次saveLayer都要分配一块和画布等大的离屏缓冲全屏画布就是几 MB 的内存分配而且在移动端 GPU 上离屏渲染的带宽开销不小。优化手段是只在真的存在橡皮笔画时才saveLayer纯画笔阶段不付这个成本。另一个思路是分层擦除把橡皮笔画当成独立的层绘制时用clipPath的差集去裁。这个做法的性能更好但实现复杂度高很多非必要不推荐。5.2 saveLayer 的性能账怎么算算一笔具体的账。假设画布 1080×1920 物理像素saveLayer需要分配一张 ARGB8888 的纹理大小是 1080 × 1920 × 4 字节 ≈ 8.3 MB。如果每帧都saveLayer一次并restore一次60 帧就是 500 MB/s 的显存带宽占用中低端 GPU 会直接掉帧。我的优化策略分三档第一档能不用就不用。如果整个画板没有橡皮笔画就完全不进saveLayer分支。这个判断很便宜遍历一遍笔画看有没有kind eraser就行可以在数据层缓存一个计数。第二档只覆盖有变化的区域。saveLayer的第二个参数是Bounds可以传一个比整块画布小的矩形只对这个区域做离屏。橡皮往往只在局部活动传一个宽松的包围盒比如当前笔画的外接矩形外扩 20 像素能省下大量内存。要注意这个包围盒必须覆盖橡皮笔画和它可能擦到的所有普通笔画算漏了会出现擦不干净的诡异现象。第三档放低刷新率。橡皮拖动的时候实际上不需要每帧全量重绘可以节流到 30Hz手指抬起时再画最终结果。视觉上几乎看不出差别性能翻倍。5.3 导出 PNG 的尺寸计算与内存红线导出这块是最容易爆内存的地方。很多人第一反应是用RepaintBoundary的toImagefinal boundary key.currentContext!.findRenderObject() as RenderRepaintBoundary; final image await boundary.toImage(pixelRatio: 3);这个方法能用但有个限制它导出的是屏幕上这块区域的渲染结果。如果画板在屏幕上只有 360×480 逻辑像素pixelRatio: 3导出的是 1080×1440 物理像素的图看起来还行。但如果用户要求导出 A4 尺寸 300 DPI那就是 2480×3508远超屏幕尺寸toImage做不到——它只能放大现有像素放大后就是模糊的。正确做法是用PictureRecorder按导出尺寸重新绘制一遍FutureUint8List exportPng({ required ListStroke strokes, required Size canvasSize, required Size targetSize, Color? background, }) async { final recorder ui.PictureRecorder(); final canvas Canvas(recorder); final scale targetSize.width / canvasSize.width; canvas.scale(scale); if (background ! null) { canvas.drawRect(Offset.zero canvasSize, Paint()..color background); } for (final s in strokes) { paintOneStroke(canvas, s); } final picture recorder.endRecording(); final image await picture.toImage( targetSize.width.round(), targetSize.height.round(), ); final byteData await image.toByteData(format: ui.ImageByteFormat.png); picture.dispose(); image.dispose(); return byteData!.buffer.asUint8List(); }这样画出来的图线条是按目标分辨率矢量重绘的不会糊。而且背景是透明的还是白底完全可控。内存这块必须算清楚。picture.toImage生成的位图内存占用是宽 × 高 × 4字节。导出 2480×3508 的 A4 图就是 2480 × 3508 × 4 ≈ 34.8 MB。这还只是位图本身toByteData编码成 PNG 的过程还要再分配一份峰值可能到 70 MB。如果同时导出多张或者用户连续点击导出按钮很容易 OOM 崩溃。我的红线是单张导出位图不超过 4096×4096超了就按比例缩到 4096。同时加一个导出中的锁防止并发bool _exporting false; FutureUint8List? safeExport(...) async { if (_exporting) return null; _exporting true; try { return await exportPng(...); } finally { _exporting false; } }一个真踩过的坑透明底导出时如果画布上一条笔画都没有picture.toImage出来的是一张全透明的图某些看图软件打开是一片黑用户会以为导出失败了。加个兜底笔画为空时直接给个提示或者画一个浅色边框。6. 问题排查实录与参数速查这一节是我整理的症状 → 原因 → 处理对照表基本上画板会遇到的怪现象都在里面了。6.1 六个高频现象的对症表现象最可能的原因处理方式笔画数一多就卡顿单层绘制每帧全量重绘分层 RepaintBoundary笔画过 200 条上Picture缓存快速划动时线条断成几截GestureDetector丢了 move 事件或父级抢了手势换ListenerHitTestBehavior.opaque竖直方向画不出线父级滚动容器赢了手势竞技场外层禁用滚动或用NeverScrollableScrollPhysics线条有明显棱角、锯齿未做贝塞尔平滑或isAntiAlias为 false中点法quadraticBezierToPaint 开抗锯齿撤销后画面不刷新shouldRepaint恒返回 false或用了同一个 painter 实例改用revision计数比较导出的图模糊用了RepaintBoundary.toImage硬拉pixelRatio改PictureRecorder按目标尺寸矢量重绘导出时闪退位图尺寸过大导致 OOM限制最大边 4096加导出锁防并发缩放后笔迹位置偏移坐标没有反算到画板空间Matrix4.invertedMatrixUtils.transformPoint点一下没反应单点笔画没画圆点points.length 1时drawCircle擦除后露出黑色BlendMode.clear没配saveLayer包一层saveLayer隔离开这张表里我最想强调点一下没反应这一条。它的隐蔽之处在于用户点一下你觉得什么都没发生很正常但用户的预期是我点了一个点应该有个点。这个在签名场景里是硬伤——签名本来就有很多顿笔用户点了发现没墨会觉得这个 App 有问题。6.2 一套可以直接抄的参数组合最后把我用了几个项目之后稳定下来的一套参数放出来可以直接拿去当起点再按实际设备微调。class BoardConfig { /// 采样点最小间距小于这个距离的点直接丢弃逻辑像素 static const double minPointDistance 1.0; /// 基础线宽 static const double baseStrokeWidth 3.0; /// 橡皮线宽 static const double eraserWidth 24.0; /// 笔锋速度档位上限逻辑像素/毫秒 static const double velocityCap 3.0; /// 最细缩放比例 static const double minWidthScale 0.4; /// 宽度量化档位 static const Listdouble widthLevels [1.0, 0.85, 0.7, 0.55, 0.4]; /// 手掌误触阈值接触面直径逻辑像素 static const double palmSizeThreshold 90.0; /// 导出最大边长 static const int maxExportEdge 4096; /// 默认导出倍率 static const double defaultExportScale 2.0; }参数怎么调给几个方向。签批场景写字用minPointDistance 1.0、baseStrokeWidth 3.0笔迹要细腻。涂鸦场景可以用minPointDistance 1.5、baseStrokeWidth 6.0粗犷一点性能也更好。橡皮宽度一般是画笔的 6 到 8 倍太小了擦不干净太大了误擦率高。velocityCap这个值要按设备实测。它的含义是速度超过这个值就按最细画。设太小比如 1.0用户正常书写就被判成极快整个字都很细设太大比如 8.0笔锋效果就出不来。我给的经验值是用户正常书写的平均速度的 2 到 3 倍可以先打日志统计一下再定。最后再分享一个小技巧关于真机调试。画板的性能问题在模拟器上基本测不出来因为模拟器跑在桌面 GPU 上性能好得多。我遇到过模拟器上丝滑、真机上掉成 20 帧的情况原因就是那一层saveLayer在移动 GPU 上的开销。所以画板这类东西一定要在低端真机上跑一遍特别是笔画数量上到 300 条以上、开了橡皮、又开了缩放的组合场景。测的时候别用新机器找一台两三年前的设备那几个场景过得了才敢说这块画板是能用的。