Flutter迁移OpenHarmony:数独应用撤销功能与状态管理实战

发布时间:2026/10/17 9:33:15
Flutter迁移OpenHarmony:数独应用撤销功能与状态管理实战 把 Flutter 搬上 OpenHarmony我第一个完整练手的项目选了数独游戏 App。选它不是图简单而是这个题材能把跨端 UI、自绘布局、状态管理、逻辑纯 Dart 化这几条关键链路全部串起来又不需要任何平台独有能力特别适合做适配验证。真正让我意外的是整个项目里占调试时间最久的不是数独生成也不是界面布局而是那个看起来人畜无害的“撤销”按钮。这篇就把这个数独 App 从架构设计到撤销功能落地的完整过程拆开讲包括快照方案选型、UndoManager 实现、OpenHarmony 环境适配、真机调试踩坑以及一组可以直接抄的核心代码。正在研究 Flutter 多端适配、或者想在 OpenHarmony 上做工具类小 App 的开发者应该能从里面拿走不少实际可用的东西。1. 项目起点与整体架构设计1.1 为什么是“Flutter OpenHarmony 数独”这个组合OpenHarmony 的应用开发主流是它自己的 ArkUI 声明式范式但 Flutter 在 OpenHarmony 上已经有一条非常活跃的适配分支Flutter 的 Skia 自绘引擎天然不依赖系统控件所以 UI 层搬到 OpenHarmony 时行为一致性比传统 WebView 方案要好得多。我当初选这个组合就是为了验证“一套 Flutter 代码能不能在 OpenHarmony 上跑得干净”数独正好是压力最小的验证载体。数独这个游戏逻辑不复杂但状态不少81 个格子、数字面板、错误标记、候选数、当前选中格、撤销历史。它足以暴露状态管理上的问题又不会把问题复杂到失控。比起做一个列表页 Demo数独能逼着我把“数据层、逻辑层、UI 层”的边界先厘清否则撤销功能写到一半就会变成一团乱麻。这个选择也对“能不能跑”这个问题给出了更真实的答案如果连带交互状态的小游戏都能在 OpenHarmony 上流畅跑那么普通的工具类和内容类 App 基本不用担心。1.2 功能范围界定与版本规划我把这个项目的功能范围卡得很小第一版只做了这几件事生成一局有唯一解的数独棋盘、点击格子后从数字面板选择数字、自动检测同行的冲突并标红、旁边放一个清除按钮、右上角放一个撤销按钮、左上角放一个“新一局”。候选数辅助、难度分级、计时器、错误次数统计全部推后。这样做的原因是撤销功能的质量取决于整个状态模型是否干净而不取决于功能数量。过早把错误提示、计时器、帮助功能叠进来会让“撤销时到底要回滚哪些状态”这个问题变得模糊。我见过不少项目功能很多但撤销只回滚了棋盘数字高亮、候选数、提示状态全部残留玩家只好一遍遍手动清理体验非常奇怪。所以第一版宁缺毋滥把撤销做成“闭环”比做出一堆半成品功能更重要。1.3 分层UI、状态、逻辑的边界整个工程目录我是这样组织的先有个宏观印象lib/ main.dart models/ cell_state.dart board_snapshot.dart logic/ sudoku_engine.dart state/ undo_manager.dart sudoku_controller.dart ui/ game_screen.dart number_panel.dart sudoku_grid.dart关键原则只有一条UI 层永远不直接写棋盘数组。所有修改必须经过 SudokuController控制器再决定要不要生成新快照、要不要通知 UI 刷新。这样做之后“撤销”就变成了一个纯粹的控制器行为把当前快照换成历史快照UI 自然跟着变。为了达到这个效果我在动手写 Flutter Widget 之前先花了一天时间把 CellState、BoardSnapshot、UndoManager 三个类全部写完并用纯 Dart 测试脚本跑了一遍模拟操作。事实证明这个前置投入非常值后面在 OpenHarmony 真机上出现的几乎所有疑难问题都跟 UI 本身无关而是状态边界没处理好。2. 撤销功能的前置思考先想清楚要撤销“什么”2.1 “一步”到底指什么做撤销功能第一件要想清楚的事是“一次撤销到底撤回哪一个动作”。这个定义如果含糊后面所有逻辑都是糊涂账。我当时列了三个候选定义按“手指点击”算一步玩家连续点了两次“删除候选数”撤销也要一步一步回退体验很碎。按“格子的有效数值变化”算一步选手在同一个格里改三次数字那三次修改就是三个独立历史节点。按“一次完整编辑会话”算一步一个格子没定下来之前所有修改算一步提交后合并。我最后选了第二种也就是按“格子数值或候选数状态的一次有效变化”来记录。理由是它最符合玩家直觉玩家每一步操作都有一个明确结果撤销时能一步到位中间不会出现“删除了一下却没变化”的空历史。这个决策直接影响 UndoManager 的 push 时机。不是每次按键都 push而是只有当格子里的实际状态发生变化时才 push。比如玩家先填了 5又填了 5第二次操作由于没有产生任何新状态就不应该记入历史。2.2 快照方案还是命令模式做过编辑器类应用的人一听到撤销第一反应往往是“命令模式”。命令模式把所有操作抽象成一个个 Command每个 Command 记录执行和反向执行逻辑然后维护 undo 栈和 redo 栈。这个思路在大型文档编辑器、绘图软件里是标准解因为一张图片或一篇文档的内存快照大到不可接受。但数独棋盘有 81 个格子每个格子的状态也就是“数字 候选数位掩码 是否给定的标志”满打满算几十个字节。一个快照撑死 1KB存 200 步历史也就 200KB 左右放在今天任何移动设备上都不值一提。所以我直接选了“不可变快照 历史栈”的方案每次格子状态发生变化就把整张棋盘当前状态复制一份存进历史栈撤销时直接切回上一份快照。这看起来“笨”但它是这个场景下最稳定、最简单、最少 bug 的方案。命令模式虽然优雅但需要为每一个操作类型实现 execute 和 undo还要处理“撤销后重新计算联动状态”的问题。数独这种小棋盘用快照换逻辑简单非常划算。2.3 快照里到底要放哪些字段快照内容我最初只放了 81 个数字真机一跑就发现问题玩家撤销之后数字倒是回退了但之前填错数字时产生的红色冲突标记还留在屏幕上。原因很简单冲突状态不是独立数据而是从棋盘数字推导出来的我在第一次实现时却没有在撤销后重新推导。所以我在 BoardSnapshot 里最终放了两个东西每个格子的数字以及每个格子的候选数位掩码。格子是否选中、是否标红都属于“派生状态”撤销后由 SudokuController 根据当前棋盘重新计算一遍而不是直接塞进快照。这个取舍很重要。如果什么状态都往快照里塞快照之间互相牵制很容易出现“数字恢复了但 UI 状态没恢复”的诡异现象。把派生状态排除在快照之外撤销逻辑就只负责一件事让真实数据回到过去然后让所有 UI 重新从真实数据算一遍。3. 工程落地Flutter for OpenHarmony 环境准备3.1 OpenHarmony SDK 与 Flutter 适配分支OpenHarmony 上跑 Flutter首先要准备好一套适配分支的 Flutter SDK因为 Flutter 官方主干还没有把 OpenHarmony 作为一等平台支持。具体步骤大概是这样# 1. 安装 OpenHarmony SDK并记录 SDK 路径 export OPENHARMONY_SDK_HOME/path/to/ohos-sdk # 2. 拉取支持 OpenHarmony 的 Flutter SDK 分支 # 这里用一个稳定的 release 分支 # 3. 把 SDK 相关路径加入 PATH export PATH/path/to/flutter-for-ohos/bin:$PATH # 4. 检查环境 flutter doctorflutter doctor 之后如果一切正常再用flutter devices能看到通过调试桥连接的真机或模拟器说明工程链路已经通了一半。这里有个比较容易踩的坑Flutter 工程默认从官方源拉依赖包但在 OpenHarmony 适配环境里很多包没有兼容的二进制产物所以我直接把依赖源切到了对应的兼容仓库纯 Dart 包则不受影响。3.2 依赖管理注意点数独 App 全程没有加任何带原生代码的第三方插件。这确实是刻意为之。在 OpenHarmony 适配早期Flutter 插件生态还没有完全迁移凡是走 MethodChannel 的插件都需要对应 OpenHarmony 原生侧的实现否则编译到原生部分就会断。数独逻辑纯 Dart 实现UI 由 Flutter 自绘完成所以从一开始就规避了整个插件兼容性问题。最终依赖极简只有 flutter 本身和一个小状态管理库。之所以没用更重的状态管理框架是因为这个项目状态足够集中ChangeNotifier 配合一个控制器类已经够用。多加一层框架反而会让“撤销时重新计算派生状态”这件事变绕。给同样入手的开发者一个建议先别急着引一堆状态管理包把“谁拥有状态、谁负责改状态”想清楚比选哪个框架重要得多。3.3 工程目录与模块划分代码层的目录我在前面已经给了这里补充说明每层职责。models 层只描述数据不含任何业务逻辑。logic 层是纯函数集合负责数独生成、合法性判断、冲突检测。state 层是控制器和撤销管理器是整个 App 的“大脑”。ui 层只负责把 state 渲染出来不存储任何游戏状态。这样的分层虽然一开始写起来多几个文件但调试撤销相关 bug 时非常幸福问题要么在 state 层要么在 ui 层定位范围瞬间缩小一半。我在真机上遇到“撤销后数字面板高亮错位”排查时直接跳过 logic 层只查 controller 里的派生状态计算几分钟就找到原因。4. 核心实现数据模型与撤销管理器4.1 CellState 与 BoardSnapshot数据模型的不可变是快照撤销的命根子。如果同一个 CellState 对象被外部修改了那么排进历史栈的所有快照都会跟着变撤销就会彻底失效。所以我所有的数据类都只提供 copyWith没有提供 setter。class CellState { final int number; // 0 表示未填 final int candidates; // 9 位二进制每一位代表一个候选数 final bool isGiven; // 是否为题目初始给定 const CellState({ this.number 0, this.candidates 0, this.isGiven false, }); CellState copyWith({ int? number, int? candidates, bool? isGiven, }) { return CellState( number: number ?? this.number, candidates: candidates ?? this.candidates, isGiven: isGiven ?? this.isGiven, ); } CellState clone() copyWith(number: number, candidates: candidates, isGiven: isGiven); }候选数用 int 的位掩码而不是 Set因为在 Dart 里每个 Set 对象都是堆分配复制 81 个格子时会产生大量垃圾对象。位掩码只是一个 int复制成本几乎为零后面真机跑起来 GC 压力也小很多。BoardSnapshot 就更直接了持有一个不可变列表。class BoardSnapshot { final ListCellState cells; BoardSnapshot(ListCellState cells) : cells List.unmodifiable(cells); BoardSnapshot clone() { return BoardSnapshot(cells.map((c) c.clone()).toList()); } }我在构造函数里强制 List.unmodifiable就是为了防止外部拿到列表后往里塞东西。快照系统的第一原则一旦进入历史栈任何代码都不能改动它。4.2 UndoManager 的设计UndoManager 是所有撤销逻辑的收口我把它设计得很小只维护一个历史列表和一个当前游标class UndoManager { final ListBoardSnapshot _history []; int _currentIndex -1; bool get canUndo _currentIndex 0; void push(BoardSnapshot snapshot) { // 先把当前游标之后的历史丢弃再追加新快照 if (_currentIndex _history.length - 1) { _history.removeRange(_currentIndex 1, _history.length); } _history.add(snapshot); _currentIndex; } BoardSnapshot? undo() { if (!canUndo) return null; _currentIndex--; return _history[_currentIndex]; } BoardSnapshot? redo() { if (_currentIndex _history.length - 1) return null; _currentIndex; return _history[_currentIndex]; } }注意 push 里先截断后半段历史这是为了实现“新操作会清掉未来的 redo 分支”。比如玩家撤销三次改了一个数字那么原来的 redo 历史就应该全部作废不然就会出现“时间线分叉”。整个 UndoManager 不关心数独规则它只处理“快照进栈”和“游标回退”两件事。这让我可以在不碰数独功能的情况下单独给 UndoManager 写单元测试覆盖连续 undo、undo 之后 push、空栈调用 undo 等边界情况。4.3 数独引擎与冲突检测数独引擎我只写了两部分生成题目、检测冲突。生成题目的简单做法是先用回溯填满一个完整终盘再按随机顺序挖去一些数字。完整回溯代码比较长核心思路就是逐格尝试 1 到 9并跳过同行同列同宫已经出现过的数字。挖洞环节我做了个轻量约束保证每个被挖掉的格子都还有唯一答案。严格生成唯一解数独需要额外做“是否有且仅有一个解”的判断我在这版里用的是随机挖洞加基础校验先把第一版跑起来难度均衡可以放到后面优化。冲突检测是撤销后派生状态重算的关键。它不修改任何数据只返回一个布尔列表供 UI 标红使用Listbool computeConflicts(BoardSnapshot snapshot) { final cells snapshot.cells; final conflict Listbool.filled(81, false); bool checkConflict(int a, int b) { if (cells[a].number 0 || cells[b].number 0) return false; return cells[a].number cells[b].number; } // 检查行、列、宫有冲突就把两个格子都标红 for (int row 0; row 9; row) { final map int, Listint{}; for (int col 0; col 9; col) { final index row * 9 col; final v cells[index].number; if (v 0) continue; map.putIfAbsent(v, () []).add(index); } for (final list in map.values) { if (list.length 1) { for (final index in list) { conflict[index] true; } } } } // 列和宫同理省略按同思路处理 return conflict; }这个函数不依赖历史每次调用都从当前快照重新计算因此撤销后 UI 拿到的冲突标记天然就是正确的。4.4 SudokuController把编辑动作变成状态变化SudokuController 继承 ChangeNotifier是整个状态流转的枢纽。它内部持有一个当前快照、一个 UndoManager、以及一份冲突列表。UI 上每次点击数字最终都调用同一个方法。class SudokuController extends ChangeNotifier { SudokuController() : _undoManager UndoManager(); final UndoManager _undoManager; BoardSnapshot? _currentSnapshot; Listbool _conflicts List.filled(81, false); BoardSnapshot? get currentSnapshot _currentSnapshot; Listbool get conflicts _conflicts; bool get canUndo _undoManager.canUndo; void loadPuzzle(BoardSnapshot puzzle) { _currentSnapshot puzzle; _undoManager.clear(); _refresh(); } void fillCell(int index, int number) { if (_currentSnapshot null) return; final cells _currentSnapshot!.cells; final cell cells[index]; if (cell.isGiven) return; if (cell.number number) return; final nextCells ListCellState.of(cells); nextCells[index] cell.copyWith(number: number, candidates: 0); final nextSnapshot BoardSnapshot(nextCells); _undoManager.push(nextSnapshot); _currentSnapshot nextSnapshot; _refresh(); } void undo() { if (_currentSnapshot null) return; final snapshot _undoManager.undo(); if (snapshot ! null) { _currentSnapshot snapshot; _refresh(); } } void _refresh() { _conflicts computeConflicts(_currentSnapshot!); notifyListeners(); } }这里有几个细节值得单独说。第一fillCell 里先判断格子是不是给定格题目给定的格子不能被玩家修改。第二如果格子数字没变不 push 历史避免记录无意义操作。第三每次状态变化后调用 _refresh重新计算冲突并通知 UI这样撤销时冲突标记也会自动重算。5. 撤销按钮的 UI 接入与交互细节5.1 AppBar 按钮与禁用态UI 侧接入撤销非常直白顶部工具栏放一个 IconButton监听 controller.canUndo 的值来决定它是否置灰。用 AnimatedBuilder 包住按钮这样每次 undo 或 push 后canUndo 变化会立刻反映到界面上。实际操作中我还做了一个小优化当可以撤销时按钮用实色图标不可撤销时用半透明图标视觉上给玩家一个明确反馈。这个细节成本极低但能让玩家第一时间确认“刚才那一步有没有被记录”。5.2 完整交互链路一次完整操作的事件流是玩家点击棋盘某个格子UI 记录当前选中格玩家在底部数字面板点一个数字数字面板把请求发给 SudokuController.fillCell控制器生成新快照、push 进 UndoManager、重算冲突、notifyListeners整个棋盘 Widget 重建数字更新冲突标红刷新。撤销按钮的事件流是玩家点撤销控制器从 UndoManager 取出上一份快照把当前快照替换成历史快照重算冲突并通知 UI棋盘 Widget 重建数字和冲突标记一并恢复。两条链路最终都汇聚到“刷新”这一步所以 UI 层完全不需要区分“这是正常填写”还是“这是撤销回退”它每次都是全量重算天然不会出现残留状态。5.3 连点防抖与撤销时的选中状态真机调试时我发现一个体验问题快速连点撤销按钮会在极短时间内连续 pop 多份历史玩家本来只想回退一步结果连退了四五步。解决办法是加一个最简单的防抖标志用 runAsync 或 async 方法保证上一次撤销的 UI 刷新完成之前不接受下一次撤销请求。bool _isUndoing false; Futurevoid undo() async { if (_isUndoing) return; _isUndoing true; final snapshot _undoManager.undo(); if (snapshot ! null) { _currentSnapshot snapshot; _refresh(); await WidgetsBinding.instance.endOfFrame; } _isUndoing false; }另外还有个容易忽略的小点撤销之后棋盘上应该选中哪个格子我的选择是保留当前选中格不变。因为快照里没有保存“选中格”所以撤销不会打断玩家的操作节奏玩家可以立刻继续在同一个格子里试下一个数字这是体验上比较顺的做法。6. 真机调试中的常见问题实录6.1 首次构建非常慢且构建产物路径有坑在 OpenHarmony 上第一次构建 Flutter 工程速度会比普通 Flutter 项目慢不少原因是除了 Dart 层编译还要经历 OpenHarmony 原生工程的编译和资源打包。这个阶段最容易出现的问题是找不到 SDK 路径、签名配置缺失、目标设备不识别。我的排查顺序是先flutter doctor看环境项再用flutter devices看设备连接最后检查工程的签名配置。把这三个环节单独打通再执行完整构建基本能避免“编译一小时最后报错发现是设备没连上”的尴尬。另外建议构建设备只留一台不要同时接模拟器和多台真机否则 OpenHarmony 调试桥的默认选择和 Flutter devices 的展示可能不一致。6.2 撤销后红色冲突标记残留这个问题我在前面提过一次但值得再展开说。第一版快照只保存了格子数字冲突是进入历史前就算好的一份列表撤销时直接恢复那份旧列表。看似没问题但下一步操作一旦改变了这个格子冲突列表就和新棋盘对不上了。正确的做法就是把冲突当派生状态每次快照切换之后强制重算。我后来把 computeConflicts 的调用放进了 _refresh并把所有进入 _refresh 的路径统一成一条问题就消失了。这个坑的核心教训是哪种状态属于“源数据”哪种状态属于“可推导数据”这个分类必须在项目开始时想明白。6.3 快照复制导致的偶发掉帧理论上 81 个格子复制不该有性能问题但在 OpenHarmony 模拟器上我确实观察到偶发掉帧。查了性能分析器才发现问题出在候选数用 Set 的那一版实现上。每复制一个格子就要 new 一个 Set一次复制 81 个 Set历史栈里又累积几十份GC 压力被放大。把 Set 改成 int 位掩码后GC 压力立刻降下来。这个优化不是提前做性能设计而是在实际设备上看到问题才动手改的但好在数据模型封装得干净UI 层根本没有感知到内部表示变化。这也再次说明面向对象封装在状态管理里的价值。6.4 后台切换回来之后 UI 和状态不一致OpenHarmony 上调试时发现App 切到后台再切回来如果中间发生过多次操作界面偶尔会显示旧状态。根因是 Flutter 层面的生命周期事件与 OpenHarmony 侧的事件传递时机不完全同步有些绘制发生在状态已经更新之前。我加的兜底处理是在 App 回到前台时调用一次强制刷新用依赖注入的方式让 SudokuController 收到生命周期回调任何 view 重建都不再走“缓存状态”而是从 controller 当前快照重新读取。// 回到前台时 controller.forceRefresh();forceRefresh 内部只做一件事重新计算冲突列表再 notifyListeners。实践证明这个办法简单可靠也不影响正常流程。如果你在做类似跨端项目遇到“界面偶尔停在旧样子”的玄学问题可以先往生命周期事件上想。7. 收获、优化方向与给后来者的建议7.1 撤销功能带来的状态管理启示做完这个项目我对撤销功能最大的感悟是撤销不是一个“按钮功能”而是一套状态模型设计。如果你把棋盘数据、候选数、冲突状态、历史列表、当前游标这些概念分清楚撤销只是“把当前指针往前移一格”。反过来如果状态东一块西一块散落在各个 Widget 里那撤销无论怎么做都是灾难。现在再看网上很多“撤销功能实现教程”大多数只是给玩家按钮上加了个“保存上一份二维数组”的逻辑。棋盘数字能恢复但高亮、候选数、禁用态全部遗漏。这类实现看着能用一遇到稍微复杂一点的状态联动就崩。数独这个项目恰好把这种复杂的联动放大值得每个做状态管理的开发者亲手写一遍。7.2 后续想补的方向第一版跑通之后我已经列好了几个扩展方向。首先是 redoUndoManager 里其实已经预留了 redo 方法只需要在 UI 上加一个按钮其次是自动候选数模式玩家填入数字后自动移除同行、同列、同宫的候选数这个功能的数据结构已经支持只是需要把候选数联动逻辑从空实现变成真正的推导算法再往后是难度分级和题目库目前生成算法是纯随机挖洞后续可以改成按难度模板生成。还有一个我自己很有兴趣的方向是“回放模式”利用现在保存的历史快照把玩家整局操作完整回放出来用来做错题分析。这种能力在快照方案下几乎白送只要遍历历史栈就行放到命令模式方案里反而要额外实现查询接口。可见选择快照方案虽然看起来“笨”却能省下很多隐形成本。7.3 给后来者的具体建议如果让我重新做一遍我会把流程压缩成三步。第一步绝不写 UI先在纯 Dart 环境里把 CellState、BoardSnapshot、UndoManager、SudokuController 写完并用测试脚本模拟一百步随机操作。第二步再接入 Flutter UI先只做棋盘的拉伸和数据显示确认状态能正确驱动 UI。第三步最后加撤销按钮和数字面板专门针对 undo 连续调用和混合操作做排查。另外一个小建议给 UndoManager 写测试时不只是测试功能“能用”而是主动写出这些场景——空栈 undo、连续 undo 到底、push 后旧 redo 分支是否清空、同一格子连续填相同数字是否产生多余历史。这几个场景几乎覆盖了撤销功能 80% 的 bug 来源测试通过之后UI 层的接入工作会变得异常轻松。这次实战的最大体会是在 OpenHarmony 上跑 Flutter工程链路和工具链确实还有一些“毛刺”但纯 Dart 层的代码几乎感觉不到跨平台差异。撤销功能之所以能顺利落地靠的并不是什么高级技巧而是把状态边界划清楚把不可变快照这条底线守住。数独这个项目后续还能继续扩展出不少玩法但基础的状态模型一旦打牢再加什么功能都不会动摇它。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询