ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter for OpenHarmony实战:数独数字填入与跨端通信详解

Flutter for OpenHarmony实战:数独数字填入与跨端通信详解 最近在折腾 Flutter for OpenHarmony做了个游戏集合 App首个正经模块选了数独。数独看起来规则简单真正把“数字填入”这个交互做得顺手牵扯到的内容比想象中多得多棋盘绘制、冲突校验、候选数笔记、跨端事件通道还有 OpenHarmony 侧适配的坑。今天把这部分实战过程完整整理出来希望能帮到正在做鸿蒙端 Flutter 应用、或者单纯对数独实现感兴趣的朋友。这篇文章会从整体设计思路开始讲到数据模型和核心算法再落地说 Flutter 与 OpenHarmony 的通信和 UI 实操最后把实际踩过的坑都列出来。我不太喜欢写那种“复制就能跑”的纯教程代码给到的是关键片段和完整思路动手时能直接改到自己的项目里。1. 项目背景与整体设计思路1.1 为什么在 OpenHarmony 上选 Flutter做 OpenHarmony 应用摆在前面的选项其实不少纯 ArkTS 开发、三方跨端框架、Flutter 等。从我这个项目的实际情况看选 Flutter 有几个硬理由。第一是团队已经有 Flutter 的技术积累Dart 语法和 Flutter 的组件模型大家都熟上手成本低第二是 Flutter 的自绘引擎在 OpenHarmony 上能跑通 UI 渲染链路不依赖系统原生控件适配工作量相对可控第三是代码可以同时留作 Android、iOS 版本复用游戏集合这种偏 UI 交互的 App跨端一致性收益特别大。当然选了 Flutter 不等于什么都很顺。OpenHarmony 官方的 Flutter 适配层是一套独立的 engine 分支很多接口和标准 Flutter 版本有细微差别插件也是走 OpenHarmony SDK 的 Native 侧实现。所以开发过程中我不光得写 Dart还要经常切到 DevEco Studio 里看 ArkTS 侧代码两套环境来回调试是常态。真正让我下决心用 Flutter 的另一个原因是游戏集合 App 的定位。数独只是其中一个房间后面还会加扫雷、华容道、2048 这类游戏。这些游戏的共同特点是逻辑层和渲染层关系密切、交互密度高、状态变化频繁。Flutter 的声明式 UI 和响应式状态管理天然适合这种场景比 ArkTS 侧用命令式改控件状态要省心很多。1.2 数独模块在整个 App 中的定位与功能拆解数独模块在游戏集合里承担“工具型游戏”的角色不需要联网不需要实时对战用户打开就能玩。第一版我规划的核心功能有四个游戏棋盘、数字键盘、笔记模式、冲突提示。这四个功能一起构成了“数字填入”的核心交互闭环。游戏棋盘9x9 宫格固定数字和用户填入数字要明显区分当前选中格要有高亮态。数字键盘底部 1-9 数字按钮附带擦除按钮和笔记模式开关。笔记模式在格子里记录多个候选数字的小标记供解谜过程使用。冲突提示用户填入的数字如果和行、列、宫内的已有数字冲突需要即时反馈。这四个功能拆出来之后开发顺序就很明确了。先做棋盘和数据模型再做点击交互最后加入冲突判定和笔记。这里我特别想说的是做游戏类功能一定先把数据模型定稳再去碰 UI。我见过不少人在棋盘 Widget 里直接存 ListList 然后到处改数最后逻辑和界面搅成一团。后面讲的模型设计就是为了避免这个坑。2. 数字填入的核心逻辑与数据结构设计2.1 数字填入的数据模型二维数组与格子状态数独棋盘拿二维数组表示最直观ListListint存 0-90 代表空。但只存数值不够因为一个格子可能有多种状态是固定题面还是用户填入填错了要不要变色笔记模式下候选数字怎么存。所以我建了一个Cell类棋盘里存ListListCell。class Cell { int value; // 0 表示空 bool isFixed; // 是否题面固定数字 bool isSelected; // 是否被选中 bool isConflict; // 是否冲突 Setint candidates; // 笔记候选数 bool isNoteMode; // 当前格子是否处于笔记填写状态 Cell({ this.value 0, this.isFixed false, this.isSelected false, this.isConflict false, this.isNoteMode false, Setint? candidates, }) : candidates candidates ?? {}; }固定数字在游戏开始后就锁定点击无反应防止用户误改题面。用户填入的数字存储在 value 里如果后面要做撤销还能加一个历史栈把每次操作前的格子状态压栈。我第一版没做撤销后来用户反馈强烈第二版补上了数据结构改起来不算伤筋动骨就是因为状态是集中管理的。有经验的 Flutter 开发者看到这个Cell类会提醒在 Widget 里直接改 Cell 的属性不会触发重绘因为对象引用没变。所以我在 ViewModel 里操作数据后会整体替换棋盘状态或者用ChangeNotifier通知刷新。这里要养成好习惯数据更新和 UI 刷新永远走同一套状态管理流程别偶尔为了省事直接 setState 局部改。2.2 冲突校验算法行、列、宫三重判断数独规则很好理解每一行、每一列、每一个 3x3 宫1-9 各出现一次不能重复。所以判断数字能不能填入其实就是检查目标格子所在行、列、宫三个维度里是否已经出现了这个数字。bool isConflictAt(int row, int col, int num) { // 行冲突 for (int c 0; c 9; c) { if (c ! col grid[row][c].value num) return true; } // 列冲突 for (int r 0; r 9; r) { if (r ! row grid[r][col].value num) return true; } // 宫冲突 int boxRow (row ~/ 3) * 3; int 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) grid[r][c].value num) return true; } } return false; }这里有个细节值得展开判断时要把自己排除掉。因为用户可能对同一个格子重复填同一个数字此时不应该提示冲突。我第一次从别处抄的代码没排除自身结果点击已填数字的格子直接变红体验很怪。另一个细节是效率。数独棋盘只有 81 个格子三重循环在每个维度最多扫 9 个格子冲突检查最多也就 27 次比较性能完全不是问题。但如果你想做得更优雅可以预计算行、列、宫的掩码表用位运算快速判断开发过程中我也实践过这版效果很好。// 位掩码方式0-9 用 bit 位表示初始 0 int rowMask 0; int colMask 0; int boxMask 0; bool canPlace(int row, int col, int num) { int bit 1 num; int box (row ~/ 3) * 3 (col ~/ 3); if ((rowMask bit) ! 0) return false; if ((colMask bit) ! 0) return false; if ((boxMask bit) ! 0) return false; return true; }掩码方式在“提示候选数字”场景特别有用。比如长按某个数字可以把所有不能填的格子一次性置灰。这个功能对新手用户非常友好我加到第二版里了。2.3 冲突反馈填入即时校验与 UI 联动用户填入数字的瞬间有三种反馈路径数字是否显示在格子里、格子是否标记为冲突、底部键盘是否有震动或颜色提示。我的流程是这样的点击数字键盘上的 “5” - 取当前选中格子 - 判断该格子是否固定 - 判断填入后是否冲突 - 更新单元格状态 - 通知界面刷新。这里我加了一个和很多数独 App 不太一样的设计冲突时不影响填入数字照常显示但数字和格子边框变成红色。这样做的原因是用户可能只是想临时试试某个数字如果直接拦截掉解题思路会被频繁打断。如果后续需要清理按一次擦除即可恢复。震动反馈我一开始没做后来在真机上试玩发现光靠颜色变化在户外光线下很容易漏看。加了一个 10ms 的短震动感受完全不一样。OpenHarmony 侧震动需要调用系统能力这就用到 MethodChannel 了下一节详细讲。2.4 候选数笔记Set 与九宫格热区笔记模式实现起来不难但有几个交互细节要注意。用户开启笔记模式后点击数字键不是把数字填入格子而是往 Cell.candidates 这个 Set 里添加或移除数字。画笔记时需要把 Set 里的数字分布到 3x3 的小九宫格位置。Widget buildNoteText(Setint candidates) { return GridView.count( crossAxisCount: 3, padding: EdgeInsets.all(1), childAspectRatio: 1, physics: NeverScrollableScrollPhysics(), children: List.generate(9, (index) { int num index 1; return Center( child: Text( candidates.contains(num) ? $num : , style: TextStyle(fontSize: 9, color: Colors.blueGrey), ), ); }), ); }笔记模式的坑通常出在状态切换上。我第一版只记录“全局是否处于笔记模式”没记录“每个格子是否处于笔记填写状态”。结果用户在高亮格子 A 时开了笔记模式切到格子 B 时笔记模式还开着输入直接进了 B 的候选区体验很混乱。后来改成笔记模式是一个全局开关但每个格子可以单独记录当前显示的是“填入值”还是“笔记”。更准确地说Cell.isNoteMode是格子自己的状态比如 A 处于笔记模式、B 处于正常模式点击数字键分别走不同逻辑这样才符合真实数独 App 的使用习惯。2.5 胜利判定与数字键盘联动填入数字时除了冲突判定还应该检查是否解完。完成条件是81 格全部非空且无任何冲突。我实现了一个简单的isComplete方法在每次填入后调用。全部非空这个条件很好判断遍历一次即可。由于已经培养了“冲突即时提示”的习惯正常情况下用户看到没有红色格子时棋盘已经满足解的条件。另外底部数字键盘的数字按钮我做了剩余数量提示。比如数字“5”在题面里已经有 8 个那按钮右下角会显示“剩 1 个”。这个功能需要每次棋盘变化时重新统计 1-9 的出现次数统计逻辑不复杂但体验提升很大。很多数独 App 都做了这个细节属于“不做没人提做了用户夸”的功能。3. Flutter 与 OpenHarmony 的 UI 与通信实操3.1 棋盘绘制自绘还是组合控件数独棋盘最容易想到的实现是用 Widget 嵌套9x9 个容器加边框。但实际做下来你会发现边框线粗细非常难控制。格子间每个 Widget 都有独立边框会导致相邻格子的线重叠或粗细不均。我的做法是用 CustomPainter 一次性绘制棋盘底层网格再从这个网格上叠放格子内容 Widget。第一步画整体外框、分区的 3x3 粗线、单元格细线。第二步根据格子状态绘制背景色选中格、同数字高亮格、冲突格分别用不同颜色。第三步在格子中央绘制数字或笔记候选数字。用 CustomPainter 的好处是绘制性能可控棋盘在重绘时只在需要时刷新而不是整个 Widget 树重建。坏处是点击热区要自己做命中测试。我定义了onTapDown事件根据点击坐标换算行列row (position.dy / cellSize).floor()然后更新选中状态。如果你不想用 CustomPainter也可以用 GridView.builder 每个格子的自定义容器数据刷新时局部更新。只是边框统一性要花更多心思去调。我最终在项目里两套方案都写了一遍留给后面做主题换肤时对比这里更推荐 CustomPainter 方案尤其当棋盘需要大量个性化状态时。3.2 组件通信棋盘与数字键盘的解耦方式数字填入交互最核心的通信发生在棋盘和底部数字键盘之间。我做了两个方向的通信棋盘往键盘传数据键盘往棋盘传操作指令。第一个方向用户选中棋盘上的格子后键盘要响应这个状态变化。比如选中格已经填入数字 5键盘的 5 按钮要高亮选中格处于笔记模式键盘的笔记开关状态要同步。这个用ValueNotifierSelectedInfo或ValueChanged回调就能实现。我在棋盘外层的ChangeNotifier里维护当前选中格的索引键盘层监听这个值变化。第二个方向用户点击键盘数字按钮后要往棋盘层发送“填入 5”“擦除”“切换笔记”等指令。这里我特意没用全局事件总线而是把键盘的点击事件通过回调函数传到上层页面再由上层统一操作数据模型。理由很简单同一个 App 里可能同时存在多个数独实例主菜单进入不同难度全局广播很容易串数据。回调函数加父子组件传参关系清晰调试也方便。class SudokuPage extends StatelessWidget { void _handleNumInput(int num) { viewModel.inputNumber(num); } override Widget build(BuildContext context) { return Column( children: [ Expanded( child: SudokuBoard( viewModel: viewModel, onCellTap: (index) viewModel.selectCell(index), ), ), NumberPad( viewModel: viewModel, onNumTap: _handleNumInput, onEraseTap: () viewModel.erase(), onNoteToggle: () viewModel.toggleNoteMode(), ), ], ); } }如果你习惯用 Provider 或 Riverpod这部分状态管理会更顺手直接用 context.read 取 ViewModel 即可。小项目用 ValueNotifier 和回调完全够了没必要为了框架而上框架。3.3 接入鸿蒙原生能力MethodChannel 与 EventChannel这里重点来了Flutter 在 OpenHarmony 上跑底层渲染和系统能力都通过自有引擎提供但访问系统 API 时还是得走插件通道。我在这个项目里用到了 MethodChannelDart 调用 Native和 EventChannelNative 持续给 Dart 推事件各派上一个用场。MethodChannel 用在震动反馈上。Dart 侧定义通道名调用 invokeMethod然后 ArkTS 侧通过 ohos.plugin.phone 的接口实现震动。static const platform MethodChannel(com.example.sudoku/haptics); Futurevoid vibrate() async { try { await platform.invokeMethod(vibrate, {durationMs: 10}); } on PlatformException catch (e) { debugPrint(vibrate failed: ${e.message}); } }ArkTS 侧注册插件时要在 module.json5 里声明依赖并在入口文件里 mappingFlutterPlugin。这个过程和 Android 上写 FlutterPlugin 类似但有些 API 名称不一样比如 Android 的BinaryMessenger在鸿蒙侧是直接通过生成的Plugin类来接收 MethodCall。第一次写容易找不到方法名我的经验是每写一个插件点先在 ArkTS 侧用日志打印收到的 method确认调用链通再写具体逻辑。EventChannel 用在游戏计时器上。数独有计时统计从鸿蒙侧定时向 Dart 推倒计时数字。真机上测试 EventChannel 时我遇到一个很隐蔽的问题如果 Dart 侧receiveBroadcastStream的监听器被垃圾回收事件会静默断开。所以一定要在 StatefulWidget 的 initState 里保存 StreamSubscription 引用而不是仅仅在 method 里调用后就丢弃。late StreamSubscription _timerSub; void _setupTimerChannel() { _timerSub EventChannel(com.example.sudoku/timer) .receiveBroadcastStream() .listen((event) { _updateTimer(event as int); }); } override void dispose() { _timerSub.cancel(); super.dispose(); }3.4 PlatformView 与文本输入焦点数独 App 里涉及原生文本输入的场景不多但用户昵称输入、存档命名还是用到了。在 OpenHarmony 上加载 Flutter 侧控件时默认的文本输入框直接生效但如果要嵌入原生控件就得走 PlatformView。我的体会是在鸿蒙上用 PlatformView一定要关注焦点切换。棋盘界面切到文本输入时如果 PlatformView 抢占了 Touch 事件棋盘点击就会失效。这块我在项目里是真踩过坑打开命名对话框后底部数字键盘第一次点击没有响应要再点一次才生效。排查后发现是 PlatformView 的触摸事件回调里没有正确释放消费标记。解决方法是监听FocusNode变化失去焦点后清空 then 回调里的事件消费状态。如果你的团队对 ArkTS 侧不熟可以先不碰 PlatformView等插件稳定了再替换。数独这种棋盘布局用纯 Flutter 绘制完全足够PlatformView 更多是给那些必须直接用系统 WebView、地图的场景准备的。3.5 通过 XTS 认证需要注意的权限和组件声明OpenHarmony 设备如果要上架应用市场或做系统认证经常要过 XTS 测试。我在数字填入功能开发完后跑过一次 XTS 认证流程有几个点需要提前准备。第一是权限声明。震动功能需要在 module.json5 里声明ohos.permission.VIBRATE如果没有声明真机上调用震动 API 不会报错但没有任何效果而且日志里不一定有明显的错误提示。这类“静默失败”特别坑排查半天发现只是权限列表漏了。第二是应用图标和标签。XTS 对应用的label、icon、versionCode有基本格式校验如果你直接用默认模板跑测试部分用例会红。不一定影响功能但认证结果会扣分。第三是组件声明完整性。所有使用到的 ability 要在 module.json5 里注册尤其是用到了后台任务的场景。数独本来不涉及后台任务但我有个“暂停计时并缓存棋盘”的功能用到了短时任务接口必须注册对应的 ability 声明。建议在开发阶段就把 module.json5 的权限声明当作和代码一样重要来维护别等认证前集中补到时候根本不知道哪个权限是哪个功能用的。4. 常见问题排查与避坑技巧4.1 真机上 Flutter 布局偏移安全区与刘海屏OpenHarmony 不同设备的屏幕比例差异比较大有平板、有带圆角和挖孔的竖屏手机。我们开发的数独棋盘如果直接把 9x9 格子铺满全宽遇到大圆角机型时最下面一行按钮会被截掉。解决方法是利用 Flutter 的SafeArea包裹棋盘底部再结合MediaQuery.padding动态计算格子的实际宽高。注意鸿蒙侧的状态栏参数有时和 Android 不完全一致获取到的 padding 值需要手动处理。我在代码里统一做了一个GameInsets类专门负责处理安全区适配方便不同机型快速调整。另一个视觉坑是 1:1 方格的绘制。如果屏幕宽度是奇数像素9 列均分后会有半像素残差横线看起来发虚。我在 Painter 里处理坐标时统一用(宽 / 9).floorToDouble()作为 cellSize宁可最右侧留白一点也不要出现模糊线条。4.2 打包时报 AssertionError 的排查思路我在项目中期打包时遇到过Could not close类异常最后定位是和 OpenHarmony 的 AOT 编译缓存有关。这个问题在开发阶段不常出现但当你改了 Dart 层代码后直接执行 release 打包有时旧缓存没有清理干净就会在编译期抛出奇怪的异常。我的排查步骤是清理整个项目的构建产物删除 ohos 模块下的 build 目录以及 Flutter 临时文件。重新拉取依赖执行 flutter pub get 确保依赖版本统一。单独编译验证先跑 debug 模式确认能正常安装启动再切 release。如果 release 仍然报错我会把日志里出现的第一个异常栈打印出来重点看是 Dart 编译失败还是 Native 工程配置失败。多数情况下 Docker 构建环境和本地环境不一致也会引发类似问题检查一下 OpenHarmony SDK 版本是否匹配你的 Flutter engine 分支。我遇到过好多次“昨天的代码今天突然打不了包”最后发现是 DevEco Studio 自动升级了 SDK 版本Flutter engine 的适配层跟不上。4.3 数独点击交互的常见体验问题数字填入初版完成后的第一轮自测我发现几个交互上的毛病这里列出来供各位参考。误触问题。格子和底部键盘距离太近用户快速点击棋盘底部时很容易误触到键盘区。解决方式是键盘区增加一个约 40 逻辑像素的留白或者把键盘按钮的最小触摸区域回调扩大。连续双击问题。快速双击同一个格子时选中和取消选中状态会交替触发导致用户以为“点了一下没反应”。我加了一个切换防抖同一格子在 200ms 内重复点击不会取消选中。固定数字误编辑问题。如果不小心点到了题面的固定数字键盘输入却改不了用户会以为卡住了。我做了高亮当前选中固定数字的反馈并且弹出轻量的 toast 提示“题面数字不可修改”。4.4 数独热门实现中的算法优化与扩展写完基本数字填入功能后我又补了“提示下一个可填数”功能方便新手用户。这个功能用到的是候选数剪枝思路遍历所有空格把每个空格的行、列、宫已有数字合并取缺失的数字作为候选集取候选最少的格子为提示目标。({int row, int col, int num})? getHint() { int bestRow -1, bestCol -1, bestCount 10, bestNum 0; for (int r 0; r 9; r) { for (int c 0; c 9; c) { if (grid[r][c].value ! 0) continue; Setint used {}; for (int i 0; i 9; i) { if (grid[r][i].value ! 0) used.add(grid[r][i].value); if (grid[i][c].value ! 0) used.add(grid[i][c].value); } int boxRow (r ~/ 3) * 3, boxCol (c ~/ 3) * 3; for (int i boxRow; i boxRow 3; i) { for (int j boxCol; j boxCol 3; j) { if (grid[i][j].value ! 0) used.add(grid[i][j].value); } } Setint candidates {}; for (int n 1; n 9; n) { if (!used.contains(n)) candidates.add(n); } if (candidates.length bestCount) { bestCount candidates.length; bestRow r; bestCol c; bestNum candidates.first; } } } if (bestRow -1) return null; return (row: bestRow, col: bestCol, num: bestNum); }这个算法不是完整的解数独求全解但作为“给用户指一步”已经完全够用。后面如果你想把数独游戏做成关卡模式只需要再补一个“逐步回收数字生成题面”的生成器再配合多解验证就是完整的数独关卡系统了。这类算法网上资料很多核心思路是让你一步步掌握递归回溯。5. 真机调试环境与开发工具配置总结这里多说一句数独这种游戏集合类 App 的开发调试配置直接决定幸福感。我自己试了很多组合最终稳定下来的方案是DevEco Studio 负责 OpenHarmony 工程和日志VS Code 写 Dart 代码双开不同终端。Flutter attach 方式连真机调试Dart 侧的改动可以热重载不需要每次重新安装应用。有一种情况需要特别注意如果你用模拟器OpenHarmony 模拟器的按键映射和真机不完全一样震动反馈无效。所以震动、声音、提交 XTS 这类系统能力务必在真机上测。在项目早期我还遇到一个很头疼的问题Flutter 的 hot reload 在 OpenHarmony 上有时不生效Hot restart 倒是稳稳的。后来发现是因为部分状态量被保存在了原生侧改动 Dart 侧不会同步刷新。解决办法是尽量把状态全部放到 Dart 侧原生侧只负责能力调用不负责业务状态存储。这个原则对任何跨端项目都通用。另外提一下版本管理。Flutter for OpenHarmony 的分支迭代很快不同分支对 impeller、渲染管线等特性支持不一样。建议在项目初始化时就把 Flutter SDK 版本、OpenHarmony SDK 版本锁死写在 README 里。6. 写在最后从数字填入到游戏集合的扩展思考数独的数字填入模块做完之后我的感觉是这个功能看起来简单但每一步都和跨端适配、状态管理、系统能力调用绑在一起。真正有价值的反而不是“怎么写一个数独”而是“如何在一个非标准平台上把 Flutter 应用做得像原生一样顺手”。如果你也在做 Flutter for OpenHarmony 的项目建议先找这种小功能模块练手踩过一遍坑之后再去看复杂页面就有信心多了。我个人在实际操作中还有一个经验想分享做游戏集合类 App一定要在最开始就规划好模块之间的跳转路由和统一的 UI 主题。我一开始只想着做完数独再说结果后面加华容道时主题颜色、字体、按钮样式全部要返工。现在我把主题抽成了一个独立的 ThemeConfig数独模块只是其中一个消费方后面加新游戏会快很多。最后如果这篇文章对你有帮助欢迎在实际开发中对照验证。OpenHarmony 生态还在快速演进过几个月你再来看可能在插件适配和构建工具优化上又会有新变化。探索过程中的那些能写成文的经验记得回头补充给社区。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进