资讯动态

Flutter for OpenHarmony数独App撤销机制深度实战

发布时间:2026/10/18 20:17:52 来源:尧图企业网站定制
做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 Setint beforeCandidates; final Setint afterCandidates; } class UndoGroup { final String actionLabel; // 例如 填入数字5、清除候选数 final ListCellChange changes; final DateTime timestamp; }之所以每个变更都同时保存beforeValue与afterValue是因为它天然同时支持撤销和重做。撤销时把after改回before重做时重新应用after不需要额外再存一套反向数据。候选数集合也是同样的道理每次变化都完整记录前后状态避免用当前值推演旧值这种容易出错的做法。我还在UndoGroup上加了一个actionLabel字段看起来不起眼但它后面可以拿来生成撤销历史列表。用户长按撤销按钮时弹出的历史记录可以直接显示填入数字5、撤销候选数变更比只显示第23步直观得多。3. 撤销栈与重做栈的实现细节3.1 核心类设计与主流程有了UndoGroup作为基础撤销管理器就非常朴素了。两个栈一个存撤销历史一个存重做历史class UndoManager { final ListUndoGroup _undoStack []; final ListUndoGroup _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撤销时对整个棋盘setStateOpenHarmony的Flutter运行时在低配测试机型上明显感觉到掉帧。后来我把每个格子拆成ValueListenableBuilder监督各自的ValueNotifier撤销时只让变更记录中提到的格子重建。实测下来单步撤销几乎是瞬时完成对帧率的影响基本消失了。3.3 重做栈的清空策略与防误触重做栈听起来只是撤销的镜像但有几个边界要处理。用户撤销三步之后如果又填了一个新数字那么原来被撤销的三步内容就永久没意义了重做栈必须彻底清空。这个逻辑看起来简单但最容易出错的是时机。如果用户是在撤销动画播放中途点了新操作新操作已经入栈动画回调又触发了一次栈操作重做栈就可能残留脏数据。我在实际项目里用了一个版本号机制每次用户触发新操作时_version撤销/重做回调里检查版本号是否与发起时一致不一致就放弃继续。这个方案粗暴但非常有效。另外撤销按钮本身要防抖动。不是怕用户连续快速点击而是怕用户在撤销动画过程中又点一次导致棋盘状态和栈内容错位。我的做法是在undo()执行前设置一个_isUndoing标志动画期间直接忽略新的点击事件动画结束才恢复可点击。连续快速撤销的需求可以让用户长按按钮来触发后面第五章会提。我还给撤销按钮做了一个很实用的状态管理栈为空时按钮置灰栈非空时高亮。这个看起来是小细节但能极大减少用户无效点击。配合局部刷新策略按钮自身的状态用独立的ValueNotifierbool来驱动不会每次操作都重建整个底部工具栏。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 Listint boardSnapshot; // 81格数值快照 final int stepIndex; // 对应撤销栈中的第几步 } class UndoManagerWithSnapshot extends UndoManager { final ListSnapshotPoint _snapshots []; static const int snapshotInterval 10; }每执行10步操作我就在快照链表里追加一条。执行撤销时先定位到目标步骤附近最近的快照再从这个快照状态出发沿着差分记录重放到目标步骤。这样既保留了差分的轻量又把跳远距离的回放开销降下来了。要特别留意的是撤销后做了新操作重做栈清空快照链表也得跟着做相应清理。比目标步骤更早的快照可以保留比目标步骤更新的快照必须全部移出否则加载时会出现索引错位棋盘瞬间变成一个错乱状态。这个方案不是必须的但如果你后续想加历史时间轴步骤回放动画这类功能混合存储几乎是最好的起点。5.3 我目前对这部分的取舍最后说说现在的实现里我实际敢放心交付给用户的是哪套方案。我保留的是差分记录加最近20步快照配合步骤分组。核心交互上撤销和重做各占一个栈按钮状态由独立监听器驱动。历史跳转我做了但入口藏得比较深放在长按撤销按钮的弹出列表里只展示最近20条记录。更早的历史我选择不展示因为用户能真正用到的跳转范围基本就是最近几步太远的回退反而容易让人迷失在推理链条里。这里有一个产品层面的体会撤销做得越深用户越容易被悔棋到几十步之前诱惑。但数独这种游戏赢了才成立过程的公平性同样重要。因此真正的数独App往往只允许回退自己的操作不允许回退到提示系统给出的自动填充结果之外的内容。保留合理的历史深度既是性能考虑也是克制。如果你也在做类似的功能我建议先把一步到底是什么这个问题和产品对齐再动手写撤销栈。很多撤销相关的Bug不是逻辑写错而是大家对一步的理解不一样导致自动候选联动、批量操作合并这些地方各写各的。我在这套模拟项目上重写过两次UndoManager前两次都是因为步骤粒度没想清楚第三次把步骤分组和事件同步栅栏想明白之后整个功能才算稳稳立住了。最后分享一个小技巧是我在实际使用中获得好评最多的交互把撤销按钮做成可以长按连续撤销按住不松手它会每300毫秒自动出栈一次直到栈空或用户松手。这个操作对用户在复杂局面下快速退回到某个思路分支特别好用也让撤销功能不再只是一个一下一下点的机械按钮而是一个真正趁手的操作工具。实现上只需要给撤销按钮加一个长按状态机配合栈非空的判断去循环调用整体成本很低推荐你试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