最近在RK3568开发板上折腾OpenHarmony,系统跑起来之后总觉得缺点什么——开机就是桌面,没有那种“能用”的感觉。干脆自己写一个数独生成器,顺便验证一下Flutter在OpenHarmony上的适配程度。这个项目表面上是“写个小游戏”,本质上其实是一趟跨端框架落地鸿蒙的实战。
数独的好处在于,它的算法边界非常清晰:生成终盘、挖洞出题、判定唯一解、用户输入校验,每一块都能独立拆开测试。UI呢,又比一般的“Hello World”复杂一点,需要处理网格绘制、高亮联动、数字键盘、笔记模式,正好能暴露Flutter在OpenHarmony上的真实渲染情况。这篇文章把整个项目的思路、算法、界面实现、部署调试的完整过程写下来,适合正在做Flutter跨端开发、或者想在OpenHarmony上跑点正经应用的朋友参考。
1. 为什么用Flutter给OpenHarmony写一个数独?我的选型逻辑
1.1 从一次“无应用可用”的尴尬说起
OpenHarmony的设备,尤其是RK3568、RK3588这类开发板,装好系统之后最大的问题不是跑不起来,而是没有让人愿意点开的应用。系统自带的设置、相册、桌面,看一眼就关掉了。当时我拿到的板子预装的是OpenHarmony 4.0,应用商店里的第三方应用少得可怜,ArkTS开发的demo也大多是列表页和按钮点击,缺乏有交互深度的样例。
那我为什么不直接用ArkTS写?因为团队的存量代码和技术栈都是Flutter。跨端框架的价值,恰恰体现在“一套代码,多端复用”上。OpenHarmony目前的官方应用开发语言是ArkTS,但它是支持Flutter的,社区也有对应的SDK适配。我想验证的正是:一个纯Flutter项目,从Android迁移到OpenHarmony,需要改多少代码、踩多少坑、性能是否可用。
数独这种游戏,对性能的要求很微妙:它不像游戏引擎那样需要60帧满帧渲染,但也不像静态页面那样完全无压力。网格重绘、点击响应、动画过渡,每一项都对UI框架有实际要求。拿它当探路石,比跑一个空白模板有说服力得多。
1.2 项目结构:把算法和皮肤拆干净
很多小游戏写着写着就乱了,尤其是界面和逻辑混在一起,后面想加功能、想测试,都变得无比痛苦。这个项目从一开始就定了三条规矩:
- 核心算法必须是纯Dart,不依赖任何Flutter组件
- UI层只负责绘制和事件转发,不持有游戏状态
- 平台相关代码尽量隔离,保留Android/iOS/OpenHarmony三个平台的壳目录,方便交叉验证
最终的项目结构大概是这样的:
sudoku_flutter/ ├── lib/ │ ├── core/ # 纯Dart算法层 │ │ ├── generator.dart # 终盘生成 │ │ ├── solver.dart # 求解器(唯一解判定) │ │ ├── puzzle.dart # 挖洞出题 │ │ └── models.dart # 格子、坐标、难度枚举 │ ├── ui/ │ │ ├── board_view.dart # 棋盘绘制 │ │ ├── number_pad.dart # 底部数字键盘 │ │ ├── note_mode.dart # 笔记模式 │ │ └── game_page.dart # 游戏主页面 │ └── main.dart ├── android/ # Android壳工程 ├── ohos/ # OpenHarmony壳工程 └── test/ # 算法单测核心算法层单独拆出来,带来的直接好处是:flutter test可以秒级验证生成器和求解器的正确性,不用跑到设备上才发现算法有bug。后面在OpenHarmony上调试时,UI出问题我能确定是渲染层的锅,不会怀疑到算法头上。
1.3 关于“鸿蒙风”的设计风格
标题里写了“鸿蒙风”,不少朋友可能会以为是鸿蒙的原生设计语言。其实我理解的是“轻、透、简洁”。HarmonyOS的设计风格偏向圆角、柔和阴影、白色卡片底,跟Material Design的厚重感不一样。所以在Flutter里做主题时,我没有直接用Material的默认样式,而是自定义了一套:
- 背景色用浅灰白(
#F2F3F7),不用纯白,避免刺眼 - 卡片和按键用大圆角(16~20),不用Material默认的4圆角
- 数字和线条用深蓝色系(
#1A3A5C),比纯黑更柔和 - 切换动画用200ms左右的淡入淡出,不搞花哨的弹性动画
这种风格在OpenHarmony设备上跑起来很协调,因为OpenHarmony原生应用本身也是这种“轻量卡片”的调性,Flutter这边自定义Theme后不会有违和感。
2. 数独生成器最难的部分不是界面,是“盘面算法”
很多第一次写数独的人,都会低估出题这个环节。以为随机填几个数字进去就行,结果生成出来的盘面要么无解,要么多解,要么难度分布完全失控。这一章详细讲一下我落地时的完整思路和代码。
2.1 生成终盘:三步填充法为什么比纯回溯快这么多
生成一个合法的数独终盘,最直观的方法是全盘回溯:从左上角开始,逐个格子尝试填入1~9,冲突就回退。这个方法能跑,但性能很看运气,运气不好回溯几万次都可能。尤其是填入中盘时频繁冲突,在低端设备上会有肉眼可见的卡顿。
我采用的是三步填充法,核心思想是:把9×9的盘面按3×3宫划分成9个宫,先填充对角线上的3个宫,再填充剩余区域。
为什么先填对角线宫?因为对角线上的3个宫互不相交,每个宫独立填1~9是1的排列组合排列,怎么填都是合法的,不存在冲突回退。而填充完这三个宫后,剩下的行和列已经有不少确定数字,后续回溯的搜索空间会大幅缩小,几乎不会碰到死胡同。
生成终盘的Dart代码如下:
import 'dart:math'; class Generator { static List<int> generate() { final board = List<int>.filled(81, 0); _fillDiagonalBoxes(board); _solveBacktrack(board); return board; } static void _fillDiagonalBoxes(List<int> board) { for (int i = 0; i < 9; i += 3) { _fillBox(board, i, i); } } static void _fillBox(List<int> board, int row, int col) { final nums = List<int>.generate(9, (i) => i + 1)..shuffle(Random()); int idx = 0; for (int r = row; r < row + 3; r++) { for (int c = col; c < col + 3; c++) { board[r * 9 + c] = nums[idx++]; } } } static bool _solveBacktrack(List<int> board) { for (int i = 0; i < 81; i++) { if (board[i] != 0) continue; final candidates = _getCandidates(board, i); candidates.shuffle(Random()); for (final num in candidates) { board[i] = num; if (_solveBacktrack(board)) return true; board[i] = 0; } return false; } return true; } static List<int> _getCandidates(List<int> board, int index) { final row = index ~/ 9, col = index % 9; final used = <int>{}; for (int i = 0; i < 9; i++) { used.add(board[row * 9 + i]); used.add(board[i * 9 + col]); } final br = (row ~/ 3) * 3, bc = (col ~/ 3) * 3; for (int r = br; r < br + 3; r++) { for (int c = bc; c < bc + 3; c++) { used.add(board[r * 9 + c]); } } return [1, 2, 3, 4, 5, 6, 7, 8, 9] .where((n) => !used.contains(n)) .toList(); } }看起来代码量不大,但性能差距是数量级的。实测在RK3568上,纯回溯生成一个终盘平均要几十毫秒到几百毫秒,三步填充法稳定在5毫秒以内,基本感觉不到。
顺带提一下,还有一种“已知合法盘面+随机变换”的生成法:先准备一个固定的合法终盘,对它做行组交换、列组交换、数字映射、转置等操作,生成另一个合法终盘。这种方法更快,但生成的盘面会有比较明显的“结构痕迹”,长期玩的人能看出来。移动端小游戏用三步填充法完全够用。
2.2 挖洞出题:不是随便挖,答案唯一是底线
有了终盘,接下来就是挖洞(删除部分数字)生成题目。这步最常见的坑是:挖完洞之后,题目存在多个解。
一个标准的数独题目,应当有且仅有一个解。怎么保证?我的做法是:每次尝试删除一个数字,然后用求解器跑一遍,检查解是否唯一。如果不唯一,就把这个数字放回去。
挖洞的完整逻辑:
class PuzzleGenerator { static List<int> generate({required Difficulty difficulty}) { final solution = Generator.generate(); final board = List<int>.from(solution); final holes = List<int>.generate(81, (i) => i)..shuffle(Random()); int targetHint = _hintCountFor(difficulty); // 简单45,中等36,困难30 int hint = 81; for (final index in holes) { if (hint <= targetHint) break; final backup = board[index]; board[index] = 0; if (_uniqueSolutionCount(board) != 1) { board[index] = backup; // 挖掉会多解,放回去 } else { hint--; } } return board; } static int _hintCountFor(Difficulty d) { switch (d) { case Difficulty.easy: return 45; case Difficulty.medium: return 36; case Difficulty.hard: return 30; } } }这里有个关键细节:判断唯一解时,不能“找到一个解就停”,而应该“尝试找第二个解,找到就立即返回”。如果完整跑完全部搜索,性能会非常差。下面2.3会详细讲求解器的实现。
另一个坑是挖洞顺序。如果不打乱顺序,从第0格开始依次挖,题目会呈现明显的“左少右多、上少下多”的偏斜。用shuffle打乱后,挖洞位置均匀分布,题目观感更好,难度也更稳定。
2.3 求解器:用“最少候选数优先”让唯一解判定提速
求解器不仅用于挖洞校验,还用于玩家填错时的“机会提示”。我用了最基本的回溯加候选数剪枝。
class Solver { static bool hasUniqueSolution(List<int> board) { return _solve(board, 0) == SolutionStatus.unique; } static SolutionStatus _solve(List<int> board, int foundCount) { if (foundCount > 1) return SolutionStatus.multiple; int bestIndex = -1; List<int> bestCandidates = []; for (int i = 0; i < 81; i++) { if (board[i] != 0) continue; final candidates = _candidatesFor(board, i); if (candidates.isEmpty) return SolutionStatus.none; if (bestIndex == -1 || candidates.length < bestCandidates.length) { bestIndex = i; bestCandidates = candidates; if (candidates.length == 1) break; // 只有一种可能,不用再找 } } if (bestIndex == -1) return SolutionStatus.unique; for (final num in bestCandidates) { board[bestIndex] = num; final status = _solve(board, foundCount); board[bestIndex] = 0; if (status == SolutionStatus.unique) { foundCount++; if (foundCount > 1) return SolutionStatus.multiple; } else if (status == SolutionStatus.multiple) { return SolutionStatus.multiple; } } return foundCount > 0 ? SolutionStatus.unique : SolutionStatus.none; } static List<int> _candidatesFor(List<int> board, int index) { final row = index ~/ 9, col = index % 9; final used = <int>{}; for (int i = 0; i < 9; i++) { used.add(board[row * 9 + i]); used.add(board[i * 9 + col]); } final br = (row ~/ 3) * 3, bc = (col ~/ 3) * 3; for (int r = br; r < br + 3; r++) { for (int c = bc; c < bc + 3; c++) { used.add(board[r * 9 + c]); } } return [1, 2, 3, 4, 5, 6, 7, 8, 9] .where((n) => !used.contains(n)) .toList(); } }核心优化是“最少候选数优先”:每次都选候选数最少的空格尝试,这样能让回溯的搜索树快速变窄。在挖洞校验时,大多数情况下只需要搜索很少的节点就能判断是否有第二个解。
补充一点:这段代码直接操作List<int>,而不是用二维数组,是为了减少对象分配。81个格子的游戏虽然不大,但在低端OpenHarmony设备上,一次操作分配一堆对象,GC一频繁就会出现掉帧。用扁平数组配合index ~/ 9和index % 9换算坐标,性能会明显更稳。
3. Flutter界面:网格绘制与交互细节
3.1 网格不用GridView硬拼,我用CustomPaint
数独界面最核心的组件是9×9网格。很多新手会直接用GridView.builder生成81个Container,颜色和边框用BoxDecoration设置。这个方案在真机上有个明显问题:81个widget在每次setState时全部重建,即使只填了一个数字,也会触发所有格子的layout,在低端设备上会有可感知的卡顿。
我的做法是分层绘制:
- 底层用
CustomPaint画网格线(细线、粗线、宫线) - 上面只放数字文本和笔记文本
- 数字用一个
Widget列表按坐标Position放置,不参与重新布局
绘制网格线的核心代码:
class BoardPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final cell = size.width / 9; final thin = Paint() ..color = const Color(0xFFD0D8E8) ..strokeWidth = 1; final thick = Paint() ..color = const Color(0xFF1A3A5C) ..strokeWidth = 2.5; for (int i = 0; i <= 9; i++) { final isThick = i % 3 == 0; final paint = isThick ? thick : thin; canvas.drawLine(Offset(i * cell, 0), Offset(i * cell, size.width), paint); canvas.drawLine(Offset(0, i * cell), Offset(size.width, i * cell), paint); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }为什么这样快?因为shouldRepaint返回false,网格线只在首次创建时绘制一次,后续填数字、高亮切换都不会重绘网格。而81个数字文本本身是最轻量的Text组件,不涉及栅格布局系统。
3.2 选中态与高亮联动:一次遍历,别搞复杂的监听
数独里一个很能提升体验的交互是:选中某个格子后,它所在的行、列、宫、以及相同数字的其他格子都会高亮。这个功能如果做成“每个格子独立监听选中状态”,代码会非常啰嗦,而且容易出bug。
正确做法是:在build里基于当前选中的坐标,一次性计算出所有需要高亮的格子索引集合,然后每个格子绘制时查询这个集合。
Set<int> _buildHighlightSet(int selectedIndex, List<int> board) { final result = <int>{}; if (selectedIndex == -1) return result; final row = selectedIndex ~/ 9; final col = selectedIndex % 9; final selectedValue = board[selectedIndex]; for (int i = 0; i < 9; i++) { result.add(row * 9 + i); // 同行 result.add(i * 9 + col); // 同列 } final br = (row ~/ 3) * 3, bc = (col ~/ 3) * 3; for (int r = br; r < br + 3; r++) { for (int c = bc; c < bc + 3; c++) { result.add(r * 9 + c); // 同宫 } } // 相同数字高亮(排除自己) for (int i = 0; i < 81; i++) { if (board[i] == selectedValue && selectedValue != 0 && i != selectedIndex) { result.add(i); } } return result; }这个集合在每次setState时重新计算,81个格子的遍历在设备上耗时可以忽略。格子绘制逻辑里:
final isSelected = index == selectedIndex; final isHighlighted = highlightSet.contains(index); final isSameValue = board[index] == selectedValue && selectedValue != 0; bgColor = isSelected ? const Color(0xFFBBDDFF) : isHighlighted ? const Color(0xFFEAF2FF) : isSameValue ? const Color(0xFFFFE8CC) : Colors.transparent;这套逻辑写出来非常直观,后续想改高亮颜色、增加“错误数字标红”,都是在自己的格子里加一个分支,不会牵扯到兄弟组件。
3.3 数字输入、笔记模式和撤销栈
底部数字键盘我用了最简单的“横向一排”:1到9,加上一个橡皮擦。点击数字时,向当前选中格子填入或移除数字。
这里有几个交互细节值得注意:
- 数字键盘要禁用无效按钮:如果当前格子已经固定(题面给出的数字),玩家点击数字应该无响应,而不是填上后又被重置。可以在数字键盘接口里传入
canEdit状态。 - 笔记模式用Set存储候选数:每个格子可能被填入多个笔记,用
Map<int, Set<int>>存储,简单直观。 - 错误提示不能靠颜色误导:玩家填错时,我用一个淡红色背景标记,但不会弹窗打断节奏。数独这种游戏最怕就是被频繁打断。
撤销功能是“能玩”和“好用”的分水岭。我用一个简单的快照栈实现:
final _history = <List<int>>[]; final _noteHistory = <Map<int, Set<int>>>[]; void _snapshot() { _history.add(List.from(board)); _noteHistory.add({ for (final e in notes.entries) e.key: Set.from(e.value), }); if (_history.length > 100) { _history.removeAt(0); _noteHistory.removeAt(0); } } void undo() { if (_history.isEmpty) return; board = _history.removeLast(); notes = _noteHistory.removeLast(); setState(() {}); }快照栈的细节是:每次push的是新List和新的Set集合,绝不能直接存引用。否则后续修改board时,历史记录也会跟着变,撤销就会失效。这个bug我一开始就踩过,排查了半天才发现是引用共享的问题。保存100步足够长局使用了,再多会占内存,在低端设备上不划算。
4. OpenHarmony部署与调试:这一路踩的坑不踩一遍真不知道
4.1 从Flutter工程到OpenHarmony壳工程
如果你熟悉Flutter的Android工程,那OpenHarmony壳工程的目录结构并不陌生。Flutter官方在OpenHarmony上的适配是通过“Flutter SDK for OpenHarmony”提供的,需要在工程里新增一个ohos目录,并配置好对应的构建脚本。
整体配置步骤大致如下:
- 下载适配OpenHarmony的Flutter SDK,并在
flutter config里指定 - 在项目根目录执行
flutter create --platforms=ohos .生成壳工程 - 在
ohos/目录下检查module.json5和build-profile.json5中的bundleName和versionCode - 执行
hdc list targets确认开发板连接 - 用DevEco Studio或命令行完成签名配置,再执行安装
第一步其实是最大的坑。因为Flutter官方主分支优先支持Android/iOS,OpenHarmony的适配分支是单独维护的。如果你用普通Flutter SDK执行flutter create,根本看不到ohos这个平台选项。正确做法是下载适配了OpenHarmony的Flutter SDK,并把它配置为当前项目的SDK路径。
配置好之后,flutter devices应该能看到OpenHarmony设备,flutter run -d <deviceId>就能把调试包推上去。如果卡在了“设备不显示”这一步,先确认hdc list targets能列出设备,hdc是OpenHarmony的命令行工具,类似Android的adb。
4.2 hdc命令的日常:查看系统版本和推送文件
开发调试过程中,我跟hdc打交道非常多。这里列出几个实用命令:
# 查看当前连接的设备 hdc list targets # 查看OpenHarmony系统版本 hdc shell param get const.product.name # 查看SDK版本 hdc shell param get const.ohos.version # 推送文件到设备 hdc file send ./app.hap /data/local/tmp/app.hap # 安装hap包 hdc install /data/local/tmp/app.hap # 查看应用日志 hdc shell hilog | grep "your_app_tag"尤其是hdc shell param get这组命令,排查版本问题时特别好用。有几次应用装上去闪退,我第一反应就是看系统版本和API Level是否匹配。OpenHarmony的API版本跟Flutter SDK的适配版本有对应关系,版本不匹配会报奇怪的符号链接错误。
4.3 签名配置:那不叫坑,叫连环坑
OpenHarmony真机安装应用,需要签名。这个流程跟Android的debug签名类似,但手续更繁琐。最典型的报错是:
error: failed to install bundle. code: 9568320 error: verify signature failed.这个错误出现时,我一度以为是hap包损坏,后来才发现是签名证书和设备的UDID不匹配。OpenHarmony的签名需要一个.p7b文件和一个对应的.cer证书,可以通过DevEco Studio的自动签名功能生成。自动签名要求登录开发者账号,并且设备必须处于联网状态。
我自己遇到的一个细节是:开发板的UDID(也可以叫devudid,可以用hdc shell bm get -u查到)和模拟器的UDID不一样,换设备就要重新生成签名。如果你在一个开发板上调试得好好的,换另一台板子突然签名失败,优先检查是不是签名没重新配置。
4.4 在RK3568和RK3588上的实际表现
我手头有两块开发板,一块RK3568,一块RK3588。前者四核A55,定位是低功耗入门级;后者四核A76加四核A55,性能明显更强。数独这个游戏,两者跑起来帧率差别不太大,但有一个现象很典型:
- RK3588上丝滑流畅,点击响应几乎是即时的
- RK3568上如果打开系统动画,或者后台有大量应用,Flutter的图形栈有时会出现掉帧
问题不在Flutter本身,而在OpenHarmony的图形合成器上。Flutter是自渲染引擎,它的每一帧都是自己画好再交给系统合成的。如果系统合成压力大,Flutter的提交会被排队。解决办法是在FlutterEngine初始化时设置:
final engine = FlutterEngine(); engine.setCompatibilityMode(true); // 兼容模式,降低部分渲染优化以提升稳定性这个参数不是所有场景都需要,但如果你的游戏在特定设备上出现不均匀的卡顿,可以试试看。另一个经验是:数独这种静态页面,不需要高频刷新整个Surface。我后来把不需要动画的区域用RepaintBoundary包起来,实测在RK3568上的帧时间从平均16ms降到了10ms左右,掉帧明显减少。
4.5 如果你同时保留Android目录:那个Gradle报错
项目里如果同时保留了android目录,有时在Android Studio或命令行构建时,会弹出类似这样的Gradle错误:
You are applying Flutter's main Gradle plugin imperatively using the apply script method.这个报错提示的是Flutter Gradle插件的新旧版本兼容问题。我一开始没管它,因为反正目标平台是OpenHarmony。但它会影响flutter build命令的整体流程,导致构建中断。解决办法很简单:在android/settings.gradle里,把旧的apply from: "$flutterRoot/packages/flutter_tools/gradle/app_plugin_loader.gradle"改成新版插件引入方式,或者如果你暂时不需要Android包,直接把android目录移出项目根目录,等需要的时候再加回来。
这个坑虽然小,但很容易让人误以为是OpenHarmony环境没配好,白白排查半小时。
5. 从能玩到好玩:难度曲线、状态恢复和几个隐藏Bug
5.1 难度分级不能只看“挖洞数量”,还要看逻辑链深度
前面提到我用“已填数字数量”来粗略控制难度:简单45个,中等36个,困难30个。但实际试玩下来发现,单纯按数量分级有个毛病:有时候挖了45个洞,仍然简单得离谱;有时候30个洞,却简单得反常。
原因是数独的难度本质由“解题需要用到的高级技巧链长度”决定。一个只有30个已知数的盘面,如果每个格子的候选数都很容易排除,可能比40个已知数但候选数密集的盘面更容易解。
后来我在项目里加了一个“候选数最少格子”的统计:出题时,计算所有空格候选数的平均数和中位数。如果平均数超过4.2,并且有至少一个空格候选数是6个以上,就把难度标记为困难。这个指标不是最精确的难度评估,但对移动端小游戏来说,简单、可解释、调试方便,比复杂的“人类解题策略模拟”实用得多。
5.2 状态恢复:不是存进度,是存“撤销栈”
很多小游戏只做了“暂停保存当前盘面”,但玩家一退出再进来,连之前填错想撤销的记录都没了。我当时做了一个更完整的恢复机制:
- 把
board、notes、history三个对象序列化成JSON - 用
shared_preferences存在本地 - 每次玩家输入新数字时,延迟1秒写入
- 应用启动时检查是否有未完成的游戏,有则弹出“继续上次”的提示
notes和history序列化的细节比较容易出问题:history是一个最多100层的List<List<int>>,直接JSON序列化没问题,但反序列化时要确保每个内层都是新的List对象,否则又会出现“引用共享”导致撤销失效。
List<List<int>> restoreHistory(List<dynamic> raw) { return raw .map((layer) => List<int>.from(layer as List<dynamic>)) .toList(); }5.3 我遇到的三个隐藏Bug,附排查思路
Bug 1:完成判定偶发失灵
现象:玩家明明填满了整个棋盘,且没有错误,但“成功弹窗”不出现。
排查链路:我先去检查完成判定函数,逻辑很简单——遍历81格,board[i] != 0且与solution[i]相等。但漏掉了一个场景:如果玩家用笔记模式覆盖了数字,board仍然是0,notes里却有值。完成判定就会失败。修复方案是把完成判定改成同时校验board和solution,且当board[i] == 0时直接判未完成。
Bug 2:换肤后数字颜色不更新
现象:在设置里切换“深色模式”后,棋盘上的数字颜色没变,但背景变成了深色。深色背景上的深蓝色数字几乎看不清。
排查链路:第一反应是主题没刷新,检查后发现Color确实变了,但格子里的数字Widget在外层PageView重建时没有触发自身的build。原因是BoardPainter的shouldRepaint返回了false,导致网格线和数字文本的绘制不更新。修复方案是把shouldRepaint改为比较背景色:
@override bool shouldRepaint(covariant BoardPainter oldDelegate) => oldDelegate.backgroundColor != backgroundColor;Bug 3:点击数字时,偶尔出现“填格没反应”
现象:在RK3568上快速连续点击数字按钮,部分点击没有生效。
排查链路:一开始以为是触摸事件丢失,后来加日志发现是“数字键盘的onPressed回调里,每次都会执行一次_snapshot()然后setState()”,连续点击时,第一次setState还没完成,第二次就进来了,导致中间某次点击被丢弃。修复方案是在输入处理函数开头加一个简单的防抖:
bool _isProcessing = false; void _onNumberTap(int num) { if (_isProcessing) return; _isProcessing = true; setState(() { // 填入数字 }); // 注意:setState是同步的,但build是异步的,用下一帧清零防抖 WidgetsBinding.instance.addPostFrameCallback((_) { _isProcessing = false; }); }这个防抖并不是真正的“丢弃输入”,而是给UI一帧的缓冲时间,避免极端快速点击下的竞态问题。用了之后,触发率恢复正常,在低端设备上连续点击再也没有丢过。
5.4 后续可以继续扩展的方向
这个数独生成器跑通后,我脑子里浮现了好几个升级方向,但目前还没有全部落地:
- 接入Flame游戏引擎做动画特效,把“填对数字”的反馈做成粒子效果
- 加入联机对战,用OpenHarmony的分布式软总线做设备间实时同步
- 做一个“每日挑战”模式,用当前日期做随机种子,保证所有用户拿到同一道题
- 把所有逻辑抽成ArkTS版本,做一个纯ArkUI的对照版,对比两种开发方式的差异
最后一个方向我觉得最有意思。如果把“同一套需求、同一套UI逻辑”分别用Flutter和ArkTS写一遍,两份代码都跑在同一块RK3568上,对比包体积、启动时间、帧率、内存占用,那对这个生态里正在选型的团队会是特别有价值的参考资料。
最后说说我的实际感受
这个项目从零到能玩,花了大概一周的业余时间。最大感受是:在OpenHarmony上跑Flutter,可行性早已不是问题,问题在于一些“系统细节”需要多花时间适应。
签名、hdc、部分渲染行为,都跟Android有微妙差异,但不会让你卡死。反而Flutter本身的跨端能力帮了大忙——算法层和UI层一次写好,Android上能跑,OpenHarmony上也能跑,几乎不用改。
如果你正打算用Flutter给OpenHarmony做一个工具类或者小游戏应用,我的建议是:把核心逻辑做成纯Dart,把UI依赖降到最低,然后在真机上尽早开始联调。别等全部写完再上设备,那样遇到渲染兼容问题会非常难受。
最后分享一个小技巧:OpenHarmony设备调试时,我习惯在代码里加一个“本机性能浮层”,用FPS + 帧时间的实时数据显示在屏幕角落。这样你在不同开发板上切换时,能立刻看到当前设备的性能表现,不用每次都拿电脑看日志。这个小功能对优化低端设备体验非常有效,强烈建议在项目早期就做进去。