☰
Flutter 自定义画板全链路:手势、重绘、撤销与导出优化
2026/10/26 4:59:13 网站建设 项目流程

项目标题写着"自定义画板",四个字背后其实藏着一整条从手势采集到像素落盘的链路——触摸点怎么进来、坐标怎么换算、笔迹用什么结构存、重绘范围怎么压、撤销栈怎么不炸内存、导出的时候分辨率怎么算。做过这块的人都清楚,这类需求最坑的地方不在于"画不出来",而在于"画得出来但一画就卡""撤销两次就错位""导出图片糊成一片"。这篇文章就是把这些年在画板类需求上踩过的坑、试过的方案、最后留下的那套能直接拿去改的结构,一条条摊开讲清楚。

说明:下面涉及的代码都是可运行的核心片段,参数值来自常见机型上的实测经验,具体项目还需要按实际场景微调。

1. 现成绘图包在真机上暴露的三个短板

pub 仓库里能搜到的绘图类包不少,拿来做 demo 基本都能跑通,手指一划有条线出来,看起来挺像回事。但真正接到需求里,问题往往在第二周才集中爆发。我印象最深的一次是产品要求"笔迹跟手、能撤销、能导出高清图",三个要求单拎出来都不难,凑在一起就把现成方案的天花板顶穿了。

1.1 为什么 demo 顺滑、上线就掉帧

现成的绘图组件大多采用"每次手势更新就重建整棵绘制树"的策略。手指移动一帧,它就往内部列表里塞一个点,然后触发一次全量重绘。画布小的时候看不出来,一旦画布铺满整屏、笔迹累计到几百条,每帧遍历所有笔迹重新构建Path对象,GPU 那边的绘制指令也跟着翻倍,帧率就从 60 掉到 30 以下,手指快速划动时笔迹明显"追不上"手指。

这里的关键认知是:重绘的粒度不等于数据的粒度。当前正在画的那一条笔迹需要高频更新,但已经画完的历史笔迹是完全静态的,没必要跟着一起重建。这个思路差异,决定了后面整体架构怎么搭。

还有一个隐蔽的问题:部分组件在paint里直接做Path拼接,每个点都调用一次lineTo,几百个点就是几百次路径命令。Path对象本身是不可变的构建过程,频繁重建会持续产生垃圾对象,触发 GC。GC 一停顿,画面就抖一下,这就是很多人说的"画久了会顿一下"。

1.2 手势层与绘制层该怎么分工

我的做法是把整个画板拆成三层,职责非常清晰:

层级承担职责实现载体
手势采集层拿到触点、换算坐标、判断起笔落笔GestureDetector/Listener
数据层存储笔迹、维护历史栈、标记脏状态普通 Dart 类 +ChangeNotifier
绘制层把笔迹列表渲染成像素CustomPainter

手势层只负责一件事:把屏幕坐标转成画布坐标,然后交给数据层。数据层不关心怎么画,只负责增删改和版本号递增。绘制层拿到数据和版本号,决定要不要重绘。

这个分法听起来很教科书,但实际收益很直接——你要做撤销,只需要在数据层操作;你要做导出,只需要调用绘制层的绘制逻辑;你要做性能优化,只需要在绘制层加缓存。三层之间没有互相污染。

最小骨架大概长这样:

class BoardController extends ChangeNotifier { final List<Stroke> _strokes = []; List<Stroke> get strokes => List.unmodifiable(_strokes); int _version = 0; int get version => _version; Stroke? _current; void begin(Offset p) { _current = Stroke(points: [p], color: Colors.black, width: 3.0); _strokes.add(_current!); _version++; notifyListeners(); } void extend(Offset p) { if (_current == null) return; _current = _current!.copyWith(points: [..._current!.points, p]); _strokes[_strokes.length - 1] = _current!; _version++; notifyListeners(); } void end() { _current = null; } }

注意这里_version是个自增计数器,后面shouldRepaint就靠它判断,比比较列表内容高效得多——比长度、比引用都会在某些场景下判断失误,计数器永远不会。

2. 笔迹的数据结构选型决定了后续所有功能的上限

很多人第一版会用List<List<Offset>>直接存点集,简单粗暴,能画。但等你加上颜色、粗细、橡皮擦、透明度、压感之后,这个结构就开始到处打补丁。我更倾向于一开始就把Stroke定义成一个完整的值对象,把所有笔迹属性都收拢进去。

