1. 为什么我会在 OpenHarmony 上用 Flutter 写一整个 2048
这事得从一次真实的开发需求说起。当时团队接了一个 OpenHarmony 设备上的轻量应用项目,要求能在支持 OpenHarmony 的平板、开发板上流畅跑起来,还要兼顾后续跨端复用。大家第一反应当然是 DevEco Studio 配 ArkTS 原生开发,但评估下来有个尴尬的问题:原生 ArkTS 生态虽然进展快,可团队里几个核心开发之前都是 Flutter 栈的,现学 ArkTS 的成本不低,且后续如果还要出 Android、iOS 版本,等于三套代码三套维护。
于是我们把目光放在了 Flutter 的 OpenHarmony 分支上。Flutter 作为跨端 UI 框架,在 OpenHarmony 上的适配已经走过了"能跑 hello world"到"能承载完整应用"的阶段,社区里也有不少设备厂商在推这个方案。而选 2048 这个游戏作为实战项目,是我刻意为之的:它看起来简单,但麻雀虽小五脏俱全——有经典的滑动手势识别,有纯逻辑的棋盘合并算法,有状态管理,有动画反馈,有分数结算,几乎覆盖了一个 App 从交互到 UI 刷新再到状态流转的全过程。用 2048 把 Flutter + OpenHarmony 的链路全部趟一遍,比做一百个 Demo 页面都管用。
这篇文章我会直接拆解整个项目的落地过程:环境怎么配、棋盘模型怎么设计、滑动合并算法怎么写得又短又稳、手势和动画怎么配合、以及我在 OpenHarmony 真机调试时踩过哪些 Flutter 特有的坑。目标读者是两类人:一类是想在 OpenHarmony 上试水 Flutter 的移动端开发者,另一类是已经会 Flutter 但想找一个"小而完整"的项目练手的朋友。2048 这个题材再好不过——游戏逻辑不复杂,但足够让你把状态管理、手势识别、动画过渡、平台适配这几个关键环节全部串起来。
2. 环境准备:Flutter 跑在 OpenHarmony 上的前置条件与工程初始化
2.1 OpenHarmony 侧 Flutter 支持现状
先说清楚一个事实:OpenHarmony 不是 Android,Flutter 官方主线目前并不直接支持 OpenHarmony 作为 target platform。要在 OpenHarmony 上跑 Flutter 应用,需要用到社区维护的Flutter for OpenHarmony分支(一般叫 flutter_flutter,仓库里能看到 ohos 相关的目录和产物)。这个分支由多方共建,华为的开源社区和第三方开发者都在持续跟进,版本节奏大体跟随 Flutter 主分支,但会落后几个小版本。
实际操作时必须注意版本配对:OpenHarmony SDK 版本、Flutter 分支版本、DevEco Studio 版本三者需要匹配。我最初随手拉了最新 Flutter stable,结果编译时直接报 API 不兼容,后来换成官方推荐的 ohos 分支对应 tag 才跑通。建议直接看 flutter_flutter 仓库的 README,里面会写明当前支持的 OpenHarmony API 版本范围,别自作主张用最新版。
2.2 开发环境组成清单
我最终跑通 2048 项目的环境如下,可以参考:
| 组件 | 版本/配置 | 备注 |
|---|---|---|
| 操作系统 | Windows 11 / Ubuntu 22.04 均可 | 我用了 Ubuntu 22.04 为主开发机 |
| DevEco Studio | 5.0.3 Release | OpenHarmony 官方 IDE,用于签名、打包、真机调试 |
| OpenHarmony SDK | API 12 | 对应 OHOS 5.0 分支 |
| Flutter for OpenHarmony | 3.7.x ohos 分支 | 基于 Flutter 3.7 打的补丁 |
| 目标设备 | OpenHarmony 5.0 开发板 / 平板 | 支持触摸屏即可 |
安装 Flutter 分支时,建议单独设置一个环境变量目录,不要和 Android 的 Flutter 混用,避免切换 channel 时把 SDK 弄乱。我当时的做法是直接把 flutter_flutter clone 到~/flutter-ohos,然后在终端里按需export PATH=~/flutter-ohos/bin:$PATH。
2.3 创建项目并接入 OpenHarmony 平台
Flutter 创建项目的命令和普通 Flutter 一样,只是需要额外生成 ohos 目录:
flutter create --platforms=ohos game_2048注意--platforms=ohos这个选项需要在 ohos 分支下才存在。执行完会生成标准的lib/目录,外加ohos/工程目录。ohos/目录本质上是一个 OpenHarmony 原生工程壳子,通过 plugin 的方式把 Flutter 引擎载入。到这里先别急着写业务代码,第一步是跑通空工程:
flutter run -d <device-id>设备列表用flutter devices查看,OpenHarmony 设备连接后通常会显示为OpenHarmony类型的设备。如果你发现设备连上了但 flutter 不识别,多半是 hdc(OpenHarmony 的调试工具,类似 adb)没有正确配置到 PATH,或者 DevEco Studio 里的 SDK 路径没被 flutter 工具探测到。
提示:如果
flutter run直接报Unable to locate hdc,可以在环境变量里手动指定 DevEco Studio 自带 SDK 下的toolchains路径。这个细节容易卡住很多人,但一旦配置好,后面全程省心。
3. 2048 棋盘模型与核心数据结构:先把游戏逻辑想清楚
3.1 棋盘用什么数据结构最顺手
2048 的棋盘是 4x4 网格,每个格子要么为空,要么有一个 2 的幂次方数字(2、4、8、16...)。最直接的做法是用List<List<int>>表示,外层 4 个元素代表行,内层 4 个元素代表列:
List<List<int>> board = List.generate(4, (_) => List.filled(4, 0));0表示空格,非 0 就是格子上的数值。为什么不用一维数组?因为后续按行、按列遍历时,二维数组的索引可读性最好,代码不用做坐标换算。而我选择直接把这个 List 包进一个Game2048Model类里,由它统一管理移动、合并、生成新数字、判断输赢。UI 层只跟这个 model 打交道。这个设计后面会体现出好处——OpenHarmony 真机上调试时,我可以完全不关心 UI 直接跑单元测试验证算法。
3.2 新数字生成策略:位置随机 + 数值比例
每次滑动合并后,棋盘至少要出现一个随机数字。主流实现有两种:固定 2、4 二选一,或者偶现更高数字。我沿用了经典规则——随机位置,90% 概率生成 2,10% 概率生成 4。
void spawnRandomTile() { final emptyCells = <(int, int)>[]; for (var r = 0; r < 4; r++) { for (var c = 0; c < 4; c++) { if (board[r][c] == 0) { emptyCells.add((r, c)); } } } if (emptyCells.isEmpty) return; final (r, c) = emptyCells[Random().nextInt(emptyCells.length)]; board[r][c] = Random().nextDouble() < 0.9 ? 2 : 4; }这里有个容易被忽略的细节:生成新数字之前必须先收集所有空位,再在空位里随机选,而不是盲目随机行列然后判断是否为空。如果直接随机行列,遇到空格概率逐渐变低时性能虽然不受影响,但会出现"滑动后明明有空格却没生成数字"的错觉。
3.3 游戏结束判定不只看满屏
2048 的游戏结束条件是:棋盘没有空格,且没有相邻相同数字。也就是说,即使满了,只要上下左右任一方向还具备合并可能性,游戏就没有结束。我在模型里维护了一个hasLegalMove()方法判断:
bool hasLegalMove() { for (var r = 0; r < 4; r++) { for (var c = 0; c < 4; c++) { if (board[r][c] == 0) return true; if (c < 3 && board[r][c] == board[r][c + 1]) return true; if (r < 3 && board[r][c] == board[r + 1][c]) return true; } } return false; }这个判断只检查"是否有空位"和"是否存在水平/垂直相邻相同"两个条件,不用尝试执行所有方向的模拟移动,极大简化了逻辑,也方便在 UI 层每次滑动后快速触发结束弹窗。
4. 滑动合并算法:四方向统一成"向左合并 + 旋转矩阵"
4.1 核心思路:方向只是参考系问题
2048 的滑动合并,看似四个方向各写一套逻辑,其实完全不需要。核心思路是:先实现一个"向左合并"函数,再把整个棋盘做旋转,让其他方向的滑动等价于旋转后向左合并,最后旋转回原位。
这个思路我在之前的专栏里详细推导过,这次直接上最终代码。向左合并的逻辑分三步:
- 把行里的非零元素紧凑靠左,去掉中间的 0(比如
[2, 0, 2, 4]→[2, 2, 4, 0]) - 从左往右扫描,相邻相同数字合并,合并后的数字放在左侧,右侧清零
- 再次紧凑挪动(因为合并后可能产生新的空洞)
第一步和第三步可以合并成一个compact函数,代码复用。一个干净的写法是:
List<int> mergeLine(List<int> line) { final compacted = line.where((v) => v != 0).toList(); final result = List<int>.filled(4, 0); var index = 0; for (var i = 0; i < compacted.length; i++) { if (i + 1 < compacted.length && compacted[i] == compacted[i + 1]) { result[index++] = compacted[i] * 2; score += compacted[i] * 2; i++; // 跳过已被合并的第二个数 } else { result[index++] = compacted[i]; } } return result; }这里有一个特别容易踩的坑:每次合并相邻元素后,i 必须再跳一格,否则会出现连锁合并的情况,比如[2, 2, 2, 2]正常应该合并成[4, 4, 0, 0],但如果忘了跳格,会变成[8, 0, 0, 0],这是不符合经典规则的。2048 规定每次滑动中,一个格子最多参与一次合并。
4.2 旋转矩阵与四方向统一处理
Dart 里把棋盘顺时针旋转 90 度的写法很统一——新矩阵的行等于原矩阵的列倒序:
List<List<int>> rotateClockwise(List<List<int>> grid) { final n = grid.length; final rotated = List.generate(n, (_) => List<int>.filled(n, 0)); for (var r = 0; r < n; r++) { for (var c = 0; c < n; c++) { rotated[c][n - 1 - r] = grid[r][c]; } } return rotated; }四种滑动方向的统一处理逻辑如下:
- 向左滑动:直接对每一行执行
mergeLine - 向右滑动:每行先反转,执行
mergeLine,再反转回来 - 向上滑动:棋盘逆时针旋转 90 度,按"向左"处理,再顺时针旋转回来
- 向下滑动:棋盘顺时针旋转 90 度,按"向左"处理,再逆时针旋转回来
用代码封装一下:
void moveLeft() { for (var r = 0; r < 4; r++) { board[r] = mergeLine(board[r]); } } void moveRight() { for (var r = 0; r < 4; r++) { board[r] = mergeLine(board[r].reversed.toList()).reversed.toList(); } } void moveUp() { rotateCCW(); moveLeft(); rotateCW(); } void moveDown() { rotateCW(); moveLeft(); rotateCCW(); }旋转思路的最大好处是:你只需要把 mergeLine 这一个函数的逻辑彻底调对,四个方向全都正确。我在开发时先用纯 Dart 写了个测试脚本,遍历各种[2, 0, 2, 4]组合,确认 mergeLine 输出正确,才接入 UI,排错时间压缩了一大半。
4.3 一个决定成败的小细节:移动前检测棋盘是否真的变化了
每次滑动如果不判断棋盘是否发生变化,会导致两个问题:
- 无效滑动也触发新数字生成
- 无效滑动也播放动画,体验很假
解决方法很简单——移动前克隆一份当前棋盘,执行完 merge 后与克隆对比:
bool move(int direction) { final before = board.map((row) => List<int>.from(row)).toList(); // 根据 direction 执行对应的移动逻辑 if (_isSame(before, board)) { return false; // 棋盘没变化,不生成新数字 } spawnRandomTile(); return true; }这一点在我最初版本里没做,导致偶尔滑动时明明没有合并,却冒出新数字,玩家会感觉很"灵异"。加上判断后整体手感立刻变扎实了。
5. UI 层实现:手势识别、状态刷新与动画反馈
5.1 手势识别:GestureDetector 的 onPanEnd 判定方向
2048 的滑动交互在 Flutter 里最简单的实现方式不是onVerticalDragEnd/onHorizontalDragEnd分开监听,而是用一个GestureDetector同时监听onPanStart和onPanEnd,在滑动结束时根据位移向量的水平/垂直分量大小和符号判断方向。
GestureDetector( onPanStart: (details) { _startX = details.globalPosition.dx; _startY = details.globalPosition.dy; }, onPanEnd: (details) { final dx = details.globalPosition.dx - _startX; final dy = details.globalPosition.dy - _startY; if (dx.abs() < 20 && dy.abs() < 20) return; // 过滤轻微抖动 if (dx.abs() > dy.abs()) { dx > 0 ? _move(Direction.right) : _move(Direction.left); } else { dy > 0 ? _move(Direction.down) : _move(Direction.up); } }, child: boardWidget, )用onPanStart+onPanEnd而不是onHorizontalDragUpdate是因为onPanEnd能同时拿到起终点坐标,方向判定的代码更集中,也不用处理多次回调导致重复移动的问题。阈值 20 是我实测下来比较舒适的数值,低于 20 基本是误触,高于 60 又会让快速滑动变得太迟钝。你可以根据自己的手感微调。
5.2 棋盘绘制:用最朴素的 Stack 或 GridView 完成
绘制 4x4 棋盘,我第一版直接用的GridView.builder,每格一个Container,背景色根据数字大小从浅到深渐变,数字 4 以上的用白色粗体显示。这个方案开发速度快,但如果你打算在这个项目上继续叠加动画,我建议你改用Stack+Positioned,原因是后面的滑动动画需要让数字块脱离格子在棋盘内自由移动,GridView的格子约束会成为动画的阻碍。
我的最终实现里用了LayoutBuilder动态算出格子大小,然后 Stack 叠两层:底层是 16 个固定背景格子,上层是当前所有非零数字块,每块用AnimatedPositioned包住的位置定位。这样数字块的坐标变化可以通过动画平滑过渡,而不是瞬间跳变。
5.3 AnimatedPositioned 实现滑动过渡
Flutter 里做数字块移动动画,最省力的方案是AnimatedPositioned——只要给定duration和curve,它会在定位属性变化时自动补间。棋盘格大小固定,格子行列坐标换算成像素坐标后,直接作为AnimatedPositioned的left和top:
AnimatedPositioned( duration: const Duration(milliseconds: 120), curve: Curves.easeInOut, left: col * cellSize + 8, top: row * cellSize + 8, child: TileWidget(value: value), )duration我试过 80ms 到 200ms,120ms 是比较舒服的区间,太快看不出移动轨迹,太慢则拖泥带水。合并出现的反馈动画同样可以用AnimatedScale做一个 1.0 → 1.15 → 1.0 的缩放脉冲,让玩家知道一次合并发生在哪个格子。
这里注意一个 Flutter 性能细节:AnimatedPositioned每次移动其实会驱动整个Stack子树的重建。在 4x4 的棋盘上不会有问题,但如果你后续把棋盘扩充到 6x6 甚至更大,建议对TileWidget做RepaintBoundary隔离,否则动画帧率在低端 OpenHarmony 设备上会掉到 40fps 以下。
5.4 分数计算与最高分持久化
分数在mergeLine里累加,每合并一次就加compacted[i] * 2。这个规则和经典 2048 一致——合并得到的数值直接计入总分。最高分用SharedPreferences持久化,注意在 OpenHarmony 适配中,这个插件的原生实现走的是 OHOS 的 Preferences 能力,依赖shared_preferences_ohos这个适配包。如果不引入适配包,直接跑默认的 shared_preferences 会报 MissingPluginException。
6. OpenHarmony 真机适配:Flutter 特有的坑与排查链路
6.1 常见的 E/flutter 崩溃日志排查思路
真机上最常看到的一类问题就是启动后马上出现类似:
E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled exception:这个模糊报错通常不是 Flutter 引擎本身的问题,而是 Dart 层抛出了未捕获异常,常见诱因有两个:
- 插件未适配 OpenHarmony:比如某个 Flutter 插件只实现了 Android/iOS 的原生端,OpenHarmony 没有对应实现,调用时 MethodChannel 找不到实现类,直接抛 MissingPluginException
- SDK 版本不匹配导致的 API 调用失败:比如高版本 Flutter 分支调用了 OpenHarmony 侧还未实现的 Engine 接口
排查链路我建议按三步走。第一步,用flutter logs或hdc shell hilog | grep flutter抓完整堆栈,看是哪一行 Dart 代码抛出来的。第二步,定位到具体插件后,去 pubspec.yaml 里确认是否有 ohos 适配声明,没有的换用社区适配版本。第三步,如果堆栈指向的是flutter_engine内部,那基本是版本配对问题,回退 Flutter 分支版本或升级 OpenHarmony SDK。
6.2 MissingPluginException 的具体修复示例
我做 2048 时遇到的实际例子是shared_preferences。在pubspec.yaml里引入插件后,Android 上一切正常,OpenHarmony 真机上跑起来,写首选项的代码直接抛 MissingPluginException。去查了插件仓库才发现,社区已经维护了一个shared_preferences_ohos适配包,使用方法简单:
shared_preferences_ohos: ^2.0.0然后在代码里几乎不用改,因为它的 API 兼容 Flutter 官方shared_preferences,只需在初始化时确认使用的不是SharedPreferences类本身(适配包里有同名类)。这个坑背后有个通用规律:凡是涉及原生能力的插件,都要先查一遍 OpenHarmony 适配包列表再做集成。现在社区已经有flutter_ohos_plugins这样的集中仓库维护常见插件的 OHOS 实现。
6.3 PlatformView 与游戏场景不大,但要注意页面跳转差异
2048 这种游戏 App 不需要嵌入原生 View,因此 PlatformView 的坑基本碰不到。但如果你在这个项目基础上扩展成"游戏合集 App",在页面跳转时会遇到另一个 OpenHarmony 特有的差异:Navigator的转场动画在部分设备上表现异常,页面切换时可能会出现白屏一闪。排查后发现是 Flutter 分支的 Impeller 渲染引擎在 OpenHarmony 设备上兼容性问题导致的,解决办法是暂时降级到 Skia 渲染:
flutter run --enable-software-rendering或者在main()里显式设置渲染器。OpenHarmony 分支目前对 Impeller 的支持还在完善中,遇到渲染相关怪癖可以直接考虑切 Skia,游戏类的纯 UI 应用对 Skia 的依赖完全没问题。
6.4 热重载与状态保持的补充说明
Flutter 开发时热重载是提效利器,但 OpenHarmony 分支的热重载稳定性不如 Android 端。我用下来遇到的情况是:热重载后状态变量有时会丢失,棋盘直接重置。这不是业务代码问题,而是 flutter-tools 的 OHOS 插件还未完全支持热状态注入。我的建议是保留测试入口,在 UI 上放一个"重置棋盘"按钮,热重载不行了就冷重启,反正 2048 的数据结构简单,重启也不心疼。
7. 从 2048 到"游戏集合 App":架构设计上的实操经验
7.1 把 2048 做成可嵌入的独立模块
标题里写的是"游戏集合 App 实战",所以我不满足于做一个孤立的 2048 页面,而是顺手把 2048 封装成了一个可复用模块。做法很简单:在lib/games/下建立独立目录,每个游戏一个GameScreen组件,主页面用一个 Grid 宫格列出各游戏入口,点击后Navigator.push进入对应游戏。2048 模块对外暴露的只有Game2048Screen一个组件,它的内部状态完全由模型管理,不依赖外部传入参数。
这个设计让后续扩展新游戏(比如贪吃蛇、推箱子、扫雷)时,只需要在games/下新增目录并实现统一的GameScreen接口即可,主页面代码一行都不用改。
7.2 状态管理选型:SetState 够用就别上框架
2048 的状态管理我用的是最基础的StatefulWidget+setState。我知道很多 Flutter 教程一上来推荐 Provider、Riverpod、Bloc,但在这个项目规模下,引入状态管理框架是纯粹增加复杂度。2048 的状态只有棋盘数据、分数、最高分、游戏结束标志四个,而且全部集中在同一个页面,setState完全能胜任。把模型和 UI 分离已经保证了可测试性,没必要再上重量级框架。
唯一的例外是如果游戏间需要共享全局数据(比如统一积分系统、成就系统),那时候再考虑在游戏集合 App 外层引入一个轻量的ChangeNotifier即可。
8. 实测数据与优化空间
8.1 性能基线数值
我在 OpenHarmony 5.0 开发板上实测的性能数据如下,给后面做游戏合集的朋友一个参考基准:
| 指标 | 数值 | 备注 |
|---|---|---|
| 冷启动完成时间 | 1.2s-1.5s | 包含 Flutter 引擎初始化 |
| 滑动动画帧率 | 55-60fps | 120ms 动画时长 |
| 电池消耗(连续玩 30 分钟) | 无明显发热 | 低负载应用 |
| 包体积 | 21MB | APK/IPA 的 OHOS HAP 包 |
OpenHarmony 的 Flutter 分支性能表现比预期好,滑动动画在开发板上没有掉帧问题。瓶颈主要在启动阶段——Flutter 引擎初始化 + Dart isolate 启动在 OpenHarmony 上比 Android 慢一些,这也是所有 OpenHarmony 上 Flutter 应用共同的现状,后续优化方向是引擎预加载和首帧渲染优化。
8.2 可扩展的几点方向
做完 2048 后,有几个方向值得继续深入:
- 引入
flutter_ohos_game_controller适配手柄输入,把游戏集合 App 从触屏操作扩展到外设操作 - 把棋盘规模参数化(4x4、5x5、6x6),增加难度分级
- 增加撤销功能(记录每一步移动前的棋盘快照)
- 接入 OpenHarmony 的无障碍能力,为数字块增加语义标签,方便视障用户操作
从 2048 这个"小游戏"延伸出的内容其实很多,关键是先把滑动合并、状态同步、跨端适配这几个基础链路做扎实。
9. 踩坑清单与经验打包
把这次开发中遇到的所有值得记录的坑整理成一张速查表,方便你复现项目时快速对照:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| flutter 命令识别不到 OpenHarmony 设备 | hdc 未加入 PATH | 将 DevEco Studio 的 toolchains 目录加入 PATH |
| 编译报 API 不匹配 | Flutter 分支版本与 OpenHarmony SDK 不匹配 | 严格按官方 README 配对版本 |
| 启动时 E/flutter 异常堆栈 | 插件未适配 OHOS | 换用 ohos 适配包,或自实现 MethodChannel |
| SharedPreferences 抛 MissingPluginException | 缺少 OHOS 原生实现 | 引入 shared_preferences_ohos |
| 页面切换白屏一闪 | Impeller 渲染兼容问题 | 降级 Skia,--enable-software-rendering |
| 热重载后状态丢失 | OHOS flutter-tools 插件限制 | 使用冷重启 |
| 热重载后箭头按钮失效 | 手势监听在热更新时未正确重绑 | 冷启动解决 |
最后分享一个我在调试 OpenHarmony 真机时提高效率的小技巧:flutter 的-d参数如果识别不到设备,可以用hdc list targets先确认设备状态,然后在flutter run时直接传--device-timeout 120延长设备连接超时时间。开发板第一次连接时常会因为握手慢导致设备列表为空,加这个参数能避免误判设备没连接。
这次的 2048 实战项目,从环境搭建到游戏逻辑再到真机适配,算是把 Flutter 在 OpenHarmony 上的开发主线完整走了一遍。核心收获可以总结成三句话:滑动合并算法用旋转统一处理四个方向是真的省心;OpenHarmony 适配的关键词是"版本配对 + 插件适配包",90% 的报错都跟这俩有关;如果只是为了验证跨端方案,先做一个逻辑完整的小游戏比堆页面更能暴露问题。后续我打算把 AI 自动玩 2048(蒙特卡洛树搜索)加进去,这又涉及 Flutter 里的 isolate 并发和平台通道交互,等实现完再写一篇展开聊。