做跨端开发这几年,我一直在关注国产系统的开发适配。今年上半年团队接到一个基于 OpenHarmony 设备端的数独游戏 App 需求,技术栈定的 Flutter for OpenHarmony。项目本身不复杂,核心玩法就是一套标准 9x9 数独,但真正动手之后才发现,难度级别控制才是最容易被低估的模块。网上聊数独算法生成的文章不少,但把难度控制和 Flutter for OpenHarmony 适配结合在一起讲的比较少,这篇就围绕这个点,把从题目生成到四档难度切换的完整思路和踩坑过程整理出来,给后面要做类似项目的开发者一个参考。
1. 整体设计与思路拆解
1.1 技术路线选择
为什么要用 Flutter for OpenHarmony 而不是直接写原生?原因很简单:团队原本就维护着一套 Flutter 代码库,数独这种界面密集、交互状态多、绘制逻辑重的 App,用 Flutter 自绘 UI 非常适合。OpenHarmony 生态的 UI 组件体系还在快速演进,如果每个屏幕都用原生组件重写,适配成本会成倍增加。Flutter 的渲染引擎是自绘的,不依赖系统原生控件,这样一来在 OpenHarmony 设备上的表现一致性比传统跨端方案更有保障。
实际开发中,Flutter for OpenHarmony 需要单独下载针对 OpenHarmony 适配过的 Flutter SDK,和 Google 官方那个 Flutter SDK 不能混用。工程建立后,Dart 代码基本可以复用,但涉及设备能力调用、本地存储、权限申请的部分,需要走 OpenHarmony 提供的平台通道。这个坑后面会详细讲,总之别天真地以为一份代码全平台通吃,至少在工程配置这一层,跑 OpenHarmony 的工程和跑 Android 的工程是独立的。
1.2 难度控制为什么是“做加法”的过程
数独难度控制很容易被想简单了。很多人第一反应是:生成一个完整终盘,挖掉几个数字,空格越多就越难。这种思路生成的题目,玩家玩起来会非常困惑——有时候空格少但逻辑链极深,有时候空格多却一路顺风,体感难度完全不可控。
困难在于,数独题目的难度与空格数量不是线性关系,更关键的是“推理路径的长度”和“分支判断的复杂度”。同样挖掉 30 个数字,有的题目几步唯一候选就收工,有的题目需要多步尝试和回溯。所以难度级别控制不能只靠挖洞数量,必须引入求解器对题目做量化评估。
我在这个项目里采用三层方案:
- 第一层是生成合法的完整终盘,保证无冲突;
- 第二层是挖洞时强制执行唯一解校验,剔除多解题;
- 第三层是用求解器回溯次数作为难度分档依据,结合空格数区间做最终定级。
这三层缺一不可。第一层是地基,第二层解决“能不能玩”的问题,第三层解决“好不好玩”的问题。
2. 环境准备与工程初始化
2.1 Flutter for OpenHarmony 环境搭建
先明确一点:跑 Flutter for OpenHarmony 开发,不能直接到 Flutter 官网下载 SDK。需要从 OpenHarmony 官方开源仓库获取对应的 Flutter SDK 分支,或者使用社区维护好的适配版本。我用的方式是:单独建一个目录放适配 SDK,通过环境变量切换,不让它和原有 Flutter SDK 冲突。
关键配置项有这几个:
| 环境项 | 推荐配置 | 说明 |
|---|---|---|
| Flutter SDK | 适配 OpenHarmony 的版本 | 不能与官方版共存,用 PATH 切换 |
| OpenHarmony SDK | API 9 或以上 | 对应系统版本,API 太低很多接口不可用 |
| hdc 工具 | 与 OpenHarmony SDK 配套 | 类似 adb,用于连接设备、安装应用 |
| DevEco Studio | 可不用,命令行足够 | 需要工程级配置时打开看一眼 |
配置好之后,在终端执行flutter doctor,如果 SDK 切换成功会看到 OpenHarmony 相关的检查项。这里我要特别提醒一个容易踩的坑:如果你机器上原来装了官方 Flutter SDK,环境变量里的 PATH 指向顺序写错了,flutter命令跑到官方 SDK 上,项目创建出来根本看不到 OpenHarmony 平台选项,排查半天才发现是 SDK 路径的问题。建议直接在项目目录下放一个切换脚本,把flutter和dart的 PATH 都指到适配版 SDK。
2.2 创建工程与设备运行
工程创建和普通 Flutter 项目类似,核心是用适配版 SDK 的flutter create生成项目骨架,然后确保项目里有 OpenHarmony 的应用壳目录。创建完后,工程里会多出一个ohos目录,这个目录就是 OpenHarmony 应用壳,包含entry模块和app.json5等配置文件。
真机调试前,先用 hdc 连接开发板。常用命令就三个:
hdc list targets hdc install ohos/app/build/outputs/hap/*.hap hdc shell第一次连接开发板如果发现hdc list targets是空的,先检查 USB 调试开关有没有开,然后执行hdc kill再hdc start重启服务,多数问题都能解决。安装应用时注意,OpenHarmony 的安装格式是 HAP 包,通过flutter build hap生成。
2.3 项目基础目录与数据模型
工程结构上,我习惯把数独核心逻辑抽成纯 Dart 模块,不依赖任何 Flutter UI 和 OpenHarmony 平台特性。这一步非常关键,因为核心逻辑独立后,可以单独写单元测试,也可以直接复用给其他平台版本。
数据模型我用了一个SudokuGame类,内部维护三张盘面:
clueBoard:题目盘面,挖完洞之后的初始状态,0 表示空格;answerBoard:完整终盘,用于校验玩家填写是否正确;playerBoard:玩家当前填写状态,随着操作实时更新。
难度枚举设计成四档:
enum GameDifficulty { easy, // 简单 medium, // 中等 hard, // 困难 expert, // 专家 }每档对应一组参数,包括目标空格数、允许提示次数、求解回溯次数上限等。后台数据结构定好后,后面所有算法逻辑都围绕这三张盘面和四档参数展开。
3. 难度级别控制的完整实现
3.1 数独终盘生成:从空盘到合法完整盘
数独终盘生成方法不少,最稳妥的还是回溯填充法。标准 9x9 数独只有一组解,我们需要随机生成一个完整终盘。生成思路:从左上角第一个格子开始,按行优先逐个填入 1-9,每填一个数字之前检查行、列、宫是否有冲突,如果当前候选全都不合法就回溯到上一个格子换数字。
为了增强随机性,遍历候选值时的顺序必须打乱。我用一个 1-9 的整数列表,每次填充前执行洗牌,然后按洗牌后的顺序尝试。生成完整终盘的时间通常不到 10 毫秒,性能完全不是瓶颈。
核心生成代码大致是这样:
bool solveRemaining(Board board, int index) { if (index == 81) return true; int row = index ~/ 9; int col = index % 9; if (board[row][col] != 0) return solveRemaining(board, index + 1); List<int> candidates = List.generate(9, (i) => i + 1)..shuffle(); for (int value in candidates) { if (_isValid(board, row, col, value)) { board[row][col] = value; if (solveRemaining(board, index + 1)) return true; board[row][col] = 0; // 回溯 } } return false; }这里有个优化细节:检查合法性时如果每次都扫描整行整列和所在宫,性能虽然也能接受,但生成一次终端棋盘没问题,后面挖洞校验时会频繁调用,性能就被放大了。建议用位掩码维护每行、每列、每宫已填入的数字集合,检查时直接按位或运算判断,效率提升好几倍。
3.2 唯一解校验:多解问题为什么不能让“随机”背锅
挖洞阶段是整个难度控制里最需要谨慎的地方。完整终盘生成后,按空格数目标从盘面上删除数字。但删一个数字简单,删完还能保证题目唯一解才是核心。
检测唯一解的方法:从挖完洞的盘面开始,用回溯求解器找出所有解。如果解的数量大于 1,就撤销这一步删除,换一个位置继续尝试。
求解器代码和生成器类似,但要注意两点:
第一,求解时不需要随机洗牌,按 1-9 顺序尝试即可,因为我们要统计解的个数,和求解顺序无关。第二,一旦找到两个解就可以立即停止,不必遍历所有解,所以求解器要支持“解数量上限”参数。
int solveCount(Board board, int limit) { int count = 0; _solve(board, 0, limit, (solution) { count++; if (count >= limit) return; }); return count; }挖洞算法我采用了对称挖洞策略。每删除一个位置的数字,同时删除它中心对称位置(8 - row, 8 - col)的数字。这样做的好处有两个:一是盘面视觉上更均衡,不会有某一块区域特别空、其他区域特别密的感觉;二是对称性在一定程度上限制了解空间的结构,对生成稳定性有正向作用。
实际操作时,维护一个待挖位置列表,列表顺序用洗牌打乱,然后逐一尝试。核心逻辑:
void generatePuzzle(Board clueBoard, Board answerBoard, int targetClues) { List<int> positions = List.generate(81, (i) => i)..shuffle(); for (int pos in positions) { if (_clueCount(clueBoard) <= targetClues) break; int row = pos ~/ 9; int col = pos % 9; if (clueBoard[row][col] == 0) continue; int symRow = 8 - row; int symCol = 8 - col; int tempA = clueBoard[row][col]; int tempB = clueBoard[symRow][symCol]; clueBoard[row][col] = 0; clueBoard[symRow][symCol] = 0; if (solveCount(clueBoard, 2) != 1) { clueBoard[row][col] = tempA; clueBoard[symRow][symCol] = tempB; } } }这段代码看起来简单,实际开发中最容易出 bug 的地方是:挖了两个洞之后,第二个洞导致多解,回滚时只回滚了第二个洞,第一个洞的状态没有还原完整。我在测试中遇到过好几回,最后发现是回滚顺序写错了,第二个洞回滚后影响了第一洞的判断结果。回滚时一定要把两个位置都恢复,再继续后续尝试。
3.3 难度级别的量化标准与分档参数
有了唯一解校验之后,题目的合法性解决了,但体感难度还是不稳定。我的做法是引入“递归试探次数”作为难度量化指标。
求解器在尝试求解挖洞后盘面时,每尝试填入一个数字并进入下一层递归,记作一次“搜索节点”。搜索节点数量越高,说明这个题需要更多的假设和排除,玩家手动推理时就越费劲。搜索节点数量本质上是解题路径深度的代理指标,比单纯空格数可靠得多。
在项目里,我按空格数和搜索节点双重约束来划分难度:
| 难度 | 目标剩余空格数 | 搜索节点数区间 | 单题生成时间 |
|---|---|---|---|
| 简单 | 43-47 | 小于 50 | < 20ms |
| 中等 | 36-42 | 50-200 | < 50ms |
| 困难 | 30-35 | 200-800 | < 150ms |
| 专家 | 25-29 | 800-5000 | < 500ms |
这里的时间是调试机上跑的结果,不同设备性能差异会导致数值不同,但相对关系可以参考。生成时如果跑完所有待挖位置还是达不到目标空格数,说明这盘初始终盘的结构不适合该难度,直接重新生成终盘再挖,不要死磕。
另外,我给每道题加了一个随机种子,种子和题目数据一起保存。这样“每日一题”模式可以根据日期生成固定种子,所有玩家拿到同一道题,公平性有保障;也可以用于后续好友对战,双方同一盘面更公平。
3.4 生成流程与缓存策略
一次题目生成的核心耗时在挖洞和唯一解校验,简单档很快,专家档偶尔会到几百毫秒。直接放主线程肯定不行,否则用户在难度切换时明显感到卡顿。我采用的方案是:
- 题目生成放在
compute后台 isolate 中执行; - 生成完成后回到主线程更新 UI;
- 生成过程中显示一个轻量的 loading 指示。
更进一步的优化是题库缓存。App 启动后,在后台线程预生成每档难度各几十道题,序列化成 JSON 字符串存到本地文件中。玩家进入某个难度时,先从缓存里取一道题,缓存不足时再实时生成并补充。
这里我把缓存数据格式固定得很简单:
{ "difficulty": "hard", "seed": 20240615, "clues": [[5,3,0,0,7,0,0,0,0], "..."], "solution": [[5,3,4,6,7,8,9,1,2], "..."] }有了缓存之后,难度切换几乎没有等待感,体验提升非常明显。需要注意是缓存文件不能无限膨胀,我做了上限控制,每个难度只保留最近 50 道题,超过后按时间戳清理最旧的。
4. 难度切换与状态管理落地
4.1 页面状态管理与难度切换
状态管理我选的是 Provider,轻量、社区文档多、团队心智负担小。游戏状态都收敛到一个GameModel类里,里面持有当前难度、三张盘面、计时器时间、撤销栈等核心字段。难度切换本质上就是“替换 GameModel 中的盘面数据,重置所有运行时状态”。
切换流程设计成三步:
- 用户点选目标难度;
- 弹出确认框,提示“切换难度会丢弃当前对局进度”;
- 确认后从缓存取题或后台生成,重置计时和撤销栈,刷新界面。
这里需要特别注意:如果玩家在当前棋局已经填了不少数字,直接切换难度会丢失进度,非常伤体验。我后来加了一个“自动存档”功能,在每次切换难度前把当前对局快照写入本地,相当于隐式的进度保护。玩家切去别的难度玩了一会儿,再切回来发现刚才填的数都在,这个小功能让不少用户好感大增。
4.2 断点续玩与多档位存档
存档设计上,我做了分难度独立存档。也就是说,简单、中等、困难、专家四个难度各有一个存档槽位,玩家在困难档玩到一半切去简单档,回来时困难档还在原地。实现上就是用一个 JSON 文件保存所有档位的状态:
{ "easy": { "clues": [], "player": [], "time": 120, "hints": 3 }, "hard": { "clues": [], "player": [], "time": 340, "hints": 1 } }序列化时注意二维数组转 JSON 的格式,建议统一成字符串而不是嵌套 List 的扁平结构,否则解析时容易因为类型问题报错。我踩过一次:List<List<int>>存到 JSON 后,再读出来变成了List<dynamic>,后续逻辑里访问时类型断言报错,调试了很久。
4.3 交互层的关键细节
难度选择 UI 我用了 Slider 加图标的形式,四档各配一个颜色和描述文案,简单是绿色、中等是蓝色、困难是橙色、专家是红色。视觉上把难度档位变成可感知的颜色梯度,玩家不需要看文字也能知道当前处于什么难度。
展示方面,在游戏页顶部固定显示一个当前难度标签,和选择页颜色保持联动。这里有个小细节:当玩家在困难档完成一局时,结算页除了显示用时外,我会额外显示“本题推理步数”,这个数据来自生成时记录的搜索节点数,让玩家对难度的感知更具体。不少用户反馈说这个设计让他们理解了“困难到底难在哪里”。
还有一个容易被忽略的点:数独盘的格子点击区域在手机和平板上的差异。OpenHarmony 设备屏幕尺寸跨度大,平板上如果格子区域过大,单手操作点边缘格子很吃力;手机上如果格子过小又容易误触。我最终采用了自适应间距,根据屏幕宽度动态计算每格最小 36dp、最大 48dp,再居中适配整个棋盘。
5. 常见问题与排查技巧实录
5.1 生成慢、卡顿与闪退
最典型的性能问题是唯一解校验造成的主线程卡顿。早期版本我把生成放到了主线程,专家档一次生成耗时接近 300ms,界面直接掉帧,用户体验很差。解决办法就是前面提到的compute后台 isolate 生成,生成期间用轻量 loading 遮罩挡住棋盘。
另一个隐藏问题是缓存文件太大导致启动变慢。某次测试发现 App 启动耗时从 800ms 涨到 2 秒,查了半天发现是缓存文件里存了大量失败生成过程的临时数据,体积超过了 10MB。后来给缓存模块加了一层封口逻辑,只保留成功生成的题目数据,体积立刻降下来。
5.2 题库多解检测不过
这个问题的表象是:生成的题目看起来空格数合适,但玩家玩到一半发现两个位置都能填同一个数字,题面没有唯一解。
排查后发现是求解器在统计解的数量时,没有在递归返回时正确恢复棋盘状态。求解器为了统计所有解,会在找到一个解后继续递归,此时如果状态恢复逻辑不完整,残留的数字会污染后续搜索路径,导致误判“只有一个解”。解决方法是把棋盘恢复放到递归函数的finally位置,保证每次回溯都恢复现场。
还有一个容易漏掉的多解场景:当两个对称洞同时存在时,求解器可能在某个分支里依赖了其中一个洞的“已填”状态,导致错误地认为另一个洞只有一个位置可填。处理方法是每次挖洞后必须完整跑一遍唯一性检测,并且检测的盘面必须包含此前所有已挖的洞,不能只检测当前这一对洞。
5.3 鸿蒙设备上的适配问题
最常遇到的是本地存储路径问题。OpenHarmony 的文件系统路径和 Android 不一样,不能直接用 Android 的路径获取方式。我在第一次真机测试时,存档功能直接崩溃,报错找不到文件目录。后来通过平台通道调用了 OpenHarmony 应用沙箱路径接口,才把读写位置固定到应用私有目录下,问题解决。
另一个问题是中文字体渲染。Flutter 自绘字体在低版本 OpenHarmony 上,数字和中文混合显示时会有轻微基线偏移,界面看起来有点廉价感。解决方式是给数字区域单独指定等宽字体,中文区域用系统默认字体,不要让两者混在同一段文本里。
还有一次遇到过:真机调试时flutter run -d hdc报错找不到设备,但hdc list targets明明能看到设备。原因是 Flutter 适配版 SDK 的 device id 和 hdc 的设备号解析规则不完全一致,需要手动查一下设备序列号,再以设备号方式连接。
5.4 测试用例与回归建议
数独生成算法这部分,强烈建议写单元测试,不要只靠手点界面。我维护了一套固定用例:
- 终盘生成测试:生成 1000 盘,断言全部都满足行列宫无重复,且每个数字出现次数为 9;
- 唯一解测试:对每组生成的题目断言解的数量严格为 1,解法用高性能求解器交叉验证;
- 难度稳定性测试:连续生成 50 盘同一难度,断言搜索节点数落在规定区间内,超过区间的比例低于 10%。
这套用例跑一遍基本能保证核心算法不会出大问题。UI 层面则回归难度切换流程、存档恢复流程、提示数扣减逻辑三个主要路径,这三个地方是后续版本迭代最容易引入回归的功能区域。
最后说一点实际操作中的体会。难度控制这个模块,表面上看是一个“生成函数”的问题,实际是算法、产品体验、平台适配三者的结合点。算法层面要保证唯一解和难度稳定,体验层面要让难度可感知、切换顺滑,适配层面要处理好存储、字体、性能这些零碎问题。项目做完之后回头看,最值得的投资是那个后台预生成题库的缓存方案,它把难度切换这种高频操作的等待时间降到了几乎不可感知,比我在界面细节上做的任何优化都更有效。
如果后面继续迭代,我建议可以扩展的方向是“难度自适应”。现在四档难度是静态的,玩家选完就固定了。更进阶的做法是根据玩家的实际解题速度、错误率和提示使用次数,动态估算他的实际水平,自动推荐下一局难度。这个方向需要收集更多局的数据,但一旦做出来,用户的留存感会有明显提升,至少目前在模拟数据上看,效果是值得期待的。