最近一直在折腾一个基于Flutter的OpenHarmony游戏集合App,里面预打算做数独、扫雷、贪吃蛇三个小游戏,目前第一个跑完闭环的就是数独。趁热把“数字填入”这个核心交互的完整实现过程记录下来,包括棋盘生成算法、Flutter侧的状态管理、以及Flutter和OpenHarmony原生层之间的EventChannel通信。如果你正准备在OpenHarmony上做工具类或游戏类App,这篇文章应该可以帮你绕开几个我踩过的坑。
1. 项目整体设计与思路拆解
1.1 为什么在OpenHarmony上选Flutter而不是ArkUI或纯原生
先说结论:在OpenHarmony上做游戏集合App,Flutter不是唯一选择,但在这个项目场景下,它是综合成本最低的方案。
OpenHarmony官方主推的ArkUI声明式开发,对于工具类App来说已经够用,而且很多系统应用都是ArkUI写的,流畅度也OK。但我的场景有一点特殊:里面是数独、扫雷这类小游戏,有大量自绘UI、交互动效和棋盘渲染需求。用ArkUI做当然也能做,但动效流畅度和自定义渲染的灵活度需要额外补很多功课。Flutter在这块儿的底子更好,所有UI都是引擎自绘,动画体系本身就是120fps级别的。配合Impeller渲染器,即使中低端设备上跑这类2D游戏也基本没有压力,实测下来的确如此。
还有一个更现实的因素:跨端复用。这个游戏集合App并不只想跑在OpenHarmony上,Android、iOS都在规划内。如果每个平台写一套,光数独棋盘渲染就要维护三份代码,这谁受得了。Flutter把大部分核心逻辑沉淀在Dart层,各平台只留一个轻量宿主壳子,数独、扫雷这些模块完全可以一套代码多处复用。当然,如果你确定只做OpenHarmony单平台,且对系统能力调用很深,直接用ArkUI反而更合适。没有绝对正确的技术选型,关键看项目定位。
1.2 数独数字填入模块的功能边界与状态流
整个App的架构不复杂:一个首页入口、一个游戏大厅、每个小游戏独立成模块。数独模块内部又拆成棋盘生成、数字填入、错误校验、计时排行、提示系统几个子模块。这篇重点讲“数字填入”,也就是从用户点击格子、弹出数字面板、选数字上板、到冲突反馈的完整闭环。
给模块定清楚边界很重要。新手做数独容易上来就想把所有功能全做完,结果每个功能都做得很毛糙。我这次特意把“数字填入”这个最小闭环摘出来,规定它只做四件事:
- 点击棋盘空格,高亮选中状态
- 弹出数字选择面板,也支持软键盘输入
- 数字写入对应格子,并触发冲突校验
- 遇到同行同列或同宫冲突,用醒目颜色反馈
把这四件事做透了,往下扩展备注模式和提示系统就自然顺畅。我当时的经验是:先跑通最小闭环再往上加功能,永远别让复杂逻辑和核心交互搅在一起。从状态管理来看,数独棋盘本质是一个9x9二维数组,每个格子带有期望值、用户填入值、是否固定、是否出错、是否候选模式等状态。我建了一个GameState类,用Provider做状态分发。数独这种模块级的小体量状态,用Provider的ChangeNotifier完全Hold住,不需要硬上Riverpod或Bloc。有些项目一上来就搞重型状态管理方案,反而给工程增加负担。
2. 数独核心逻辑:棋盘生成与填入规则
2.1 棋盘生成:回溯法填盘与挖洞法出题
数独棋盘生成的标准套路是“先填满,再挖洞”。填满这一步,我用的是回溯法,思路和走迷宫差不多:从第一个格子开始,找一个合法数字填进去,继续往后走,如果走到某个格子发现1到9全都不合法,说明前面某个数字选错了,就回退到上一个格子换一个数字再试。直到81个格子全部填满,一个完整终盘就出来了。
这里有两个关键点需要注意:随机性和剪枝。随机性体现在候选数字的选取顺序,我每次生成时先把1到9打乱再逐个尝试,这样每局终盘都不一样。不随机的话,玩家几次玩下来就会发现套路,非常影响体验。剪枝则是性能的关键,在当前格子计算“同行、同列、同宫内已经出现过的数字集合”,剩下的才是可以尝试的候选数字,候选范围一下子从9个缩小到平均3到5个,递归效率完全不是一个量级。
List<List<int>> generateSolution() { final board = List.generate(9, (_) => List.filled(9, 0)); _fillBoard(board); return board; } bool _fillBoard(List<List<int>> board, [int row = 0, int col = 0]) { if (row == 9) return true; final nextRow = col == 8 ? row + 1 : row; final nextCol = col == 8 ? 0 : col + 1; if (board[row][col] != 0) return _fillBoard(board, nextRow, nextCol); final candidates = List<int>.generate(9, (i) => i + 1)..shuffle(); for (final num in candidates) { if (_isValid(board, row, col, num)) { board[row][col] = num; if (_fillBoard(board, nextRow, nextCol)) return true; board[row][col] = 0; } } return false; }有了完整终盘后,下一步是挖洞生成题面。挖洞的原则是:随机从终盘里移除一些数字,但必须保证移除之后这局题仍然只有一个解。唯一解验证同样借助回溯法,从挖洞后的棋盘重新求解并统计解的个数,超过一个就说明挖多了,需要恢复个别数字。我实测下来,简单难度挖30到35个洞,中等挖40到45个,困难挖50到55个,是比较合理的范围。要注意挖洞位置尽量打散,不要集中在某一行或某一列,否则题面看起来会很奇怪,玩家也会觉得别扭。
关于生成耗时有一个真实数据:Debug模式下生成一局棋盘大约需要50到80毫秒,Release模式不到10毫秒。这个量级完全可以直接同步生成,没必要上异步或预生成。我一开始还想着把生成逻辑丢进Isolate,后来发现纯粹是增加通信复杂度,又改回了同步方案。有些优化真的得先量过数据再决定做不做。
2.2 数字填入的交互设计、冲突校验与视觉高亮
数字填入的交互流程,我拆成三个环节来讲:选中格子、输入数字、冲突反馈。
选中格子是整个交互的起点。棋盘我用CustomPaint绘制9x9网格,点击坐标换算命中到具体行列。格子状态我定义了一个CellStatus枚举,对应固定题面、已填入、可选、冲突、选中几种情况,和board二维数组一一对应。输入数字的方式,第一版做了一个底部数字面板,九个数字按钮加一个擦除按钮,专门适配手机拇指操作。后来也支持了软键盘,但主入口还是数字面板,主要是好控制和体验统一。
冲突校验的规则要严格还原数独规则:行、列、宫三个维度的查重。我没有做全盘扫描,而是只检查当前格在所在行、列、宫内是否重复,单格局部校验在微秒级,全盘扫描虽然写法简单但每次填入都跑一遍,在低端设备上会有多余开销。
bool isConflict(List<List<int>> board, int row, int col) { final val = board[row][col]; if (val == 0) return false; for (int i = 0; i < 9; i++) { if (i != col && board[row][i] == val) return true; if (i != row && board[i][col] == val) return true; } final boxRow = row ~/ 3 * 3; final boxCol = col ~/ 3 * 3; for (int r = boxRow; r < boxRow + 3; r++) { for (int c = boxCol; c < boxCol + 3; c++) { if (r != row && c != col && board[r][c] == val) return true; } } return false; }冲突的视觉反馈也花了一些心思。格子默认是白色背景,选中状态用浅蓝色高亮,冲突数字用红色字体加浅红背景。另外做了一个联动高亮效果:选中某个格子时,它所在的行、列、九宫格全部用淡灰底色,让玩家一眼看清当前格的关联区域。这个交互细节做出来之后,测试反馈非常好,很多玩家甚至不需要看教程就能理解联动规则。
状态管理上,我用GameState extends ChangeNotifier持有board、solution、cellStatuses和selectedPos。每次填入数字就更新board和cellStatuses,然后调用notifyListeners触发刷新。这里有一个实战经验:棋盘刷新不要用setState包整个页面,否则每次填数字都会重建整棵Widget树,低端机上肉眼可见掉帧。我把棋盘拆成独立BoardWidget,用CustomPainter绘制,只监听GameState的变化,setState也只在它自身范围内重绘。这个改动让帧率稳定了很多。
3. Flutter与OpenHarmony原生的EventChannel联动
3.1 数独模块里为什么要走原生事件通道
看到这里你可能会问:数字填入明明是纯UI交互,为什么还要往OpenHarmony原生层走?这里澄清一下,我不是把数字填入逻辑放到原生层,而是在游戏集合App里有一个跨游戏通用的全局计时状态和用电量统计,需要读取OpenHarmony的电池状态和前台运行时间。这些系统数据只有原生层能拿到,Flutter侧需要一条持续的、事件驱动的数据流,这正是EventChannel的用武之地。借数独这个项目,我正好把EventChannel从搭建到调试的完整链路串了一遍,这些经验对任何需要感知系统状态的游戏模块都通用。
Flutter与OpenHarmony的通信机制其实分两种。MethodChannel适合“调用一次,返回结果”的同步场景,比如让原生层保存一个文件。而EventChannel适合“持续广播”的场景,打个比方,原生层是直播间的主播,Flutter侧是观众,主播不断推流,观众只需要订阅收看。电池状态变化、前台切换这类事件天然适合后者。我这次没有单独为每个场景建通道,而是把所有原生桥接收敛到一个统一的BridgingManager,后期维护省心很多。数独模块主要在三个场景用到跨端通信:读取电池状态、获取前台计时、一局完成后触发本地通知。
3.2 从原生注册到Dart监听的完整实现步骤
下面直接按操作顺序写实现步骤,这是当时在DevEco Studio里配好Flutter环境后实际跑通的流程。
第一步:确认插件工程目录。Flutter插件在OpenHarmony侧的结构大致如下,ohos目录放原生ArkTS代码,lib目录放Dart接口,example放测试工程。
- ohos/ - src/main/ets/... - lib/ - gamekit_battery.dart - example/ - lib/main.dart第二步:在原生侧创建并注册EventChannel。以电池状态为例,在插件注册入口创建EventChannel实例,并实现streamHandler。OpenHarmony侧的ArkTS代码简化后大概是这个样子:
import { EventChannel } from '@ohos/flutter_ohos'; export class BatteryEventChannel { private eventChannel: EventChannel; constructor(pluginBinding: FlutterPluginBinding) { this.eventChannel = new EventChannel(pluginBinding, 'gamekit/battery'); this.eventChannel.setStreamHandler({ onListen: (arguments) => { this.startBatteryMonitor(); }, onCancel: (arguments) => { this.stopBatteryMonitor(); } }); } }这里有一个特别需要注意的机制:onListen在Flutter侧开始监听时触发,onCancel在取消监听时触发。我一开始没在onCancel里释放资源,导致玩家退出游戏页面后电池监听还在后台跑,测试机上耗电明显增加。事件型通道的“有监听就推,没监听就停”这个闭环一定要处理好,这不是小问题。
第三步:Dart侧订阅频道。核心代码非常简短:
static const EventChannel _batteryChannel = EventChannel('gamekit/battery'); void initBatteryListener() { _batteryChannel.receiveBroadcastStream().listen((event) { final batteryLevel = (event as Map)['level'] as int; final isCharging = (event as Map)['charging'] as bool; // 更新数独界面的电量显示 }, onError: (error) { debugPrint('Battery channel error: $error'); }); }第四步:真机联调。在DevEco Studio里安装应用到OpenHarmony设备或模拟器后,用Flutter attach连接,可以看到Dart侧的EventChannel日志。这里我踩过一个大坑:如果Dart侧先于原生侧准备好就发起监听,首次注册会超时失败。解决办法是在页面initState调用receiveBroadcastStream后做一个短延迟重试,或者确保原生插件的注册在Flutter入口之前完成。我在项目里选择了后者,把原生插件初始化提前到了Application层。
EventChannel的数据格式也值得说两句。OpenHarmony原生侧向Flutter传Map时,键值必须是可序列化的简单对象。我传过int、bool、String都能正常解码,一旦传自定义类实例就会在序列化时报错。所以桥接层的数据模型建议全部用简单Map,复杂逻辑放Dart侧做。另外,如果你做OpenHarmony应用上架,原生插件的行为规范也要注意,不要调用系统敏感能力却不申请权限声明,否则应用认证阶段会卡住。
4. 实战踩坑记录与性能优化
4.1 我在编译适配中遇到的真问题
这个部分把印象最深的几类问题整理出来,希望帮你少走弯路。
第一类是编译依赖问题,现象很典型:OpenHarmony工程编译Flutter插件时报“Could not resolve all task dependencies for configuration ':app:debugcompileclasspath'”。这个报错主要两个原因,一是Flutter SDK与OpenHarmony适配分支版本不匹配,比如Flutter的ohos分支要求OpenHarmony SDK不低于某个版本;二是Gradle仓库同步失败,需要检查依赖仓库地址和本地Gradle缓存。那次我排查了很久,最后发现是本地Gradle缓存损坏,删掉缓存目录重新构建就好了。
第二类是PlatformView的使用取舍。游戏集合App如果要在Flutter页面里嵌一个OpenHarmony原生控件,理论上要用PlatformView。但数独棋盘我强烈不建议通过PlatformView去嵌原生画布,因为OpenHarmony上的PlatformView性能还有明显瓶颈,焦点抢占、触摸事件分发都存在不确定性。棋盘纯Flutter绘制,原生层只做事件通道和数据存储,这样分工最舒服。
第三类是热重载失效的坑。只改Dart层代码,热重载很爽很顺滑;但一旦改了原生插件代码,热重载必然不会生效,必须停止应用重新安装。原因很好理解:原生插件编译成独立的so库,Flutter热重载只能替换Dart层代码。所以我的调试习惯是分清层次:纯UI逻辑用热重载,涉及原生层改动就直接重新构建。
第四类是关于EventChannel的发热和内存问题。长时间挂着监听流,页面销毁时没有取消监听,会导致原生侧对象无法回收。我在数独模块的dispose方法里特意取消了订阅,原生侧在onCancel里释放定时器。就这个看似很简单的点,让我在一台4GB内存的测试机上避免了内存持续攀升的问题。这里补充一句,Impeller渲染器在OpenHarmony适配版里默认开启,实测数独这类2D绘制场景帧率稳定在60fps以上。如果低端设备掉帧,可以先关掉Impeller做对比实验,定位是不是Shader编译导致的抖动。
4.2 性能实测数据与几个提升体验的小细节
游戏类App,体验先过得去才算完事。数独模块优化完成之后,我在OpenHarmony 5.0模拟器和一台RK3568开发板上各跑了一遍性能测试,关键数据整理成表:
| 测试项 | RK3568开发板 | OpenHarmony 5.0模拟器 |
|---|---|---|
| 棋盘生成耗时(Release) | 约9ms | 约6ms |
| 数字填入后UI刷新耗时 | 约2ms | 约1ms |
| EventChannel推送电池数据延迟 | 小于5ms | 小于3ms |
| 页面切换整屏构建耗时 | 约180ms | 约120ms |
数字填入后UI刷新能控制在2ms以内,主要靠两点:棋盘用CustomPainter只重绘脏区域,不重建Widget树;数字填入时采用乐观更新策略,先更新board数据和格子状态,再触发冲突校验。因为单格冲突校验是微秒级操作,这种顺序不会带来可感知的延迟,但代码逻辑会清爽很多。
还有一个容易被忽略的细节:数字面板的点击区域。手机屏幕有限,9个数字加1个擦除按钮,如果按钮尺寸低于44x44dp,实际体验会非常难受。我直接做到54x54dp,界面虽然稍微占地方,但拇指命中率明显提升。游戏App里“按钮够大好点”永远排在“界面密度高”前面,这个优先级别搞反了。
棋盘绘制还有一个观感技巧:不要在整块棋盘上均匀画9条横线加9条竖线,那样会产生粗细不均的视觉错觉。正确的做法是分层绘制,3x3大宫用粗线分隔,普通格子用细线,这样视觉上专业很多。最后关于输入顺滑度还有一个发现:连续输入时,如果每次填完数字都自动跳到下一个空格,节奏非常顺畅;但如果填入的是冲突数字,要停在原格让玩家先看清问题。这个细节让数独的录入体验发生了质变,非常推荐你也这样设计。
我在实际跑完这个项目后,最大的体会是:Flutter在OpenHarmony上的开发体验已经比早期成熟了不少,但桥接层的坑依然需要自己一个个踩。EventChannel这种跨端通信机制,不是“搭完就完”的事情,它的资源释放、时序和数据格式都需要认真设计。如果你也想在OpenHarmony上做一个类似的游戏集合App,建议把数独的数字填入模块当作入门练手项目,它能覆盖Flutter绘制、状态管理、事件通道这一整套核心技能,而且难度适中,不容易从一开始就被劝退。做OpenHarmony开发,与其等生态完全成熟,不如现在就拿一个真实的小项目跑起来,跑通一个闭环,后面的路就会顺很多。