前一阵子我在给自己做游戏集合App,想着怎么也得拿出个能真正“玩起来”的小游戏撑场面。翻来覆去,最后选了记忆翻牌,而且牌面全部用表情图案。这游戏规则两句话能讲完,但真把它做好,涉及洗牌算法、状态机、翻牌动画、计时计步、成绩结算,还要把整套东西塞进一个可扩展的游戏列表框架里,最后跑到OpenHarmony真机上,工作量一点都不小。这篇就是我做“Flutter for OpenHarmony游戏集合App之记忆翻牌表情图案”的完整复盘,从设计思路到实际踩坑都放在里面。
1. 项目背景与整体设计思路拆解
在聊代码之前,先说说我为什么要做这个项目,以及为什么用Flutter跑OpenHarmony这件事本身值得尝试。
1.1 项目定位:从游戏集合容器到第一个核心玩法
游戏集合App的思路很简单:一个App里放多个独立小游戏,用户从首页进入任意一个游戏,玩完再回到列表。这个形态很常见,但它的核心难点不在某个游戏多复杂,而在于架构能不能支撑后续不断加新游戏。我需要一个App入口、一套统一路由、一个游戏结果回传机制,再加一个足够有代表性的首作游戏。
选择记忆翻牌当第一个游戏,有几个原因。第一,规则高度标准化,几乎所有人都玩过,不用解释玩法。第二,它天然适合用表情图案当牌面,不需要设计切图资源,一个字符串就能表达一张牌。第三,翻牌过程中的动画、状态切换、匹配判定、计时计步,几乎覆盖了做其他小游戏时都会遇到的基础技术场景。把它啃下来,后面的数独、连连看、找不同都有现成的范式可以套。
这个项目的整体层级定位是这样的:底层是Flutter for OpenHarmony的工程,中间是游戏集合容器(管理入口、路由、共享状态),上层是记忆翻牌游戏页。每层之间保持独立,游戏页不反向依赖容器,这样以后加新游戏时,不会动到已经稳定的框架代码。
1.2 技术选型:为什么是Flutter for OpenHarmony
很多人听到OpenHarmony,第一反应是“得学ArkTS”。但如果你已经会Flutter,并且有现成的跨端业务要覆盖,那社区维护的Flutter for OpenHarmony适配分支完全可以拿来用。它保留了Flutter的核心开发方式:Dart语言、Widget树、热重载、同一套代码逻辑,只是构建产物从APK变成了HAP。
我选Flutter还有一个很现实的原因:记忆翻牌这类吃动画和交互的应用,用声明式UI写起来效率高。Flutter的AnimationController、Transform、AnimatedBuilder这套动画体系已经非常成熟,翻牌的三维旋转效果可以很自然地实现。如果每个动画都要自己处理底层绘制,开发周期会成倍拉长。
另一个关键点是状态管理。我给这个项目定的原则是:能局部更新就不全局重建,能用原生StatefulWidget就不引入重型状态管理库。记忆翻牌的游戏状态虽然多,但都集中在单个页面内,setState加几个成员变量足够了。游戏集合容器层的跨页面状态也不复杂,一个简单的ChangeNotifier就能搞定。Bloc、Cubit这种方案适合业务逻辑复杂、状态流需要严格约束的场景,放进这个项目反而会制造模板代码。
1.3 为什么第一作选记忆翻牌:功能覆盖最全的小型玩法
记忆翻牌的技术覆盖面其实很广。
- 数据层:生成牌面列表、洗牌、配对,考验你对集合处理和随机算法的理解。
- 交互层:点击、翻牌、匹配失败回翻,需要状态机防止输入错乱。
- 动画层:翻牌时的三维旋转、匹配成功的高亮动画,需要控制好动画时序。
- 游戏逻辑层:计时器、步数统计、胜利判定、成绩结算。
- 容器层:如何从游戏列表页跳进游戏,结束后如何带回结果。
把这五点拆开看,每一块都是小游戏开发的基本功。而且记忆翻牌的玩法本身有天然的“再来一局”驱动力,非常适合作为集合App的初始内容。
从工程摊还的角度说,这个游戏写完以后,里面的洗牌工具函数、卡片组件、计时器组件、成绩弹窗都可以抽成公共模块。后续开发新游戏直接复用,不用从零开始。
2. 记忆翻牌的核心设计拆解
这一部分讲游戏本身的建模,重点是状态机和数据结构的取舍。代码层面的错误大多出在这里:状态定义不清、洗牌算法有偏、点击事件处理不及时。
2.1 玩法规则与难度分级
我给记忆翻牌设计了三个难度:简单4x4共16张牌(8对),标准6x4共24张牌(12对),挑战6x6共36张牌(18对)。牌面来源是一个按主题组织的表情池,确保每轮需要多少对就能取出多少对不重复的表情。
为了让新手更容易上手,我加了一个可选的“开局预览”模式:开局先把所有牌面展示1.5秒,再统一盖回去。这个设计在经典记忆翻牌里很常见,能明显降低第一局的挫败感。预览模式是一个独立的bool开关,关掉后就是标准难度。
难度与表情池的对应关系可以做成这样:
| 难度 | 网格 | 对数 | 表情池示例 |
|---|---|---|---|
| 简单 | 4x4 | 8对 | 动物主题:狗、猫、兔、熊、熊猫、老虎、狮子、猴 |
| 标准 | 6x4 | 12对 | 食物主题:苹果、橙子、西瓜、葡萄、草莓、桃子、樱桃、柠檬 |
| 挑战 | 6x6 | 18对 | 物品主题:足球、篮球、棒球、网球、吉他、钢琴、太阳、月亮 |
这里要考虑一个实际问题:表情图案在不同系统上渲染效果不一致。所以选表情时要优先用最基础、最通用的那批Unicode表情,比如动物脸、水果、球类,尽量不要用需要ZWJ序列拼接的复杂表情,否则低版本系统的字体缺字时,牌面会显示成豆腐块。这个坑后面我会专门展开讲。
2.2 状态机:四个状态解决所有交互Bug
玩过记忆翻牌的人都知道,这类游戏最大的体验问题是“乱点”。玩家情绪上来了会疯狂连点,如果代码没做防护,就会出现翻到一半的牌被重复触发、三张牌同时翻开、匹配判定错乱等一堆问题。
我建立的卡片状态是一个四态枚举:
enum CardPhase { hidden, // 背面朝上,等待翻牌 flipping, // 正在翻转动画中 revealed, // 正面朝上,还未判定 matched, // 已匹配成功,保持翻开 }之所以不用简单的bool字段区分翻开/未翻开,是因为翻转过程本身是有时间的。从点击到牌面完全翻开,大概需要300毫秒,这期间状态是模糊的。如果只用bool,动画进行中用户再次点击同一张牌,就会发生状态冲突。引入flipping状态后,点击处理函数可以先判断:只有hidden状态的牌才允许触发翻转,其余状态一律短路返回。
状态迁移关系是这样的:
- hidden点击后进入flipping,动画翻到正面后成为revealed。
- revealed如果和另一张revealed匹配成功,变为matched。
- revealed如果匹配失败,延迟后回到flipping,盖回去后成为hidden。
- matched保持终态,不会再被点击。
整个游戏全局还需要一个busy锁。当已有两张牌处于判定状态时,所有新的点击都会被丢弃,等判定完成后才释放锁。这层防护虽然简单,但能挡住90%以上的乱点Bug。
2.3 洗牌算法:为什么必须用Fisher-Yates
洗牌听起来简单,但用错算法会产生明显偏差。最容易犯的错误是“排序洗牌”:给每张牌生成一个随机数,然后按随机数排序。这种做法理论上有偏,因为随机数碰撞时的处理方式会破坏等概率性。我实测过,牌面分布会出现一些固定位置的牌永远凑不到一对,玩家的记忆策略会被规律干扰。
正确做法是Fisher-Yates洗牌,也叫Knuth洗牌。核心思想是从后往前遍历,每次从剩余未处理的位置中随机选一个交换到当前位置。这个算法是原地操作,时间复杂度O(n),而且能保证每个排列出现的概率相等。
import 'dart:math'; List<T> fisherYatesShuffle<T>(List<T> source, {Random? random}) { final list = List<T>.from(source); final rng = random ?? Random(); for (var i = list.length - 1; i > 0; i--) { final j = rng.nextInt(i + 1); final tmp = list[i]; list[i] = list[j]; list[j] = tmp; } return list; }注意这里有个细节:必须先复制一份源列表再原地洗,否则会污染原始表情池,下一局就没得用了。另外,如果要支持“相同的随机种子重现同一局布局”(比如录像回放),可以传入一个带种子的Random对象,这样洗牌结果可复现,调试卡牌逻辑时会非常方便。
牌面数据模型我用了两个ID:一个entryId代表牌面对应的“原值ID”,用于匹配判断;一个cardId代表这张牌在棋盘中的唯一实例ID。两只牌entryId相同就代表匹配成功。这个设计的好处是,同一对牌在棋盘里是两个独立实例,但它们的匹配关系通过entryId就能关联,不需要额外建map。
class MemoryCard { final int cardId; final int entryId; final String emoji; CardPhase phase; MemoryCard({ required this.cardId, required this.entryId, required this.emoji, this.phase = CardPhase.hidden, }); }3. 翻牌动画与UI实现的实操要点
动画是记忆翻牌最有存在感的部分。Flutter里做3D翻转效果并不难,但有几个细节做不好就会露馅:透视效果缺失、翻转中途切换牌面的时机不对、动画期间整页重建导致卡顿。
3.1 三段式翻转动画:Matrix4与AnimationController
一张牌从背面翻到正面,视觉上包含两个阶段:前半段(0度到90度)看到的是牌背,后半段(90度到180度)看到的是牌面。代码实现上,我用AnimationController驱动一个0到1的Tween,再拆成两个Interval:
AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); Animation<double> _frontFlip = CurvedAnimation( parent: _controller, curve: const Interval(0.0, 0.5, curve: Curves.easeIn), ); Animation<double> _backFlip = CurvedAnimation( parent: _controller, curve: const Interval(0.5, 1.0, curve: Curves.easeOut), );渲染时,用AnimatedBuilder监听动画值,对Transform做Y轴旋转。关键代码是Matrix4的setEntry设置透视参数:
Transform( alignment: Alignment.center, transform: Matrix4.identity() ..setEntry(3, 2, 0.0012) ..rotateY(angle), child: angle < 90 ? _buildCardBack() : _buildCardFront(), )setEntry(3, 2, 0.0012)是给变换矩阵加上透视投影,没有这一步旋转会是扁平的“压扁”效果,而不是有纵深的翻转。angle小于90度时显示牌背,大于90度时显示牌面,正好在90度临界点完成内容切换。
实际翻牌调用的方法可以写成这样:
Future<void> _animateFlip(MemoryCard card) async { card.phase = CardPhase.flipping; await _controller.forward(from: 0); card.phase = CardPhase.revealed; }这里有一个生产环境要注意的点:一个AnimationController可以驱动多张牌,但多张牌同时翻转时需要区分各自的动画进度。简单做法是每张牌持有自己的AnimationController,成本可控;如果牌数量大(36张),建议用TweenAnimationBuilder配合每个卡片的独立Animation ,避免同时持有过多Controller。
3.2 表情图案渲染与兜底方案
用表情当牌面,最直接的收益是不需要图片资源。Flutter的Text组件天生支持渲染Unicode表情,只要系统字体里有对应的字形就行。我封装了一个卡牌正面组件:
class CardFront extends StatelessWidget { final String emoji; const CardFront({super.key, required this.emoji}); @override Widget build(BuildContext context) { return Container( width: 72, height: 72, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: const [ BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)), ], ), child: FittedBox( fit: BoxFit.contain, padding: const EdgeInsets.all(8), child: Text(emoji, style: const TextStyle(fontSize: 40)), ), ); } }用FittedBox包裹表情文本,是为了避免不同表情的固有字号差异导致卡片内容溢出或缩放不一致。实测下来,同一个FittedBox容器里,大部分基础表情的显示大小都能保持在视觉一致的范围内。
表情图案的兜底是个容易被忽略的细节。OpenHarmony各版本系统字库里,表情覆盖范围不完全一致。为了稳妥,我做了三道防线:
- 选择表情时避开冷门区域,只使用最通用的基础表情集合。
- 给Text的style指定fontFamilyFallback列表,优先匹配系统内常见表情字体。
- 在游戏开始前做一个“表情渲染自检”,检查每个表情的渲染宽度是否正常,若发现豆腐块,就用Material Icons里的替代图标。
3.3 计时、计步与胜利结算
计时我用Timer.periodic,每秒触发一次更新。这里有个性能小技巧:不要把计时器文本放在游戏主页面里整页setState,而是把它单独拆成一个StatefulWidget。这样每秒的刷新只会重建计时器文本,不会牵连整个GridView。
class GameTimer extends StatefulWidget { final bool running; const GameTimer({super.key, required this.running}); @override State<GameTimer> createState() => _GameTimerState(); } class _GameTimerState extends State<GameTimer> { Timer? _timer; int _elapsed = 0; @override void initState() { super.initState(); _start(); } void _start() { _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (_) { setState(() => _elapsed++); }); } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Text( '$_elapsed s', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ); } }计步逻辑相对简单,每次点击进入翻转流程时步数加一。胜利判定放在匹配计数上:当matched数量等于总对数时,取消计时器,弹出结算框。
结算的星级评定我设计得比较宽松,目的是让普通玩家也能拿到高分:简单模式步数小于等于最小步数加4给3星,加8给2星,其余完成给1星;挑战模式门槛相应放宽。最小步数就是总对数,因为理想情况下每对只需要翻两次。
4. 游戏集合App的容器设计与OpenHarmony打包
记忆翻牌写完后,真正让它变成“游戏集合App里的游戏”,还需要完善容器层和平台适配层。
4.1 游戏集合App的骨架:新增游戏的三步操作
我把游戏集合的首页做成了GridView游戏入口列表。每个入口对应一个GameEntry模型:
class GameEntry { final String name; final String description; final WidgetBuilder builder; const GameEntry({ required this.name, required this.description, required this.builder, }); } final List<GameEntry> kGameList = [ GameEntry( name: '记忆翻牌', description: '翻开成对的表情图案,考验观察力和记忆力', builder: (_) => const MemoryMatchPage(), ), // 后续新游戏在这里追加即可 ];路由统一用onGenerateRoute管理,避免在MaterialApp里写死每个游戏的跳转路径:
MaterialApp( onGenerateRoute: (settings) { switch (settings.name) { case '/memory_match': return MaterialPageRoute(builder: (_) => const MemoryMatchPage()); default: return null; } }, )新增一个游戏时,只需要三步:写一个游戏页面组件、在kGameList追加一条GameEntry、在路由表里加一个case。容器层完全不用改。
游戏结束后的结果回传,我用Navigator.pop返回结果对象。比如记忆翻牌结束时,把步数、用时、星级组成一个GameResult对象pop回列表页,列表页可以提示“上次战绩:3星”。
4.2 Flutter for OpenHarmony工程配置与hap构建
要跑OpenHarmony,必须使用社区维护的Flutter OHOS适配版SDK,常规官方Flutter SDK不支持ohos平台。安装细节不同版本略有出入,但整体流程是固定的:获取OHOS分支的flutter SDK、配置OpenHarmony SDK路径、用支持OHOS平台的flutter命令创建工程。
创建工程时,如果适配版SDK支持平台参数,可以直接指定:
flutter create --platforms ohos --org com.example --project-name game_hub game_hub_app如果当前版本不支持这个参数,可以先创建标准工程,再通过适配版提供的模板添加ohos目录。创建完成后,工程的ohos目录就是OpenHarmony原生侧工程,后续签名、构建、安装都围绕它进行。
构建HAP包的典型命令:
flutter build hap --debug产物一般在ohos目录下的build/outputs里,后缀是.hap。注意OpenHarmony的调试工具是hdc,不是adb:
hdc install build/outputs/hap/debug/game_hub_app.hap日志查看用hilog,相当于Android的logcat:
hdc hilog签名这一步很容易卡住。HAP包没有有效签名是装不上的。开发阶段可以在DevEco Studio里对ohos工程做自动签名配置,签名完成后,hdc install才会顺利通过。
4.3 真机运行与调试经验
真机调试中我踩过几个印象深刻的问题。第一个是热重载失效。OHOS适配分支的Flutter对hot reload的支持不稳定,经常出现改了代码但界面不更新的情况。我的处理方法是优先用hot restart代替hot reload,或者直接重新run。一旦发现热重载后状态异常,不要犹豫,立刻全量重启。
第二个是性能日志的解读。OpenHarmony设备上跑Flutter,压力最大的往往不是CPU而是GPU的着色器编译。真机调试时如果发现首帧有明显的白屏卡顿,大概率是着色器编译导致的。优化方向是减少动画的首帧shader复杂度,比如避免在动画首帧用大量模糊滤镜。
第三点是设备兼容性。不同OpenHarmony设备的GPU能力差异很大,同一套代码在开发板上流畅,在另一台设备上可能掉帧。所以动画参数不要写死,做成可配置项,遇到性能不足的设备可以降低动画时长、关闭阴影。
5. 踩坑实录与常见问题排查
最后这部分,我把实际开发中遇到的典型问题整理成一份问题清单。每个问题都给出排查思路和解决方案,方便后来者直接对照。
5.1 “configured Flutter SDK is not known to be fully supported”报错
这个报错字面意思是:当前配置的Flutter SDK版本,没有被项目的构建配置完全支持。在OHOS工程里,常见触发原因是ohos原生工程的compileSdkVersion或compatibleSdkVersion设置过高,超出了当前Flutter适配版SDK测试过的范围。
排查步骤是这样:先看报错提示里给出的具体SDK版本号,再到ohos工程目录下找到构建配置文件,里面的compileSdkVersion和compatibleSdkVersion改成设备实际可支持的API等级。改完以后执行flutter clean再重新构建。如果使用的是较老的适配版SDK,而系统API等级较新,也可能出现反向不兼容,这时候需要升级适配版Flutter SDK到更新版本。
这类“SDK版本警告”本质上属于版本匹配问题,不要试图绕过检查,而是要让工程配置落在SDK支持区间内。
5.2 翻牌动画卡顿到40fps,如何找回60fps流畅度
我最初版本的问题很明显:每次翻牌或计时更新都对整页GridView执行setState,导致36张牌全部重建。在低配OpenHarmony设备上,帧率直接掉到40fps上下,翻牌动画发“肉”。
排查思路其实可以借鉴移动端性能优化的通用路子。第一,用RepaintBoundary隔离卡片,让单张牌的动画只触发自己的绘制,不牵连旁边的牌。第二,把计时器、步数这些高频更新组件各自封装成独立Widget,让它们的setState影响范围最小化。第三,动画期间尽量减少阴影和复杂背景的绘制,阴影是GPU的开销大户。
我实际改动后,帧率稳定在了60fps。核心动作就是上面三个,收益最大的是RepaintBoundary隔离。
5.3 表情图案显示成豆腐块
这个问题的表现是:某些牌面的表情在OpenHarmony设备上显示成方框,完全没法辨认。原因是系统字体缺少对应Unicode码点的字形。
排查办法是缩小范围:先确认是全部表情都不显示,还是个别不显示。如果是全部,说明设备的系统字体没有覆盖基础表情区域,需要检查字体配置;如果是个别,大概率是用了冷门码点或ZWJ复合序列。
我的解决方案是给Text显式配置fontFamilyFallback,把系统里常见的几个表情字体作为备选。如果设备上表现依然不理想,就在表情池配置里替换掉出问题的表情。考虑到这是游戏应用,牌面的可辨识度比“用哪个具体表情”更重要。
5.4 其他值得一提的小问题
- ohos工程里留着android/、ios/目录如果被IDE自动触发了gradle同步,有时会报“applying flutter's main gradle plugin imperatively”这类Android侧错误。这个报错不影响ohos构建,但会干扰心情。建议不需要跨端构建时,直接把多余平台目录从IDE项目里排除。
- 如果游戏集合App使用TabBar组织分类页,有些读者会问怎么取消TabBar点击的“水波纹动画”。这个和记忆翻牌本身无关,但属于集合App里常见的UI细节诉求。方案是设置TabBar的splashFactory为NoSplash.splashFactory,并把overlayColor设为透明。
- 游戏过程中,如果页面被切到后台再回来,建议暂停计时器并在回来后继续,避免时间虚报。这个我用WidgetsBindingObserver监听AppLifecycleState来实现。
5.5 最后分享一点个人体会
把记忆翻牌完整跑在OpenHarmony真机上之后,我的体会是:Flutter在OpenHarmony上做交互密集型应用是可行的,但别把官方Flutter的体验直接照搬过来。平台适配分支在热重载、构建链、渲染细节上都还有自己的脾气,写代码时多一些防御性设计(比如表情兜底、动画配置化、状态机锁)能让整个项目稳很多。
这类小游戏的真正价值不在游戏本身,而是用最低成本验证了Flutter在目标平台上的动画能力、交互响应和工程化流程。等后续把数独、拼图加进集合App时,洗牌工具、卡片组件、路线配置这些基础模块都能直接复用,这就是当初认真做容器的回报。