☰
OpenHarmony上Flutter数独提示功能:求解器设计与性能优化
2026/10/17 16:20:53 网站建设 项目流程

最近在做一个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工具链。项目初始化流程大致如下:

  1. 下载适配版Flutter SDK,解压后配置flutter_ohos环境变量。
  2. 安装OpenHarmony SDK,并用官方IDE创建一个空的OpenHarmony工程。
  3. 在空工程中启用Flutter模块,配置hap打包参数。
  4. 把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上,行为上确实有不少差异,我遇到的典型问题整理如下:

现象AndroidOpenHarmony处理方式
字体渲染默认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高亮色,真机上的视觉表现才最接近实际用户体验。别嫌麻烦,这一步值得做。

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

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

立即咨询