2.1 Stroke、Point、Path 三者到底该存哪一层

这里有个经典争论:到底是存原始点集,还是存构建好的Path。两种方案的取舍很明确,我做过一次对比测试,结论如下:

存储方式内存占用重绘成本支持二次编辑平滑处理空间
只存Path低极低(直接 drawPath)几乎为零无法回溯调整
只存List<Offset>高(点越多越明显)较高(每次重建 Path)完整支持完整支持
点集 + 缓存 Path中低(缓存命中时不重建)完整支持完整支持

我的选择是第三种:原始点集作为唯一真相源,Path作为可失效的缓存。理由很实在——你要是存Path,后面想加"笔迹平滑强度可调"这个功能就只能推倒重来;你要是只存点集,每次重绘几百条笔迹都重新构建路径,性能又扛不住。缓存层把这个矛盾解开了。

具体实现里给Stroke加一个懒加载字段:

class Stroke { final List<Offset> points; final Color color; final double width; final bool isEraser; Path? _cachedPath; Stroke({ required this.points, required this.color, required this.width, this.isEraser = false, }); Path get path { if (_cachedPath != null) return _cachedPath!; final p = Path(); if (points.isEmpty) return _cachedPath = p; p.moveTo(points.first.dx, points.first.dy); for (var i = 1; i < points.length; i++) { p.lineTo(points[i].dx, points[i].dy); } return _cachedPath = p; } Stroke copyWith({List<Offset>? points}) { return Stroke( points: points ?? this.points, color: color, width: width, isEraser: isEraser, ); } }

copyWith里故意不复制_cachedPath,因为点变了缓存必然失效,让它重新算就对了。这个细节如果搞错,会出现"画到一半笔迹突然跳回上一帧形状"的诡异现象,排查起来极其费时。

2.2 点的采样密度要主动控制

还有个容易被忽略的点:onPanUpdate的触发频率跟设备刷新率挂钩,120Hz 的屏幕上,手指划一秒可能就进来一百多个点。点太密不是好事——内存涨得快,路径命令多,导出的时候光栅化也慢。

我在数据层做了一个简单的距离阈值过滤,两点距离小于某个阈值就丢弃:

