最近一直在折腾一个基于Flutter的OpenHarmony游戏集合App里面预打算做数独、扫雷、贪吃蛇三个小游戏目前第一个跑完闭环的就是数独。趁热把“数字填入”这个核心交互的完整实现过程记录下来包括棋盘生成算法、Flutter侧的状态管理、以及Flutter和OpenHarmony原生层之间的EventChannel通信。如果你正准备在OpenHarmony上做工具类或游戏类App这篇文章应该可以帮你绕开几个我踩过的坑。1. 项目整体设计与思路拆解1.1 为什么在OpenHarmony上选Flutter而不是ArkUI或纯原生先说结论在OpenHarmony上做游戏集合AppFlutter不是唯一选择但在这个项目场景下它是综合成本最低的方案。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个递归效率完全不是一个量级。ListListint generateSolution() { final board List.generate(9, (_) List.filled(9, 0)); _fillBoard(board); return board; } bool _fillBoard(ListListint 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 Listint.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(ListListint 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约1msEventChannel推送电池数据延迟小于5ms小于3ms页面切换整屏构建耗时约180ms约120ms数字填入后UI刷新能控制在2ms以内主要靠两点棋盘用CustomPainter只重绘脏区域不重建Widget树数字填入时采用乐观更新策略先更新board数据和格子状态再触发冲突校验。因为单格冲突校验是微秒级操作这种顺序不会带来可感知的延迟但代码逻辑会清爽很多。还有一个容易被忽略的细节数字面板的点击区域。手机屏幕有限9个数字加1个擦除按钮如果按钮尺寸低于44x44dp实际体验会非常难受。我直接做到54x54dp界面虽然稍微占地方但拇指命中率明显提升。游戏App里“按钮够大好点”永远排在“界面密度高”前面这个优先级别搞反了。棋盘绘制还有一个观感技巧不要在整块棋盘上均匀画9条横线加9条竖线那样会产生粗细不均的视觉错觉。正确的做法是分层绘制3x3大宫用粗线分隔普通格子用细线这样视觉上专业很多。最后关于输入顺滑度还有一个发现连续输入时如果每次填完数字都自动跳到下一个空格节奏非常顺畅但如果填入的是冲突数字要停在原格让玩家先看清问题。这个细节让数独的录入体验发生了质变非常推荐你也这样设计。我在实际跑完这个项目后最大的体会是Flutter在OpenHarmony上的开发体验已经比早期成熟了不少但桥接层的坑依然需要自己一个个踩。EventChannel这种跨端通信机制不是“搭完就完”的事情它的资源释放、时序和数据格式都需要认真设计。如果你也想在OpenHarmony上做一个类似的游戏集合App建议把数独的数字填入模块当作入门练手项目它能覆盖Flutter绘制、状态管理、事件通道这一整套核心技能而且难度适中不容易从一开始就被劝退。做OpenHarmony开发与其等生态完全成熟不如现在就拿一个真实的小项目跑起来跑通一个闭环后面的路就会顺很多。