
上一家做休闲游戏的工作室里产品经理拿着竞品截图来找我“鸿蒙HarmonyOS这边的用户量涨得很快我们这套小游戏能不能也上”团队里没人写过一行 ArkTS而我们的 Flutter 代码库里正好躺着一版状态不错的“连连看”。用 Flutter 跨平台方案去啃鸿蒙这块新市场就成了我当时最想验证的事。这篇文章不聊虚的直接把我从环境搭建到 HAP 打包、从棋盘建模到路径判定算法的完整过程拆给你看适合手里有 Flutter 经验、正准备往鸿蒙迁移的产品或团队作为参考。1. 为什么是 Flutter鸿蒙上做连连看我对比过几条路1.1 团队现状逼出来的选型现有 Flutter 资产怎么迁移先交代背景。我们的游戏库里“连连看”是用 Flutter 写的纯 Dart 逻辑加上自绘控件没有依赖重量级原生 SDK。当时摆在我面前的无非四条路用鸿蒙原生 ArkTS 重写、用 React Native 套一层、用 Tauri 2 走 Web 容器、继续用 Flutter 并打通鸿蒙构建链路。原生重写最“正统”但时间成本不允许。连连看看起来简单实际上棋盘生成、消除判定、无解洗牌、动画状态机这些逻辑零零总总也有两千多行代码用 ArkTS 重写一遍至少一周写完还得重新测边界情况。再说以后还有别的游戏要搬每次都重写团队会被拖死。RN 那边的情况是动态化交给 JS 引擎之后高频点击和动画的桥接开销在小游戏场景里体现得很明显而且 RN 在鸿蒙上的适配进度当时并不比 Flutter 乐观。Tauri 2 在热词里常见那个方向用 WebView 渲染和 Flutter 自绘是两种路径。鸿蒙对 WebView 的管控比一般系统严格小游戏这种对帧率和触摸反馈敏感的场景走 Web 容器下限低、上限也低调试起来反而麻烦。最后兜兜转转还是 Flutter——它本来就是跨平台方案里最适合“自绘游戏型 UI”的那一类。1.2 跨平台候选方案对比我给当时的选型会拉了一张对比表核心考量其实就三列代码复用率、动画性能、鸿蒙适配链路成熟度。方案代码复用小游戏动画表现鸿蒙适配链路我当时的态度ArkTS 原生全部重写好官方一级支持项目紧不做React Native逻辑要桥接一般卡顿风险社区适配暂缓Tauri 2Web 栈复用一般适配中观望FlutterDart 全量复用好自绘渲染社区厂商共建选它表格里没写的是“团队熟悉度”。我们组四个人都是 Flutter 出身如果为了鸿蒙而放弃 Flutter等于让所有人从头学一套新 UI 框架风险远大于收益。学习成本不该被低估。1.3 连连看这个题材对 Flutter 渲染管线的天然契合为什么非要提连连看因为这个游戏的核心界面非常“规则”——它是一张网格棋盘每个格子是一张小图标交互无非是点选、高亮、连线、消除。这种元素密集、布局规整、动画路径明确的界面正好是 Flutter 自绘 UI 的舒适区。你不需要复杂的原生地图引擎也不需要接入视频纹理一套CustomPaint加几个AnimationController就能把所有视觉效果管起来。从跨平台的角度看这也是最理想的迁移对象UI 全部由 Flutter 绘制不依赖任何平台控件鸿蒙侧只需要提供一个能跑 Flutter 引擎的窗口。也就是说只要 Flutter 引擎能在鸿蒙上跑起来游戏本体代码基本不用改。这就是我当时赌的方向。2. 鸿蒙版 Flutter 工程从 0 到 1工具链搭建与真机联动2.1 DevEco Studio 与 HarmonyOS SDK 的版本配对开工第一步是装环境。鸿蒙应用的开发 IDE 是 DevEco Studio它类似 Android Studio集成了 HarmonyOS SDK、签名工具和 HAP 打包链。这里第一个坑就是版本配对Flutter 鸿蒙适配链路对 DevEco Studio 和 HarmonyOS SDK 的版本是有要求的。我当时用的组合是某个 Flutter 适配分支对应的 DevEco Studio 和配套 SDK版本号未必适合现在的读者所以我给个可复现的思路先确定 Flutter 鸿蒙适配 SDK 的分支说明再按它的要求装对应 DevEco 版本别贪新。嘴硬装上最新版 DevEco 后构建插件没适配反而报“SDK component missing”最后卸了重装才消停。提示DevEco Studio 装好后首次启动会让你登录华为账号这是为本地签名服务的建议提前注册好。2.2 给 Flutter 工程接上鸿蒙构建目标环境就位后关键一步是让 Flutter 工程具备鸿蒙构建能力。当前主流的做法是使用社区维护的 Flutter for HarmonyOS 适配 SDK。把这个 SDK 的flutter命令切到 PATH 里再执行flutter doctor -v此时应该能看到 dart 编译器和 Flutter 引擎都指向了适配分支。接着在项目目录下执行flutter create --platforms ohos .是的适配版 SDK 的create命令支持ohos平台会自动生成一个ohos/目录里面是鸿蒙原生工程结构。这个目录提供了 Flutter 模块的宿主窗口后续签名、构建 HAP 都在这层做。如果你是已有工程想迁移不用重新创建往现有工程里补一个ohos/外壳目录也行。我这里图省事直接重新生成再拷lib/和assets/过去。2.3 签名、真机调试与无线调试代码生成出来下一步就是真机运行。先把鸿蒙手机打开开发者模式在系统设置里狂点版本号进入开发者选项后开启 USB 调试。鸿蒙用的调试命令是hdc和 adb 类似的工具链hdc list targets看到设备编号后可以一键从 DevEco 跑起工程。不过 USB 线在调试时老是被队友拔掉后来我改用无线调试。先hdc tconn 192.168.x.x:5555建立 TCP 连接再跑到hdc list targets确认设备在线和 Android 的adb connect一个套路。热词里有人问“鸿蒙 4.2 开启无线调试”其实就是这一套流程。本地调试用 DevEco 的自动签名就行登录账号后 IDE 会自动生成调试证书。这块别自己折腾手动签名踩过的坑都是泪。2.4 从 APK 思维切换到 HAP 思维跑通真机后你会很快发现打包逻辑也变了。Android 的构建产物是 APK鸿蒙这里是 HAPHuawei Ability Package一个应用可能包含多个 HAP最终上架还要合成 APP。对于连环看这种单页面应用一个 entry 类型的 HAP 就够了。这个思维切换挺重要。你在 Flutter 工程里配的 Android 构建依赖、minSdk 版本、gradle 插件等等到了鸿蒙侧大部分都不起作用。鸿蒙侧构建主要由 DevEco 的 hvigor 工具链负责Flutter 只是作为引擎依赖被打进去。所以遇到构建报错先分清到底是 Flutter 引擎的问题还是鸿蒙原生工程的问题别一上来就瞎改 gradle。3. 连连看的棋盘与配对核心玩法背后的数据结构与状态管理3.1 棋盘模型二维数组加一圈“虚拟空格”把环境跑通之后真正的游戏逻辑才刚开始。连连看的基础是一张网格棋盘常见规模是 8 行 × 10 列也就是 80 个格子需要 40 对相同图案。我用的数据结构是一个二维数组class GameBoard { final int rows; final int cols; late ListListint grid; }grid[row][col]里存的是图案 ID0 表示该位置为空已消除大于 0 表示对应图案。这里有个非常关键的细节棋盘数组要在四个方向各扩一圈作为虚拟空区域。为什么因为连连看的连接规则允许路径从棋盘边缘“绕出去”。比如棋盘最左边一列的两个格子它们之间的最短路径很可能要经过棋盘左侧外部的空白区域。如果不扩一圈代码里每次越界判断都写得头大。扩一圈之后虚拟区域天然就是 0空路径扫描函数一视同仁处理逻辑干净很多。具体实现上grid的实际长为rows 2宽为cols 2可见范围是[1, rows] × [1, cols]。索引 0 和最后一列/行都是空地。3.2 成对图案的生成与洗牌棋盘模型定好后初始化就是填格子。流程分两步先把图案 ID 按对填满再打乱。Listint tiles []; for (int id 1; id pairCount; id) { tiles.add(id); tiles.add(id); } tiles.shuffle(); // Fisher-Yates 洗牌如果是 80 格图案种类通常是 20 种每种出现 4 次比两两配对更耐玩。我先做了两两配对后面调难度时改成“每种图案出现 N 次且总数为偶数”的通用逻辑。打乱之后要重新填入网格。这里有个容易被忽视的点如果直接tiles.shuffle()然后平铺进棋盘有概率出现一种局面——棋盘上某两个相同图案被隔死甚至整局无解。所以洗牌后必须做一次可解性校验稍后在算法章节细说。3.3 状态管理选型我为什么没用 Cubit讲完数据模型聊点状态管理。热词里不少人搜flutter cubit、flutter 组件通信说明大家在纠结状态管理方案。在我的连连看项目里最终选择是GameModel ChangeNotifier setState没有上 flutter_bloc 或 Cubit。原因很简单小游戏的状态是“密集但内聚”的无非就是棋盘数据、选中状态、分数、剩余步数这几个变量。bloc 那套事件驱动的写法在业务复杂时很好用但在连连看里会有种“杀鸡用牛刀”的繁琐感——每次点击都要 dispatch 一个事件再写 reducer 去更新状态回头调试还得翻两层。我用的是这种class GameModel extends ChangeNotifier { GameBoard board; Offset? selected; int score 0; void selectCell(int row, int col) { if (selected null) { selected Offset(row, col); } else { // 尝试消除失败则切换选中 } notifyListeners(); } }界面层通过AnimatedBuilder监听 model局部重建棋盘区域性能也不错。如果你的项目后续要在这个游戏壳里加各种玩法大厅、成就系统再考虑把 Cubit 引进来也不迟。3.4 点击交互、选中态、消除与计分交互流程很直接第一次点击某个非空格子把它标记为选中状态格子上加高亮边框第二次点击另一个格子先判断两个格子图案是否相同再走路径判定能连通就消除并加分不能就连上选中的新格子。这里有个产品层面的细节连连看的老玩家很在意“消错会不会扣步数”。我在这一步选择不计负分只加分并且不做限时让新手也能顺畅上手。计分规则是基础分加连击加成连消两次以上每连一次多乘 0.5 倍。用两个计数器就能实现不复杂。动画方面用的是AnimationController消除前先做一个 200ms 的缩放反馈然后才真正把格子置 0。视觉上有一个“选中 → 缩小 → 消失”的顺滑过程比瞬间消失体感好太多。4. 消除算法的三种连接判定从直线到两折的路径计算细节4.1 零折与一折连接最朴素的矩形扫描连连看的核心算法是判断两个同图案格子之间是否存在“拐弯不超过两次、且路径上所有格子均为空”的连线。这也是最容易写错的地方我建议从最简单的情况一层层往上加。先定义两个基础判定函数// 判断同一行 row 上从 colA 到 colB 之间的格子是否全部为空 bool _lineXClear(int row, int colA, int colB) { int start min(colA, colB); int end max(colA, colB); for (int c start; c end; c) { if (_grid[row][c] ! 0) return false; } return true; } // 判断同一列 col 上从 rowA 到 rowB 之间的格子是否全部为空 bool _lineYClear(int col, int rowA, int rowB) { int start min(rowA, rowB); int end max(rowA, rowB); for (int r start; r end; r) { if (_grid[r][col] ! 0) return false; } return true; }注意这两个函数判断的是“整段全空”所以如果我是拿它检查从起点到终点整条直线就得到零折连接if (pa.row pb.row _lineXClear(pa.row, pa.col, pb.col)) { return true; } if (pa.col pb.col _lineYClear(pa.col, pa.row, pb.row)) { return true; }一折连接也很直观在两个点组成的矩形里只有两个候选拐点分别是(pa.row, pb.col)和(pb.row, pa.col)。判断逻辑是拐点本身为空并且起点到拐点、拐点到终点两条直线都通畅。4.2 两折连接的穷举思路三条直线段与中间点两折是连连看最难的部分。它的路径由三段直线组成有两个拐点整体看起来像一个“Z”或“U”形。穷举思路其实不复杂把中间那条直线段的行或列遍历一遍看看能不能把起点和终点接起来。以“水平中继”为例我遍历候选行rfor (int r 0; r maxRow; r) { // 中间行上起点列到终点列之间必须畅通 if (!_lineXClear(r, pa.col, pb.col)) continue; // 两个拐点本身必须是空位 if (!_isEmpty(r, pa.col) || !_isEmpty(r, pb.col)) continue; // 起点到左拐点、终点到右拐点的竖向直线必须畅通 if (!_lineYClear(pa.col, pa.row, r)) continue; if (!_lineYClear(pb.col, pb.row, r)) continue; return true; }“垂直中继”同理把行列交换再遍历一遍。这里我要强调一个隐藏但尤为重要的细节起止点本身不要参与“空位”检查中间点才要检查为空。因为起点和终点站的是图案本身它俩不是空位。如果函数不小心把起点也要求为空那算法永远不通。另外因为棋盘扩了一圈虚拟空位遍历范围自然覆盖到 0 和 maxRow这让棋子能从棋盘边缘绕过和经典玩法完全一致。两折判定其实覆盖了零折和一折的情况你完全可以用这段逻辑统一收口但显式写出零折/一折判定能让代码意图更清晰也方便以后加“三折模式”之类的变体玩法。4.3 死局检测与自动洗牌不放跑一局不可解有了canConnect之后死局检测就非常简单遍历棋盘上所有剩余图案的任意两格只要找到任何一对能连通的说明还没死局。bool hasAvailableMove() { for (int r 1; r rows; r) { for (int c 1; c cols; c) { if (_grid[r][c] 0) continue; for (int r2 r; r2 rows; r2) { for (int c2 c; c2 cols; c2) { if (_grid[r2][c2] 0) continue; if (_grid[r][c] ! _grid[r2][c2]) continue; if (r r2 c c2) continue; if (_canConnect(Offset(r, c), Offset(r2, c2))) { return true; } } } } } return false; }我在每次玩家消除后调用这个方法如果返回 false就触发洗牌逻辑。洗牌并不是简单地调shuffle()再平铺因为洗出来可能还是死局。稳妥的做法是循环洗最多洗 50 次每次都重新校验hasAvailableMove如果 50 次后依然无解就直接把两块图案的位置做交换通常交换两三个格子就能打破僵局。这个“保底交换”听着土但实际测试里极少触发因为只要你在地图里保证每种图案数量是偶数、每次洗牌都是重新随机分布大面积死局概率本身就很低。4.4 所有判定都基于同一套棋盘视图这里想提一个工程上的建议canConnect这套逻辑不要在 UI 层写到一半再挪到逻辑层。我的做法是把棋盘、路径判定、死局检测全部封装在GameModel里UI 只做一件事调用model.trySelect(row, col)。好处很直接——将来如果你要把这个连连看逻辑搬到别的项目或者写单元测试不用启动 Flutter 引擎就能跑通整个核心算法。我在测试里专门建了一个纯 Dart 测试用例模拟了十几组典型场景包括边界外绕行、双拐点绕过障碍、死局触发洗牌全部在命令行秒跑。这一步对交付质量的提升远超你想象。5. 鸿蒙平台适配Impeller、EventChannel、插件这些绕不开的坎5.1 Impeller 在鸿蒙上的渲染表现与兼容开关Flutter 3.x 之后Impeller 渲染引擎逐渐成为默认热词里也一直有人搜flutter impeller。鸿蒙适配链路上Impeller 的支持情况取决于你用的引擎版本。我个人实测下来的经验是如果游戏画面出现偶发闪烁或者某些机型首帧异常先用命令行关掉 Impeller 验证一下问题是否消失flutter run --no-enable-impeller如果关掉就正常了说明问题大概率出在 Impeller 后端在这台鸿蒙设备上的兼容度而不是你业务代码的问题。遇到这种情况要么锁定 SDK 版本等修复要么在构建配置里显式禁用 Impeller。对于连连看这种以静态网格和轻量动画为主的小游戏Impeller 带来的渲染红利其实有限关掉换稳定性完全不亏。5.2 EventChannel 与 MethodChannel游戏和鸿蒙系统怎么对话Flutter 和鸿蒙系统通信有两条通道经常有人混。简单区分MethodChannel 是 Dart 主动调鸿蒙EventChannel 是鸿蒙主动往 Dart 推事件。我的连连看里有一个场景需要用到原生能力消除时手机震动反馈。这里的震动是 Dart 主动发起的调用走 MethodChannelstatic const MethodChannel _hapticChannel MethodChannel(com.example.game/haptic); Futurevoid triggerHaptic() async { try { await _hapticChannel.invokeMethod(trigger); } on PlatformException catch (e) { debugPrint(震动调用失败: $e); } }鸿蒙侧收到trigger方法调用后调用系统的振动接口。在鸿蒙原生工程里这通常要在 Flutter 模块的入口类里注册对应的 MethodChannel 处理器。EventChannel 的场景则是系统往 Dart 推状态比如玩家切后台再切回来我需要暂停计时器网络状态变化时我要提示用户“游戏请求异常”。Flutter 侧通过receiveBroadcastStream监听static const EventChannel _lifecycleChannel EventChannel(com.example.game/lifecycle); StreamSubscription? _sub; void listenSystemEvents() { _sub _lifecycleChannel .receiveBroadcastStream() .listen((event) { if (event onPause) _model.pauseTimer(); if (event onResume) _model.resumeTimer(); }); }原生侧的鸿蒙事件源比如页面生命周期回调只要拿到事件就通过 EventSink 发过来。两边通道名必须完全一致否则静默失败而且日志很容易让人忽视。5.3 PlatformView 和“能不用就别用”的插件原则PlatformView 是把原生控件嵌进 Flutter 页面里热词里的flutter platformview就是这块。我在连连看里刻意绕开了它。原因很简单游戏的网格、按钮、动画全部是 Flutter 自绘嵌一个原生控件进去反而会带来焦点竞争、触摸事件穿透、性能衰减一堆问题。如果非要嵌入原生广告视图建议把广告单独放在一个页面或浮动区域和游戏主场景隔离。插件方面我的处理原则更明确能不依赖原生插件的就不依赖。连连看需要的震动、音效、存储等优先找已有鸿蒙实现的 Flutter 插件。第三方插件的鸿蒙适配流程和热词里那个flutter 平台插件 okta 适配鸿蒙流程本质上是一回事保留 Dart 侧接口不变把插件里的 Android/iOS 原生实现翻译成鸿蒙实现再在 pub 的插件描述里声明 ohos 平台支持。纯 Dart 插件基本上开箱即用比如我用来管理音效计时器的audioplayers如果找不到鸿蒙实现那就退一步用系统声音或干脆不做音效。小游戏交付要懂得取舍。5.4 第三方登录、支付与上架前的前置准备如果游戏要上鸿蒙应用市场支付和账号体系大概率绕不开华为自家的 SDK。这类 SDK 的 Flutter 插件适配成熟度参差不齐建议提前一个月做技术验证。我在交付阶段因为支付 SDK 的鸿蒙版适配关系差点没赶上提审。后来是把支付页面退回成 Web 版临时方案才保住上线窗口。所以给后来者的建议是先列清游戏需要的所有原生能力清单逐项确认 Flutter 插件有没有鸿蒙实现。没有的尽早决定是自己补实现还是砍需求。这比开发到一半再发现“震动没法调”要舒服得多。6. 真机验证、HAP 打包与交付阶段复盘6.1 真机调试里常见的构建失败与解决办法整个开发过程中最耗时间的不是游戏逻辑而是构建链的挣扎。我遇到的第一类错误和 Gradle 插件相关报错带着applying flutters main gradle plugin imperatively这类字样。根源是 Flutter 模块的 Android 侧构建脚本在新旧 Gradle 切换时插件应用方式冲突。鸿蒙侧构建虽然不走纯 gradle但插件依赖解析偶尔会带上 Android 残留的配置所以出问题时先把 Flutter 工程根目录下与 Android 相关的旧配置清一遍会有奇效。第二类错误是 Java 构建过程中途挂掉报could not close i...或者各种.gradle临时文件写入失败。排查到最后发现是桌面端杀毒软件锁了项目缓存目录。清理build/和.gradle/文件夹后在杀毒软件里把项目目录加入白名单重新构建就过了。第三类错误是最萌的DevEco 构建提示签名文件找不到。原因是 DevEco 的自动签名在工程从 USB 调试切换到无线调试时会偶发丢失设备关联。这时候重新在 IDE 里跑一次签名流程即可不用重新申请证书。6.2 性能优化帧率、内存与首帧启动开发期我在 Debug 模式下跑棋盘点击和消除动画偶尔掉帧一开始还以为是鸿蒙渲染引擎的问题。后来用 Profile 模式跑了一遍发现 Debug 和 Profile 的帧率差异巨大问题基本全在 Dart 虚拟机的调试开销上和平台无关。优化集中做了三件事。第一格子视图从“每个格子一个独立 Widget”降级为“整个棋盘一个 CustomPaint”用 Canvas 批量绘制。80 个格子真不算多但动画频繁时Widget 重建开销积少成多。第二动画使用AnimationController.unbounded的会自动销毁消除完立即清理避免后台堆叠。第三首帧启动时先加载棋盘数据再开启动画避免首帧同时做 decode 和 layout 导致白屏。上线前用hdc抓了一次性能数据持续帧率稳定在 120fps 附近的机型不少最差的老机器也能维持 60 帧。说实话这个成绩比我在 Android 中端机上跑同样代码还稳鸿蒙的后台调度相对克制是一个加分项。6.3 HAP 打包、签名与应用市场的交付细节发布包和调试包是两条签名字链。调试用 DevEco 自动签名发布用真正的发布证书。我踩过一次本地调试跑得好好的打包 release HAP 装到另一台没登录过的机器上直接提示签名异常。原因是发布包需要用发布证书签名而不能用调试证书。DevEco 里打包 HAP 的入口很直观但要注意 HAP 的版本号要和 config.json 里的保持一致。上架前还要在开发者后台配置权限声明我的连连看用到了震动、网络状态两大权限。隐私政策里需要说清楚权限用途这部分别糊弄。上架材料里的应用图标、截图、简介比 Android 市场要求更细致。尤其是鸿蒙商店对“应用内更新”和“用户隐私弹窗”有明确的审核关注点建议提审前先在真机上完整跑一遍隐私弹窗链路。6.4 复盘这套流程对后续鸿蒙产品意味着什么最后做个复盘。这次鸿蒙版连连看核心代码复用率大约在 90% 以上真正为鸿蒙额外写的代码主要是平台通道、打包配置和适配测试。整个周期从环境搭建到提审差不多两周完全在预期内。有个小技巧分享鸿蒙开发者后台支持“同款应用多端发布”如果未来要做折叠屏或平板适配Flutter 的响应式布局能在较少改动的范围内覆盖。但前提是棋盘 UI 在宽屏上要设计成居中画布模式别直接用满屏拉伸否则格子变成矩形游戏手感会很怪。我实际做的时候特意把棋盘最大尺寸限制为设计稿里的逻辑分辨率两侧多余空间用背景填充。这样一来手机、平板、甚至横屏下都能保持正方形的格子比例。这条经验比任何架构层面的优化都更直接地提升玩家体验。这次的经历也让我对“Flutter 跨平台鸿蒙开发”有了更实在的认知它说不上零成本但确实是一条能让小游戏快速触达鸿蒙用户的高性价比路线。如果你手里也有一个 Flutter 写好的休闲玩法不妨照着这个流程试一把。先把环境跑通再把一台真机拿到手后面的事情远没有想象中那么难。