static const double _minDistance = 1.2; void extend(Offset p) { if (_current == null) return; final last = _current!.points.last; if ((p - last).distance < _minDistance) return; // ... 追加逻辑 }

阈值取 1.2 到 1.8 逻辑像素之间比较合适。太小了过滤不掉冗余点,太大了笔迹会显得有棱角。这个值跟笔画粗细也有关系,粗笔可以放宽一点,细笔要收窄,否则曲线会明显多边化。

提示:过滤阈值不要写死,最好跟着当前画笔宽度走,比如取max(1.0, width * 0.35),这样细笔和粗笔的视觉效果都能接受。

另外,我习惯在点集里额外保留一个时间戳字段(List<int> timestamps),一开始觉得没用,后来做速度模拟压感、做笔迹回放的时候发现它太值了。等你要用的时候再补,历史数据结构迁移会疼得你想哭。

3. 从屏幕触点到画布坐标:这条链路上最容易错位

坐标问题几乎是所有画板类需求的必经之坑。表现是:手指点哪儿,笔画就偏一段距离;或者画布一缩放,笔迹整体漂移;再或者导出图片后笔迹位置全乱。根源都在坐标系转换这一步没理清楚。

3.1 globalToLocal 到底该在哪一层调用

GestureDetector回传的DragUpdateDetails里,globalPosition是相对于整个屏幕的,localPosition是相对于当前 widget 的。很多教程直接用localPosition,在简单场景下确实没问题,但只要画布外面套了Padding、SafeArea或者滚动容器,localPosition就开始不可靠。

稳妥的做法是拿RenderBox手动转换:

Offset _toCanvas(Offset globalPos, BuildContext context) { final box = context.findRenderObject() as RenderBox; return box.globalToLocal(globalPos); }

globalToLocal会自动把RenderBox自身的变换矩阵考虑进去,包括平移、缩放、旋转。这意味着即使你的画布被Transform.scale包着,转换出来的坐标依然是画布本地坐标系下的值,笔迹不会漂。

但这里有个前提:findRenderObject拿到的必须是画布本体那个RenderBox,不能是外层容器。我见过有人把context传成了Scaffold的 context,结果所有笔迹都往下偏移了一个 AppBar 的高度,排查了半天才发现是取错了对象。

3.2 缩放画布后坐标怎么跟着变

如果画板支持双指缩放,情况会复杂一层。我的做法是维护一个Matrix4变换矩阵,所有进来的触点先经过逆矩阵变换,再进入数据层:

Offset _applyInverse(Offset local, Matrix4 transform) { final inverted = Matrix4.inverted(transform); final vec = Vector3(local.dx, local.dy, 0); final result = inverted.transform3(vec); return Offset(result.x, result.y); }

这样数据层存的一直是"画布原始坐标系"下的点,缩放只是渲染层的事。好处是导出的时候不用管当前缩放状态,直接按原始坐标渲染就是全图。坏处是如果缩放比例很大,采样点的密度在视觉上会变稀,需要在缩放状态下动态降低过滤阈值。这个细节做过的人不多,但体验差异很明显。

3.3 设备像素比与导出分辨率的关系

Flutter 的绘制单位是逻辑像素,而屏幕物理像素是逻辑像素乘以devicePixelRatio。画在屏幕上看着清晰,导出成 PNG 之后发现糊了,就是因为导出时没乘这个系数。

final dpr = MediaQuery.of(context).devicePixelRatio; final targetWidth = (canvasSize.width * dpr).round(); final targetHeight = (canvasSize.height * dpr).round();

但也不是乘得越大越好。我试过按 3 倍导出,一张 1080 宽度的画布导出成 3240 像素,内存瞬间涨到一百多兆,低端机上直接 OOM 崩掉。折中的做法是导出的pixelRatio跟屏幕dpr解耦,固定用 2.0,或者根据画布尺寸动态计算——画布小的时候可以给高倍,画布大的时候压到 1.0 到 1.5 之间。

4. shouldRepaint 的返回值不是越勤越好

CustomPainter的shouldRepaint是性能优化的第一道闸门,但它的行为跟字面意思不太一样:返回true只是"允许重绘",不代表"一定重绘";返回false则是明确告诉框架"这次别管我"。搞反了会导致两种极端——要么该刷新的没刷新,笔迹不动;要么疯狂刷新,帧率崩盘。

4.1 用版本号对比较内容对比更可靠

最常见的错误写法是对比列表:

@override bool shouldRepaint(covariant BoardPainter old) => old.strokes != strokes;

List的!=比的是引用。只要你的strokes每次notifyListeners时传入的是同一个 List 实例(哪怕内容变了),这个比较就永远返回false,笔迹永远不刷新。反过来,如果你每次build都List.from一个新列表,那它永远返回true,等于没优化。

所以我的做法是维护一个自增的version字段,每次数据发生变化就 +1,比较版本号:

@override bool shouldRepaint(covariant BoardPainter old) => old.version != version;

这个方案的好处是永远准确,且开销是一次整数比较。唯一的注意点是:版本号必须在所有引起视觉变化的地方递增,包括颜色切换、粗细调整、清空画布、撤销重做。漏了一处就会出"改完颜色没反应"的 bug,这种 bug 因为跟绘制逻辑无关,排查时会绕很大一圈。

4.2 RepaintBoundary 该加在哪里

RepaintBoundary的本质是隔离图层,让某块区域的重绘不牵连其他区域。在画板场景里,它最该出现的位置是把画布跟外层 UI 隔开,而不是画布内部。

RepaintBoundary( key: _canvasKey, child: GestureDetector( onPanStart: ..., onPanUpdate: ..., child: CustomPaint( size: Size.infinite, painter: BoardPainter(controller.strokes, controller.version), ), ), )

加了这层之后,画布区域的重绘不会传导到外层的标题栏、工具栏,减少了不必要的布局计算。而且_canvasKey在导出toImage的时候能直接派上用场,一举两得。

但要注意,RepaintBoundary不是越多越好。每个边界在 GPU 上都是一个独立图层,图层数量多了,合成开销反而上升。我试过在每条笔迹外面都套一层,结果帧率不升反降——几十个图层合成比直接画在一个图层上贵得多。

4.3 静态笔迹与动态笔迹分开绘制

进阶优化思路是双CustomPainter:一个专门画历史笔迹(懒惰重绘),一个专门画当前正在画的笔迹(每帧重绘)。用Stack叠起来:

Stack( children: [ RepaintBoundary( child: CustomPaint(painter: HistoryPainter(strokes)), ), CustomPaint(painter: ActivePainter(currentStroke)), ], )

这样手指移动时,框架只需要重绘上面那一层,下面几百条历史笔迹的绘制指令完全被跳过。实测在笔迹数量超过 200 条时,这个方案能把每帧耗时压到原来的三分之一左右。代价是代码复杂一点,撤销、清空的时候要同时通知两个 painter。

5. 撤销重做与橡皮擦:状态机比想象中难写

撤销重做看起来简单,实现起来却很容易翻车。见过最典型的翻车是:画了十笔,撤销五次,再画一笔,重做栈没清空,结果点重做跳到了奇怪的状态。根源在于对"历史"这个概念的理解没到位。

5.1 历史栈的两种实现方式怎么选

方案实现方式内存开销适用场景
快照式每次操作存一份完整笔迹列表副本高,随笔迹数量线性增长笔迹少、操作频繁
操作式存操作类型(新增/删除/修改)低,跟操作次数成正比笔迹多、需要精细控制

快照式实现简单,撤销就是strokes = history.pop(),但笔迹多了之后内存压力很大。我算过一笔账:假设一条笔迹存 500 个点,每个Offset占 16 字节,500 个点就是 8KB,一路画 300 条笔迹,一次快照就是 2.4MB。如果历史栈保留 20 步,接近 50MB,在低端机上基本就是崩溃边缘。

操作式更省,但实现起来需要一个完整的命令模型,每种操作都要写正反两个方法。工程量大概多出一倍。

我自己的折中方案是半快照:不复制整份列表,只存索引操作。比如新增笔迹就记录(add, index),删除就记录(remove, index, stroke),撤销的时候按记录反向操作。这样内存开销只有被删掉的笔迹真正占空间,正常绘制流程几乎零额外内存。

abstract class BoardAction { void undo(BoardController c); void redo(BoardController c); } class AddStrokeAction implements BoardAction { final int index; final Stroke stroke; AddStrokeAction(this.index, this.stroke); @override void undo(BoardController c) => c.removeAt(index); @override void redo(BoardController c) => c.insertAt(index, stroke); }

用这套结构,撤销栈的容量可以开到 50 步甚至更多,内存占用依然可控。

5.2 橡皮擦不是"用白色笔画一遍"

这是新手最容易踩的坑。很多人实现橡皮擦就是画一条白色笔迹,看起来能把东西"擦掉",但只要背景不是纯白(比如有网格底纹、有导入的底图),或者导出时要求透明背景,立刻就露馅了。

正确的做法是用混合模式做真正的擦除。Column里如果颜色直接叠上去,白色会盖住一切;用BlendMode.clear才能真正把像素打穿。

// 在绘制历史笔迹之前先开一个图层 canvas.saveLayer(Offset.zero & size, Paint()); for (final s in strokes) { final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = s.width ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; if (s.isEraser) { paint.blendMode = BlendMode.clear; } else { paint.color = s.color; } canvas.drawPath(s.path, paint); } canvas.restore();

关键在于saveLayer。它会创建一个离屏图层,所有绘制先落到图层上,最后整体合成到画布。没有这一层,BlendMode.clear直接作用在画布上,会连背景一起擦成透明。

注意:saveLayer的性能开销不小,笔迹数量多的时候会明显影响帧率。更高效的做法是把笔迹按"是否被擦除过"分段,只有涉及擦除的区段才开图层。这个优化我是在笔迹超过 500 条之后才做的,前期可以先不管。

另外,橡皮擦的strokeWidth一般要比普通笔大 2 到 3 倍,手指的视觉预期跟实际擦除范围需要匹配,太小了会觉得"擦不动"。

6. 笔迹平滑:从折线到曲线的距离有多远

用lineTo直连采样点,在慢速绘制时看起来还行,但快速划线时笔迹会明显呈多边形,拐弯处尤其难看。这是采样点稀疏导致的,不是渲染问题。解决思路有两条:一是插值补点,二是在点之间画曲线。

6.1 二次贝塞尔的中点画法

中点法是最常用也最稳定的做法。核心思路是把相邻两个采样点的中点作为曲线的端点,原始采样点作为控制点,这样画出来的曲线在连接处天然平滑,不需要额外做切线计算。

Path buildSmoothPath(List<Offset> 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.first.dx, pts.first.dy); if (pts.length == 2) { return path..lineTo(pts[1].dx, pts[1].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; }

这个算法有个细节:起点和终点是直接moveTo和lineTo的,中间才是曲线。所以整条笔迹的首尾会稍微"硬"一点,但实际肉眼看不太出来。

还有一个变体是 Catmull-Rom 样条,平滑效果更好,但计算量更大,而且容易出现"过冲"现象——曲线在急转弯处会冲出去一段,视觉上像笔迹越过手指。我的经验是:普通笔记、涂鸦用中点法够了,签名、书法类场景才值得上样条。

6.2 用速度模拟压感

真机没有压感硬件的情况下,可以用速度差模拟粗细变化:划得快的地方线条细,划得慢的地方线条粗,这跟真实笔刷的手感非常接近。

实现上需要把单条笔迹拆成多个小段,每段单独设置strokeWidth:

void drawVariableWidth(Canvas canvas, Stroke s) { for (var i = 1; i < s.points.length; i++) { final dt = (s.timestamps[i] - s.timestamps[i - 1]).clamp(1, 100); final speed = (s.points[i] - s.points[i - 1]).distance / dt; final factor = (1.0 - (speed * 0.6)).clamp(0.35, 1.0); final paint = Paint() ..color = s.color ..style = PaintingStyle.stroke ..strokeWidth = s.width * factor ..strokeCap = StrokeCap.round; canvas.drawLine(s.points[i - 1], s.points[i], paint); } }

注意这里用drawLine而不是构建整条Path,因为每段宽度不同,没法共用一条路径。这个方案性能比单路径差,笔迹长的时候要谨慎用。

速度系数的取值需要反复调,0.6 是我在几台机器上试出来的中间值,太快了线条细得像针,太慢了粗细变化看不出来。真要做得细,还得加一次低通滤波,把速度突变抹平,否则笔迹会出现"一节粗一节细"的斑马纹。

7. 导出图片:分辨率、透明度和内存的三方博弈

导出功能看着最没技术含量,但它是最容易在最后一刻出问题的地方。我就见过导出功能上线前一天还在修 bug 的情况——导出来的图要么背景全黑,要么分辨率不对,要么大画布直接崩。

7.1 PictureRecorder 的正确用法

导出的标准流程是:创建PictureRecorder,拿到Canvas,按pixelRatio缩放,复用绘制逻辑,结束录制,转成Image,再编码成字节。

Future<Uint8List?> exportPng({ required Size canvasSize, required List<Stroke> strokes, double pixelRatio = 2.0, }) async { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); canvas.scale(pixelRatio); // 画白色底(可选,取决于是否需要透明背景) final bg = Paint()..color = Colors.white; canvas.drawRect(Offset.zero & canvasSize, bg); final painter = BoardPainter(strokes, 0); painter.paint(canvas, canvasSize); final picture = recorder.endRecording(); final image = await picture.toImage( (canvasSize.width * pixelRatio).round(), (canvasSize.height * pixelRatio).round(), ); final data = await image.toByteData(format: ui.ImageByteFormat.png); image.dispose(); picture.dispose(); return data?.buffer.asUint8List(); }

三个细节值得强调。第一,canvas.scale必须在任何绘制之前调用,否则缩放只对后续绘制生效,前面的内容位置全错。第二,toImage的宽高要乘pixelRatio,但传入的canvasSize是逻辑尺寸,这俩必须对应上。第三,image.dispose()和picture.dispose()千万别漏,Native 层的图像资源不会自动回收,漏掉几次导出就能把内存吃满。

7.2 大画布导出的内存控制

导出内存的最大头是toImage生成的位图,计算方式是宽 × 高 × 4 字节。一张 2000×2000 的图就是 16MB,如果按 3 倍导出就是 144MB,低端机必崩。

我的处理策略是分三档:

画布尺寸建议 pixelRatio峰值内存
小于 800×8002.0约 10MB
800×800 到 1600×16001.5约 23MB
大于 1600×16001.0约 40MB

如果确实需要超高分辨率,正确的做法是分块导出再拼接,而不是一次性生成大图。实现上就是把画布按网格切开,每次只渲染一块,逐块写进最终的字节流。工程量大一些,但能彻底解决 OOM 问题。

还有一种更轻的方案:如果只是需要保存和分享,不一定要出高清图,可以先用RenderRepaintBoundary.toImage截取当前画布视图,pixelRatio给个 1.5 就够看了。缺点是受当前缩放比例影响,缩放过的话会截不全。

8. 实测中反复栽跟头的几个具体场景

前面讲的是结构和方案,这一节专门记录几个我在真实项目里被卡住过、后来才想明白的具体问题。这些坑不写下来,下次换个项目还会踩。

8.1 setState 引发的连锁重绘

最开始我用StatefulWidget的setState驱动画板,每来一个触点就setState一次。单笔绘制没问题,但加上工具栏显示"当前笔划数"之后,每次触点更新都会连带工具栏一起重建,整个Scaffold子树都重新走一遍build。

改成ChangeNotifier+AnimatedBuilder局部监听之后问题解决了——只把画板和需要显示的数字包进监听范围,其他部分完全不动。更进一步,可以把工具栏那个数字做成ValueListenable单独监听,这样连AnimatedBuilder的开销都省了。

这里的原则很简单:手势频率有多高,监听的粒度就要有多细。绘制过程中每秒上百次通知,任何无关的 widget 都不应该被牵连。

8.2 滚动容器嵌套后的手势冲突

画板嵌在ListView或PageView里的时候,垂直方向的划动会被外层容器抢走,结果是画横线正常、画竖线只能画出一小段就被判定成滚动。这个问题折腾了我一整天。

解决方式是给画板的手势识别器加一层"竞技"配置,让它跟外层容器掰手腕:

RawGestureDetector( gestures: { _EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers< _EagerPanGestureRecognizer>( () => _EagerPanGestureRecognizer(), (instance) { instance ..onStart = _handleStart ..onUpdate = _handleUpdate ..onEnd = _handleEnd; }, ), }, child: CustomPaint(...), )

自定义识别器继承PanGestureRecognizer,重写rejectGesture让它永远不主动认输,同时在addAllowedPointer的时候提高自己的优先级。这样手指一落下,画板就抢到手势控制权,外层滚动容器不会介入。

代价是画布区域内的滚动直接失效,所以实际项目里通常会在画布外留一圈可滚动区域,或者加一个"移动画布"模式,用双指手势触发。

8.3 撤销之后画不出来的幽灵 bug

有一次遇到一个诡异现象:撤销一笔之后,再画新笔迹,前几帧画不出来,抬手之后才突然出现。排查下来发现是shouldRepaint里只比较了version,但撤销操作里我图省事直接操作了内部列表,没递增版本号。

这个 bug 的隐蔽之处在于:撤销本身是能看见效果的(因为画笔在paint里每次都重新遍历列表),但只要没触发重绘,画布就一直显示旧内容。而新笔迹的第一帧因为没有版本变化,也没被刷新,看起来就像"画不出来"。

修法很直接,把列表操作封装成几个方法,每个方法结尾统一递增版本号并通知:

void _mutate(void Function() action) { action(); _version++; notifyListeners(); }

之后所有增删改都走_mutate,再没出过类似问题。这种"统一入口"的写法看着啰嗦,但它能杜绝一整个类别的 bug,非常值。

8.4 撤销栈与内存的长期共存

长期使用的画板类应用,撤销栈必须有上限,否则用户画一天下来能堆几百步历史。但单纯截断栈又会导致"明明还能撤销却点不动"的体验问题。

我现在的做法是双上限:步数上限 50,内存上限 20MB。每次入栈时检查两个条件,超了就从头截掉最老的记录。

void pushAction(BoardAction action, int estimatedBytes) { _undoStack.add(action); _memoryUsed += estimatedBytes; while (_undoStack.length > 50 || _memoryUsed > 20 * 1024 * 1024) { final removed = _undoStack.removeAt(0); _memoryUsed -= removed.estimatedBytes; } _redoStack.clear(); }

estimatedBytes让每个 action 自己算,比如新增笔迹就是点数乘以 16。粗估即可,目的是防爆而不是精算。

这套机制上线后,用户在极端场景(连续画半小时、反复撤销重做)下也没再出现内存暴涨。我觉得对绝大多数画板类需求来说,这个量级是够用的。

如果后续还要扩展,我大概会往两个方向走:一是把笔迹数据做成可序列化结构,支持保存草稿和跨会话恢复;二是引入分块渲染,把超大画布按视口切片,让用户能真的在"无限画布"上作画。这两块的技术栈跟现在这套是一脉相承的,数据层不用大改,主要工作量在渲染调度上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询