做Flutter for OpenHarmony数独游戏App的实战里,撤销功能看起来是个配角,其实才是用户每天都要摸到的按键。填错一个格子,用户的第一反应不是去找橡皮擦,而是找撤销;策略走错了,想退回几步重新推演,也是按撤销。真正把撤销做扎实之后我发现,它根本不是"用一个栈存一存状态"那么简单,背后牵扯到候选数联动、错误校验、重做栈清空、棋盘局部刷新,甚至还要跟输入法抢事件。
这篇文章就把我在模拟项目X上实现撤销功能的全过程拆开讲,从需求粒度到数据结构,再到OpenHarmony平台上踩过的几个特殊坑。适合正在用Flutter开发棋盘类小游戏、或者需要适配类Harmony系系统特性的开发者参考。如果你只是好奇"撤销到底怎么设计才好用",这里面也会有产品层面的思考。
1. 为什么说撤销功能是数独App的隐形核心
1.1 用户体感从"格子填错"开始
很多新手做数独App,第一版往往只做棋盘渲染、数字录入、胜负判断,等到用户开始玩了,第一个反馈一定是"能不能撤销"。我最初也没当回事,觉得无非是记录一下历史格子值,回退就完事了。但实际体验下来,撤销的响应速度、撤销粒度、撤销后的界面一致性,直接决定用户对App流畅感的判断。
你想一下典型场景:用户填格子时看错行列,把一个候选数点进了确定格里;或者做数独提示功能时,用户想看看某格填某个数字之后会不会引发全局冲突,试完之后想退回去。这些动作的频率远超完成整盘的难度操作。所以撤销并不只是一个纠错入口,它是用户在棋盘上"反复试探"的底气,也是对数独这种推理型游戏最核心的交互姿态。
1.2 撤销不只回退数值,还要保证业务状态一致
数独App的棋盘状态不只是"81个格子各自是什么数字"。很多实现里还会附加候选数集合、错误标记、当前选中格、高亮单元等派生状态。尤其麻烦的是自动候选模式:当你填入数字5,程序会自动把同行、同列、同宫内所有候选数里的5移除。如果撤销机制只回退了格子值,却没有回退那些候选数变更,那整个棋盘看过去就非常诡异——这格子的数字没了,但同行其他格子里的候选数已经被清过了,接下来用户按正常推理逻辑操作会直接走偏。
所以地址撤销的第一步不是写代码,而是把"一步"定义清楚。我最后把一次"可感知的操作"作为最小步骤单元,具体包含:填一个确定值、清一个格子、切换一个候选数、批量清理候选数(如自动排除某个数字)、以及工具类操作(如清空错误标记)。每一次操作内部可能改动多个格子的多个属性,但对外部用户来说,它就是一步。
1.3 这篇实战记录会覆盖什么
后面内容会按我当时的推进顺序来:先讲撤销数据怎么存,再讲撤销栈和重做栈的核心实现,接着分享OpenHarmony上我实际撞到的几个坑,最后给一套我验证过比较舒服的进阶方案。整体偏工程实现,代码以核心流程为主,不贴完整工程,大家根据自己项目往里套就行。
2. 一条撤销记录的"粒度"设计:从单格变到步骤分组
2.1 三种存储方案与我的取舍
我最早想到的方案是每操作一步就把整个棋盘81个格子的值做一次快照,撤销时直接把旧快照覆盖回去。这个思路最简单,恢复也是O(1)级别。但问题在于,数独棋盘虽然只有81格,每步全量存储还是要完整保存数组结构。如果一步接着一步操作上百次,内存就变成了一个不断增长的列表,而且快照只记录结果,丢失了"从哪个格子变成哪个数字"的语义,后续想做动画和变更提示会很麻烦。
第二种方案是差分记录,只保存变化的格子,比如(行, 列, 旧值, 新值),撤销时按反转顺序回放。这样做内存占用小,还能轻松得到变更描述,可以用于UI提示和动画。缺点是不能直接"跳到任意历史步骤",因为要逐个回放,但如果只是做撤销和重做,这个缺点完全不是问题。
第三种方案是把每一步封装成命令对象,配合反向命令做撤销。适合操作类型很复杂、需要支持宏命令回放的场景。数独的操作集很小,远没到需要命令模式的程度,所以我没有选它。
最终我选了差分记录,并且在一层之上做了"步骤分组"处理。我测试期间的实际数据是:正常完成一局数独大约需要操作80到120步,差分方案栈内存占用几乎可以忽略,而全量快照方案同一局会多出几MB的浮动——在OpenHarmony的低内存设备上,这个差异还是挺明显的。
| 方案 | 存储内容 | 恢复速度 | 内存占用 | 是否保留变更语义 | 适用场景 |
|---|---|---|---|---|---|
| 全量快照 | 每一步的81格完整状态 | O(1) | 高 | 否 | 小步数、需要任意跳转 |
| 差分记录 | (行,列,旧值,新值) | O(k) | 低 | 是 | 常规撤销、重做 |
| 命令对象 | 命令+反向命令 | O(k) | 中 | 是 | 复杂操作、宏回放 |
2.2 步骤分组:一次操作可能改动十几个格子
这里有个关键问题:如果用户在自动候选模式下填了一个数字5,程序不一定只改这一个格子,它可能同时清掉同行同列同宫所有格子里的候选数5。假设一共改了12个格子,那么这些变更应该算作"12步"还是"1步"?
如果算作12步,用户点一次撤销,棋盘却要连续跳回12个状态,视觉上非常混乱,也很容易让用户误以为按钮失灵。所以我的做法是用beginStepGroup和endStepGroup把一次逻辑动作里产生的一组变更合并成一条撤销记录。用户在一个动作里改了多少格子,对外都只是一步。
这里的判断标准是"触发器边界"。一次点击、一次长按、一次面板输入、一次自动清理,每个触发器的开始到结束,算保留边界。如果在代码里通过同一个setter连续改动多个格子,且有意识地把它们放入同一个分组,那它们就是一步。这个设计需要产品层面先对齐,否则开发写代码时会各行其是,出现"有些撤销一步跳很远,有些撤销只动一个格子"的割裂体验。
2.3 数据结构的落地形态
我最终使用的数据结构长这样:
class CellChange { final int row; final int col; final int? beforeValue; final int? afterValue; final Set<int> beforeCandidates; final Set<int> afterCandidates; } class UndoGroup { final String actionLabel; // 例如 "填入数字5"、"清除候选数" final List<CellChange> changes; final DateTime timestamp; }之所以每个变更都同时保存beforeValue与afterValue,是因为它天然同时支持撤销和重做。撤销时把after改回before,重做时重新应用after,不需要额外再存一套反向数据。候选数集合也是同样的道理,每次变化都完整记录前后状态,避免用"当前值推演旧值"这种容易出错的做法。
我还在UndoGroup上加了一个actionLabel字段,看起来不起眼,但它后面可以拿来生成撤销历史列表。用户长按撤销按钮时,弹出的历史记录可以直接显示"填入数字5"、"撤销候选数变更",比只显示"第23步"直观得多。
3. 撤销栈与重做栈的实现细节
3.1 核心类设计与主流程
有了UndoGroup作为基础,撤销管理器就非常朴素了。两个栈,一个存撤销历史,一个存重做历史:
class UndoManager { final List<UndoGroup> _undoStack = []; final List<UndoGroup> _redoStack = []; UndoGroup? _pendingGroup; void openGroup(String label) { _pendingGroup = UndoGroup(actionLabel: label, changes: []); } void pushChange(CellChange change) { if (_pendingGroup != null) { _pendingGroup!.changes.add(change); } else { _undoStack.add(UndoGroup(actionLabel: '修改格子', changes: [change])); } } void closeGroup() { if (_pendingGroup != null && _pendingGroup!.changes.isNotEmpty) { _undoStack.add(_pendingGroup!); } _pendingGroup = null; } void undo() { if (_undoStack.isEmpty) return; final group = _undoStack.removeLast(); for (final change in group.changes.reversed) { _applyChange(change, forward: false); } _redoStack.add(group); } void redo() { if (_redoStack.isEmpty) return; final group = _redoStack.removeLast(); for (final change in group.changes) { _applyChange(change, forward: true); } _undoStack.add(group); } }这段代码最核心的注意点是_applyChange。我当时踩过的坑就在这里,一开始图省事,撤销时直接操作格子数组,绕过了正常填数的setter。结果自动候选模式完全错乱,因为正常的填数流程里setter会同时处理候选数的联动剔除,而撤销路径绕过去了,只回退数字不回退候选数。后来我改成撤销也必须走同一个setter,让所有派生状态跟着更新,问题才消失。
另一个细节是撤销时changes.reversed,因为一次操作内部可能按顺序改了A格子再改B格子,回退时必须逆序,否则如果同一个格子在一组变更里出现多次,正序回放会出现中间态错乱。
3.2 撤销后的状态刷新:校验、高亮、当前格
撤销之后,界面上至少要处理三件事。
第一,错误标记要重新计算。用户因为某格填错而触发了错误高亮,撤销之后冲突可能已经解除,错误标记必须立刻消失,不能等到下一次填数才刷新。最简单的做法是每次撤销/重做触发一次全盘错误校验,因为81格因为一两次变更算总冲突其实很快,性能上没有压力。
第二,当前选中格要保持在合理位置。如果用户撤销的是一个非选中格的变更,选中格不应该跳走,更不应该清空。如果撤销的就是当前选中格本身,比如刚填进去一个数字马上撤销,那么该格继续保持选中状态是最自然的,用户大概率会继续在这个格里尝试新的数字。
第三,候选数面板要同步刷新。很多数独App支持点击格子后弹出候选数气泡,撤销影响到的格子如果正好是不活跃候选面板的数据源,不刷新的话气泡里会残留已经过时的候选数。我统一把候选数变更也包进CellChange里,这样撤销后只需监听受影响格子的状态变化,所有界面组件会通过监听器自动刷新。
这里顺手说一个局部刷新的经验。早期我的页面是一个根StatefulWidget,撤销时对整个棋盘setState,OpenHarmony的Flutter运行时在低配测试机型上明显感觉到掉帧。后来我把每个格子拆成ValueListenableBuilder,监督各自的ValueNotifier,撤销时只让变更记录中提到的格子重建。实测下来,单步撤销几乎是瞬时完成,对帧率的影响基本消失了。
3.3 重做栈的清空策略与防误触
重做栈听起来只是撤销的镜像,但有几个边界要处理。
用户撤销三步之后,如果又填了一个新数字,那么原来被撤销的三步内容就永久没意义了,重做栈必须彻底清空。这个逻辑看起来简单,但最容易出错的是时机。如果用户是在撤销动画播放中途点了新操作,新操作已经入栈,动画回调又触发了一次栈操作,重做栈就可能残留脏数据。我在实际项目里用了一个"版本号"机制:每次用户触发新操作时_version++,撤销/重做回调里检查版本号是否与发起时一致,不一致就放弃继续。这个方案粗暴但非常有效。
另外,撤销按钮本身要防抖动。不是怕用户连续快速点击,而是怕用户在撤销动画过程中又点一次,导致棋盘状态和栈内容错位。我的做法是在undo()执行前设置一个_isUndoing标志,动画期间直接忽略新的点击事件,动画结束才恢复可点击。连续快速撤销的需求,可以让用户长按按钮来触发,后面第五章会提。
我还给撤销按钮做了一个很实用的状态管理:栈为空时按钮置灰,栈非空时高亮。这个看起来是小细节,但能极大减少用户无效点击。配合局部刷新策略,按钮自身的状态用独立的ValueNotifier<bool>来驱动,不会每次操作都重建整个底部工具栏。
4. OpenHarmony上实现的三个特殊坑与对应处理
4.1 中文字体与弹层控件渲染问题
我在OpenHarmony设备上首次跑通撤销功能时,棋盘、格子数字都正常,但统计面板、撤销历史弹窗里的中文全部变成了方块。查了半天发现是Flutter默认的字体栈里有Roboto,类Harmony系系统上不一定有可用的默认中文字体,导致中文回退失败。
解决方式有两种,一种是在ThemeData里统一指定fontFamily: "sans-serif",另一种是把目标字体资源打包进App,在MaterialApp里全局配置。我的项目用的是前者,改完之后弹窗、菜单、对话框里的中文全部恢复正常。这个小坑非常隐蔽,如果从头到尾只在模拟器上看效果可能永远遇不到,真机一跑就露馅。
4.2 软键盘与棋盘布局的挤压冲突
数独App免不了要处理输入。用户偶尔需要输入一个自定义数字来跳转到某个坐标,或者对手动输入框做快速填数。系统的resizeToAvoidBottomInset默认会把整个界面顶上去,棋盘在这个压缩过程里会变得非常扁,九行格子几乎被压变形,最下面一行甚至会被截掉。
我的处理方式是:把棋盘页面的键盘避让关掉,改用自定义数字面板完成输入动作。因为数独本身只需要输入1到9的数字,完全不需要系统键盘的复杂按键和候选词。自定义数字面板放在棋盘下方,不管键盘弹不弹,它都固定在底部工具栏区域。这个改法同时解决了另外两个问题:用户不会被系统输入法中途打断视线,对OpenHarmony上输入法弹出慢、有时会残留半秒白屏的体验也完全规避了。
如果你实在需要保留系统键盘,那就要监听键盘高度变化,手动给列表底部留白,而不是依赖Flutter的默认避让。但我的个人建议是,棋盘类工具能绕开系统键盘就绕开,定制输入层的成本远低于适配各种输入法怪癖的成本。
4.3 事件竞态:输入动作和撤销动作同时触发的诡异现象
这是我在OpenHarmony真机上遇到的最迷惑的一个问题。大概是这样的场景:用户弹出自定义输入面板,填了一个数字,马上伸手去点撤销按钮。结果数字填进去了,撤销也执行了,但棋盘最终状态却是"数字还在,撤销无效"。
排查到最后发现,原因是输入面板的按键事件和按钮点击事件在同一帧里先后触发,底层事件顺序不稳定。输入面板把数字写入格子的时候,撤销按钮的点击已经被分发到了按钮控件,但那时撤销栈里还没有包含刚才那次输入。等撤销执行完,输入事件的回调才真正生效,又把数字填了回来。
解决办法并不复杂:在执行撤销操作之前,先主动unfocus()并清空输入面板的挂起状态,然后同步当前是否有未收尾的输入事件。如果有,先把输入事件处理完,再清掉输入事件队列里残余的更新。我在UndoManager和输入面板之间加了一个简单的"事件同步栅栏",所有输入事件必须被标记为已提交后,撤销按钮才可以触发栈操作。从那之后,这个诡异现象再没出现过。
这个坑的核心经验是:在OpenHarmony这样偏定制、且事件分发细节不一定和Android完全一致的平台上,不能假设"用户不会同时做两件事"。交互上要允许叠加操作,但底下必须有一层明确的"事件提交顺序"约束。
5. 往后想一步:撤销历史、跳转回放与性能优化
5.1 从"撤销一步"到"跳转到历史任意一步"
撤销功能做扎实之后,我忍不住更贪心了一点:能不能让用户直接看到历史操作列表,点一下就能跳回任意步骤?这个需求在体验上很直观,但在数据结构上是个分水岭。
如果栈里保存的是差分记录,跳到历史第10步,就要从当前状态开始逐个回放差异,一直退到目标步骤。对最近几步来说完全没问题,但要撤到三四十步之前,逐个回放明显变慢。我最后采用了一个折中方案:在差分记录不变的情况下,额外保存"最近20步"的全量快照。用户如果只需要回退20步以内,直接加载对应快照;如果超过了,就从最近的快照开始往回放。
这个方案比纯快照省内存,比纯差分响应快,测试下来感觉很均衡。对OpenHarmony这种内存可能不太宽裕的嵌入式设备来说,算是比较保守的工程取舍。如果你的App不打算做历史跳转,那连快照都不需要,纯差分足够。
5.2 快照+差分的混合存储策略
实际的混合存储设计是这样的:
class SnapshotPoint { final List<int> boardSnapshot; // 81格数值快照 final int stepIndex; // 对应撤销栈中的第几步 } class UndoManagerWithSnapshot extends UndoManager { final List<SnapshotPoint> _snapshots = []; static const int snapshotInterval = 10; }每执行10步操作,我就在快照链表里追加一条。执行撤销时,先定位到目标步骤附近最近的快照,再从这个快照状态出发,沿着差分记录重放到目标步骤。这样既保留了差分的轻量,又把"跳远距离"的回放开销降下来了。
要特别留意的是,撤销后做了新操作,重做栈清空,快照链表也得跟着做相应清理。比目标步骤更早的快照可以保留,比目标步骤更新的快照必须全部移出,否则加载时会出现索引错位,棋盘瞬间变成一个错乱状态。
这个方案不是必须的,但如果你后续想加"历史时间轴""步骤回放动画"这类功能,混合存储几乎是最好的起点。
5.3 我目前对这部分的取舍
最后说说现在的实现里,我实际敢放心交付给用户的是哪套方案。
我保留的是差分记录加最近20步快照,配合步骤分组。核心交互上,撤销和重做各占一个栈,按钮状态由独立监听器驱动。历史跳转我做了,但入口藏得比较深,放在长按撤销按钮的弹出列表里,只展示最近20条记录。更早的历史我选择不展示,因为用户能真正用到的跳转范围基本就是最近几步,太远的回退反而容易让人迷失在推理链条里。
这里有一个产品层面的体会:撤销做得越"深",用户越容易被"悔棋到几十步之前"诱惑。但数独这种游戏,赢了才成立,过程的公平性同样重要。因此真正的数独App往往只允许回退自己的操作,不允许回退到提示系统给出的自动填充结果之外的内容。保留合理的历史深度,既是性能考虑,也是克制。
如果你也在做类似的功能,我建议先把"一步到底是什么"这个问题和产品对齐,再动手写撤销栈。很多撤销相关的Bug不是逻辑写错,而是大家对"一步"的理解不一样,导致自动候选联动、批量操作合并这些地方各写各的。我在这套模拟项目上重写过两次UndoManager,前两次都是因为步骤粒度没想清楚,第三次把步骤分组和事件同步栅栏想明白之后,整个功能才算稳稳立住了。
最后分享一个小技巧,是我在实际使用中获得好评最多的交互:把撤销按钮做成可以长按连续撤销,按住不松手,它会每300毫秒自动出栈一次,直到栈空或用户松手。这个操作对用户在复杂局面下快速退回到某个思路分支特别好用,也让撤销功能不再只是一个"一下一下点"的机械按钮,而是一个真正趁手的操作工具。实现上只需要给撤销按钮加一个长按状态机,配合栈非空的判断去循环调用,整体成本很低,推荐你试试。