最近在做一个Flutter for OpenHarmony的数独游戏App,整个编译链和渲染层都踩完之后,发现最磨人的不是棋盘绘制,也不是关卡生成,而是那个看起来不显眼的“提示”功能。数独游戏没有提示也能玩,但一旦加上“提示”,玩家体验、求解算法、双端适配、性能表现全都会被牵动。这篇文章就把我在OpenHarmony上用Flutter实现数独提示功能的全过程拆开讲讲,包括求解器设计、提示分级、双端适配的坑,以及可以直接抄作业的Dart代码。
如果你正打算在OpenHarmony设备上做一个Flutter应用,或者想把一个普通的数独/逻辑类小游戏做得更完整,这篇内容应该能帮你少走不少弯路。文中涉及的方案不一定是最优解,但都是我自己实测跑通的路径,适合中小型项目参考。
1. 项目整体设计:一个“小功能”背后的三层结构
1.1 为什么选 Flutter for OpenHarmony,而不是原生 ArkTS
做OpenHarmony应用,第一反应通常是原生ArkTS。我这次选择Flutter,主要看重的是三维度:跨端代码复用、自绘渲染一致性和Dart的纯计算能力。
跨端代码复用不用多说,同一套棋盘逻辑、求解算法、提示生成代码,在Android/iOS/OpenHarmony三端可以共用。渲染方面,Flutter是自绘引擎,棋盘格子、高亮动画、数字字体的绘制效果在OpenHarmony和Android上能做到几乎一致,不需要为不同平台写两套UI。第三点容易被忽略:数独求解和提示生成是CPU密集型的纯计算逻辑,Dart的单线程事件循环配合高效的集合操作,写起来比ArkTS的声明式状态管理要顺手得多。
代价也很直接:OpenHarmony上的Flutter生态没有Android成熟,部分原生插件不可用,需要自己做平台通道适配。好在我这个项目用到的大多是纯Dart能力,只有文件存储、屏幕尺寸获取这些基础功能需要桥接,整体可控。
1.2 提示功能的产品定位:从“一键填答案”到“解题教练”
很多数独App的“提示”就是一个按钮:点一下,错误的数字消失,正确的数字填进去。这种实现最简单,但对玩家毫无帮助,玩完还是不会做。
我这次把提示做成了三个等级,既满足“卡住了直接告诉我答案”的玩家,也满足“想学解题思路”的玩家:
| 提示等级 | 行为 | 适用玩家 |
|---|---|---|
| LV1 候选数显示 | 高亮所有空格可填的候选数,并排除行/列/宫冲突 | 新手:先自己判断 |
| LV2 单格提示 | 标出某一格的唯一候选数,但不由系统填入,由玩家自己操作 | 爱动脑的玩家 |
| LV3 直接填入 | 给一个正确数字并附简短解题技巧说明,如“唯一余数法” | 卡关急用 |
三个等级不是互相替代关系,而是递进关系。LV1帮助玩家理解候选数机制,LV2引导玩家做排除,LV3提供最终答案。这个设计也影响了后面的算法实现——提示系统不只是“求解”,还要能解释“为什么是这个数字”。
2. 数独求解器:提示功能的地基
2.1 回溯法求一个解,为什么要用位运算
提示功能的核心是一个合法的数独求解器。数独本质是约束满足问题,最直观的解法就是回溯:从左到右、从上到下找空格,尝试填入1到9,检查行、列、宫是否冲突,不冲突就继续下一格,冲突就回退。
代码不难,但如果直接对每个空格循环1到9并逐格检查冲突,遇到复杂题面会有明显卡顿。我用的是位运算优化,每个空格用一个int类型的mask表示可填候选数,bit 0到bit 8分别对应数字1到9,这样判断冲突从“循环9次”变成“位与一次”。
class SudokuSolver { List<int> board; // 81个元素,0表示空格,1-9为已填数字 SudokuSolver(List<int> source) : board = List.of(source); bool solve() { final pos = _findEmpty(); if (pos < 0) return true; final candidates = _candidatesAt(pos); var bits = candidates; while (bits != 0) { final bit = bits & -bits; // 取最低位的1 bits -= bit; board[pos] = _bitToNumber(bit); if (_isValidAt(pos) && solve()) return true; board[pos] = 0; } return false; } int _findEmpty() { for (var i = 0; i < 81; i++) { if (board[i] == 0) return i; } return -1; } int _candidatesAt(int pos) { final row = pos ~/ 9; final col = pos % 9; var used = 0; for (var i = 0; i < 9; i++) { final rowVal = board[row * 9 + i]; final colVal = board[i * 9 + col]; if (rowVal != 0) used |= 1 << (rowVal - 1); if (colVal != 0) used |= 1 << (colVal - 1); } final boxRow = (row ~/ 3) * 3; final boxCol = (col ~/ 3) * 3; for (var r = 0; r < 3; r++) { for (var c = 0; c < 3; c++) { final v = board[(boxRow + r) * 9 + (boxCol + c)]; if (v != 0) used |= 1 << (v - 1); } } return (~used) & 0x1FF; // 低9位为1表示可填 } bool _isValidAt(int pos) { // 这里只需校验当前位置所在行/列/宫是否有重复值 final val = board[pos]; final row = pos ~/ 9; final col = pos % 9; for (var i = 0; i < 9; i++) { final rp = row * 9 + i; final cp = i * 9 + col; if (rp != pos && board[rp] == val) return false; if (cp != pos && board[cp] == val) return false; } final boxRow = (row ~/ 3) * 3; final boxCol = (col ~/ 3) * 3; for (var r = 0; r < 3; r++) { for (var c = 0; c < 3; c++) { final p = (boxRow + r) * 9 + (boxCol + c); if (p != pos && board[p] == val) return false; } } return true; } int _bitToNumber(int bit) { var n = 1; while ((bit & 1) == 0) { bit >>= 1; n++; } return n; } }整个过程在Dart里跑一个复杂题面,实测耗时通常在10毫秒以内,用户完全感知不到。这也是后续所有提示功能能顺畅工作的前提。
2.2 从“求一个解”到“验证唯一解”
做提示功能时有个隐藏问题:如果棋局本身存在多个解,提示给出的数字可能会和玩家已知的推理矛盾。比如玩家已经通过排除法确定某格是3,但提示系统求出的是7,因为题目本身多解。
正规数独题目要求唯一解。所以在生成题面时,我会在挖空后执行一次唯一解验证:继续回溯,找到第二个解立即停止。
int _countSolutions(List<int> board, int limit) { final solver = SudokuSolver(board); var count = 0; void dfs() { if (count >= limit) return; final pos = solver.findFirstEmpty(); // 扩展一个公开方法 if (pos < 0) { count++; return; } final candidates = solver.candidatesAt(pos); var bits = candidates; while (bits != 0 && count < limit) { final bit = bits & -bits; bits -= bit; solver.board[pos] = bitToNumber(bit); if (solver.isValidAt(pos)) dfs(); solver.board[pos] = 0; } } dfs(); return count; }如果返回的解数量大于1,就说明这道题不合格,需要重新挖空或直接舍弃。在提示功能里,如果解析用户当前盘面时发现多解,我会在提示面板顶部加一行小字说明“当前盘面存在多个解,提示基于第一组合法解”,避免玩家拿提示结果和排除法冲突时产生困惑。
注意:唯一解验证的成本比单纯求一个解高不少。生成题面时建议异步执行,别放在UI线程,否则挖空较多时会有可感知的卡顿。
2.3 提示分级算法实现:从“给答案”到“讲思路”
LV1 候选数显示的实现最直接:对每个空格调用_candidatesAt,得到一个mask,UI层按mask绘制小数字。需要注意的是,候选数显示时要排除玩家已经填过的冲突数字,如果玩家在某行填了两个4,LV1应该优先标红这两个冲突格,而不是给出候选数。
LV2 单格提示的实现逻辑是:用求解器求当前盘面的一个解,然后遍历所有空格,找出那些“在解中只有一个可填数字”的空格,随机挑一个,只高亮这个格子,并显示它的候选数。这里有个细节:玩家看到的候选数列表是1到9,但真正“唯一”的数字通常是“当前盘面条件下,经过行、列、宫排除之后只剩一个候选数”的数字。我一开始直接拿解里的数字去对比,发现很多格子有多个候选数,提示不具备引导性。后来改成先计算候选数mask,再根据mask的bits个数排序,优先展示“候选数最少”的格子。
LV3 直接填入则需要额外的解题技巧标注。我的做法是维护一个技巧列表,按顺序检查:
- 唯一余数法:某格候选数只剩一个
- 唯一候选数法:某数字在某行/列/宫中只出现在一个格子里
- 摒除法:两个候选数在两个格子中互斥,从而排除其他行/列中的候选数
class HintResult { final int index; // 空格在81格数组中的位置 final int value; // 推荐填入的数字 final int technique; // 技巧枚举值 final String description; // 展示给玩家的文字 } HintResult generateHint(List<int> board, HintLevel level) { final solver = SudokuSolver(board); final solved = List<int>.of(board); if (!solver.solveTo(solved)) { return HintResult.error('当前盘面无解'); } // 先找候选数最少的空格 int bestPos = -1; int bestMask = 0x1FF; for (var i = 0; i < 81; i++) { if (board[i] != 0) continue; final mask = solver.candidatesAt(i) & ~(1 << (solved[i] - 1)); final cnt = _countBits(mask); if (cnt < _countBits(bestMask)) { bestMask = mask; bestPos = i; if (cnt == 1) break; } } if (bestPos < 0) return HintResult.error('已无空格可提示'); // 根据候选数个数判断技巧 if (_countBits(bestMask) == 1) { return HintResult( index: bestPos, value: solved[bestPos], technique: '唯一余数法', description: '第${bestPos ~/ 9 + 1}行第${bestPos % 9 + 1}列只剩唯一候选数', ); } return HintResult( index: bestPos, value: solved[bestPos], technique: '排除法', description: '经过行列宫排除后,此格最优候选数为${solved[bestPos]}', ); }这样LV3提示不仅能给出答案,还能告诉玩家“为什么填这个数字”,真正做到“解题教练”而不是“答案机器”。
3. Flutter for OpenHarmony 关键实战:从配置到绘制
3.1 环境配置与适配层选型
Flutter在OpenHarmony上跑,不能直接用官方Flutter SDK,需要用社区维护的OpenHarmony适配分支,配合OpenHarmony的SDK和IDE工具链。项目初始化流程大致如下:
- 下载适配版Flutter SDK,解压后配置
flutter_ohos环境变量。 - 安装OpenHarmony SDK,并用官方IDE创建一个空的OpenHarmony工程。
- 在空工程中启用Flutter模块,配置
hap打包参数。 - 把Flutter侧代码通过
flutter_attach挂载到OpenHarmony原生壳工程中。
这个过程中容易踩的坑有三个:
- 版本强绑定:适配版Flutter和OpenHarmony SDK版本之间往往有严格的对应关系,不能随便拿一套最新版硬配,否则编译报错会非常难查。
- 路由和页面生命周期:OpenHarmony的原生页面栈和Flutter的Navigator是两套体系,如果App里涉及原生页面跳转,需要自己处理双向通信。我直接全部用Flutter单页面,避免复杂桥接。
- 插件缺失:
shared_preferences、path_provider这类常用插件在OpenHarmony上不一定可用。我最后用平台通道自己写了一个简单的键值存储桥接,代码量不大,但省去大量兼容性折腾。
提示:如果你是第一次配环境,别急着写业务代码,先把官方的hello world跑进OpenHarmony模拟器或真机,确认Flutter页面能正常渲染,再开始搭数独棋盘。否则后面排查问题时很容易分不清是业务问题还是环境问题。
3.2 棋盘数据模型与状态管理
数独棋盘本质上就是一个81位的数组,但加上谜题标记、错误标记、笔记候选数、选中态,状态就会复杂起来。我最终的数据模型是这样的:
class SudokuBoard { List<int> solution; // 最终解 List<int> current; // 玩家当前填值 List<int> given; // 题面初始值(0表示空格) Set<int> noteCells; // 笔记标记,用index*10+数字编码 }状态管理没有用重量级框架,而是用ChangeNotifier搭配AnimatedBuilder。棋盘的每个格子是CustomPainter绘制,不使用Widget树中的大量容器,这样在高刷新率下也能保持流畅。
一个很关键的优化是:绘制棋盘时不要每次setState重建所有格子。提示功能触发时,需要同时更新高亮格、候选数显示、错误标记三类状态,如果全部走Widget重建,OpenHarmony低端机上会掉帧。我的做法是把棋盘分成两层:
- 底层
BoardPainter:只绘制格线、给定数字、当前数字。 - 上层
HighlightPainter:单独绘制高亮格、候选数、错误红边。
提示功能只改变上层Painter的数据,底层画布不动,渲染开销小很多。
class SudokuBoardView extends StatelessWidget { final SudokuGameState gameState; final int selectedIndex; final Set<int> highlightedIndices; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: gameState, builder: (context, _) { return CustomPaint( size: Size.square(360), painter: BoardPainter(gameState: gameState), foregroundPainter: HighlightPainter( current: gameState.current, candidates: gameState.candidates, highlightedIndices: highlightedIndices, selectedIndex: selectedIndex, ), ); }, ); } }3.3 提示功能的交互与UI渲染细节
UI交互上,提示按钮放在底部工具栏,点击后弹出三个等级选项。每次提示消耗一个“提示次数”,次数用完后需要看广告或复活(我暂时砍掉了广告位,只做了本地次数限制)。
LV1 候选数显示时,我在每个格子内绘制小号数字。这里有个排版细节:候选数数字需要按九宫格位置排列,1到9分别放在格子的左上、上中、右上、左中、中心、右中、左下、下中、右下。直接遍历candidatesMask的每个bit并计算位置即可。
LV2 的交互设计对OpenHarmony触控有个适配点:OpenHarmony部分设备的触控采样率偏低,快速连续点击提示按钮时,双击事件的过滤逻辑可能把第二次点击当作无效事件。我的解决方法是给提示按钮增加一个300毫秒的防抖,并在提示后延迟一小段时间再允许下一次提示,从根上规避双击误触。
LV3 的提示展示我做成底部弹出卡片,包含数字、技巧名和解释文字。卡片弹出时添加一个轻微缩放动画,用AnimationController加Curves.easeOutBack,视觉效果不会突兀。OpenHarmony的Flutter引擎对easeOutBack这类曲线支持没问题,但要注意过度使用动画会导致GPU占用上升,棋盘绘制和动画尽量分开刷新。
4. 常见问题与排查技巧实录
4.1 OpenHarmony 与 Android 的行为差异
同样的Flutter代码跑在OpenHarmony和Android上,行为上确实有不少差异,我遇到的典型问题整理如下:
| 现象 | Android | OpenHarmony | 处理方式 |
|---|---|---|---|
| 字体渲染 | 默认Roboto | 字体宽度偏小 | 棋盘数字统一指定fontFamily,并微调fontSize |
| 滚动惯性 | Android惯性自然 | 惯性偏弱 | 自定义ScrollPhysics,加大摩擦系数 |
| 文件路径 | 常规缓存路径 | 沙箱路径不同 | 用平台通道获取实际路径 |
| 系统字体缩放 | 跟随系统 | 部分设备系统缩放不生效 | 设定textScaleFactor: 1.0固定棋盘比例 |
| 真机调试 | 热重载稳定 | 部分版本热重载会丢状态 | 修改状态时手动保存,避免依赖热重载保留现场 |
这里最坑的是字体渲染。一开始棋盘数字在OpenHarmony上明显发虚,排查后发现是默认字体渲染引擎对不同字体的灰度算法不同。我给数字绘制指定了一个专用于数字的字体文件,并开启isAntiAlias,发虚问题才解决。
4.2 提示逻辑的边界 case
提示功能的坑不在正常流程里,而在各种异常盘面上。我整理了几个印象深刻的边界case,每个都值得在代码里显式处理:
玩家填入了矛盾数字:比如同一行填了两个7,此时求解器求得的解可能和玩家当前盘面冲突。我的处理是优先检查行、列、宫的重复值,如果发现冲突,LV1直接标红冲突格,不给候选数;LV2和LV3拒绝执行,并提示玩家先处理冲突。
无候选数空格:某些极端盘面下,某空格的行、列、宫已经被1到9全部占满,此时
_candidatesAt返回0。提示逻辑必须兜底:遍历其他空格,找到一个候选数非0的格子来提示,否则用户点击提示没有任何反馈。盘面已完成但错误:玩家填满了81格,但有错误数字。此时求解器会发现当前盘面无解。提示功能要明确区分“未完成”和“已完成但有错”两种状态,前者正常提示,后者显示“盘面包含错误,请检查”,而不是继续给候选数。
多解盘面:如前面说的,当前盘面可能多解。提示前会跑一次唯一解检测,多解时提示文案附带说明,避免误导。
4.3 性能优化记录:求解耗时与首帧渲染
数独棋盘只有81格,按理说性能压力不大,但OpenHarmony上坑点在于Flutter引擎的线程调度和渲染管线,某些低端机上会出现在Android上遇不到的掉帧。我做了三轮优化:
第一轮优化求解器。初始实现直接对每个空格循环检查行、列、宫,在复杂题面上单次求解耗时接近80毫秒。改成位运算后,降到3到5毫秒。这里的关键不是算法原理多高深,而是避免不必要的循环。
第二轮优化提示生成。LV3提示生成时需要对当前盘面求一个解,如果盘面接近完成,求解很快;如果盘面空格很多,求解耗时可能到几十毫秒。我把提示生成放到compute函数里,用后台Isolate执行,避免阻塞UI。
第三轮优化绘制。候选数显示时,81个格子每个都可能绘制9个小数字,如果用Widget实现会创建700多个Widget,非常浪费。改为CustomPainter直接画文字后,UI线程的帧耗时从平均20毫秒降到了5毫秒以内。
Future<HintResult> generateHintAsync(SudokuBoard board, HintLevel level) { return compute(generateHint, _HintPayload(board, level)); }用compute有个注意点:传给它的对象必须能跨Isolate传递。Dart的compute默认只支持简单对象,像数独盘面这种List<int>没问题,但如果有自定义类,需要把数据拆成可传递的Map或List,或者用SendPort手动传。
实测数据记录:
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 复杂题面求解 | 约80ms | 约4ms |
| LV3提示生成(32空格) | 约45ms | 约8ms |
| 棋盘全量重建 | 约20ms/帧 | 约5ms/帧 |
| 候选数全显 | 帧耗时明显波动 | 稳定60fps |
5. 值得注意的设计取舍与后续扩展
5.1 提示次数限制与玩家体验的平衡
提示次数限制我卡在每局5次,用完后就只能靠“检查错误”功能(标出错误数字但不给正确值)。这里有个产品上的思考:提示次数太少会让新手玩家卡关挫败;次数太多会让解谜失去意义。5次是针对普通难度的折中值,如果你做不同难度关卡,可以根据难度动态调整。
技术实现上,提示次数被持久化到本地存储,中途退出App再进来次数不丢失。这个持久化在OpenHarmony上我走了平台通道,因为没有现成插件可用。
5.2 候选数笔记功能可以复用同一套算法
做提示功能时生成的候选数mask,完全可以复用到“笔记模式”。玩家长按格子时,显示当前格子的候选数,玩家可以自行勾选记录。这个功能我早期版本做了,发现和LV1候选数显示共用同一套计算逻辑,代码几乎不用改,只是把mask展示从“自动”改为“手动”。
另外,求解器的_candidatesAt还可以用来做“谜题难度评估”。挖空后统计所有空格的平均候选数数量,候选数总和越少,题目越难。这比单纯按挖空数分难度更科学,后续做关卡系统时可以直接用。
5.3 自定义绘制与自绘渲染的取舍
OpenHarmony的Flutter渲染和Android一致,都是Skia引擎自绘。这意味着棋盘用CustomPainter是最稳妥的方案,不会因为OpenHarmony的组件风格差异出现问题。但要注意CustomPainter的shouldRepaint一定要写对,否则提示高亮变化时底层棋盘也会跟着重绘,白白浪费性能。
class BoardPainter extends CustomPainter { final SudokuGameState gameState; BoardPainter({required this.gameState}); @override void paint(Canvas canvas, Size size) { // 绘制格线和固定数字 } @override bool shouldRepaint(covariant BoardPainter oldDelegate) { return oldDelegate.gameState.boardVersion != gameState.boardVersion; } }我在SudokuGameState里维护了一个自增的boardVersion,每次玩家填入或删除数字时递增,其他状态变化不触发底层重绘。这样提示功能刷新高亮和候选数时,底层棋盘不会重复绘制,实测帧稳定性提升明显。
做完整套提示功能以后,我对“小功能不小”这句话有了更深的体会。提示功能表面上只是“给个答案”,实际上它横跨了求解算法、交互设计、性能优化、双端适配四个环节,每一步都有值得打磨的细节。如果你也在做类似的数独类App,我的建议是先把求解器单独抽成一个纯Dart类,和UI完全解耦,这样后面所有玩法(笔记、自动填候选数、解题教学)都会变得轻松很多。
最后再分享一个小技巧:调试提示功能时,可以用一个已知唯一解的题面在真机上反复验证,不要只在模拟器上测。OpenHarmony的模拟器在某些设备上渲染精度和真机有差异,尤其是候选数小字体和HighLine高亮色,真机上的视觉表现才最接近实际用户体验。别嫌麻烦,这一步值得做。