Flutter 实战:memory_match_game 翻牌记忆游戏的双卡匹配、延迟翻回与鸿蒙适配解析
Flutter 实战:memory_match_game 翻牌记忆游戏的双卡匹配、延迟翻回与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
memory_match_game 是一个基于 Flutter 编写的本地翻牌记忆游戏。应用把 8 个图标复制成 16 张牌并随机打乱,玩家每次点击两张卡片进行配对;如果两张牌图标一致,则保持翻开,否则在 800 毫秒后翻回。页面同时统计尝试次数和已匹配对数,直到所有配对完成后显示胜利提示。
这个项目没有联网、没有排行榜、没有计时器,也没有复杂动画控制。它的价值集中在 随机洗牌、状态驱动翻牌、双选配对、延迟回收、自定义卡片样式和胜利统计 上。对于 Flutter 入门、游戏交互和鸿蒙适配来说,这是一份很清晰的练习样例。
翻牌类小游戏最适合拿来理解“点击一次改变局部状态,点击两次触发判断,异步延迟恢复界面”这一整套 Flutter 交互模式。

图示说明:本文围绕 Flutter 本地翻牌记忆游戏展开,重点分析随机卡牌、配对判断、延迟翻回、胜利统计和鸿蒙端适配要点。
一、项目定位与源码概览
1.1 应用目标
memory_match_game 的目标是实现一个经典的记忆翻牌游戏。玩家需要通过记住已翻开的牌面,尽可能少的步数完成所有配对。源码中的游戏流程非常清晰:
- 启动后生成 16 张牌。
- 牌面隐藏,只显示问号图标。
- 点击一张牌后翻开。
- 再点击第二张牌后触发匹配判断。
- 若匹配成功,两张牌保持翻开。
- 若不匹配,800 毫秒后自动翻回。
- 当所有对数匹配完成时显示胜利提示。
1.2 功能边界
当前源码是 本地单机小游戏,没有实现以下功能:
- 不接入网络排行榜。
- 不记录最高分或历史记录。
- 不提供计时器。
- 不支持难度切换。
- 不支持关卡系统。
- 不支持自定义牌面素材包。
因此,文章分析应围绕源码真实具备的能力展开,不把它写成完整商业游戏。
1.3 核心文件
| 文件 | 作用 | 说明 |
|---|---|---|
pubspec.yaml |
依赖声明 | 使用 Flutter SDK 和基础图标依赖 |
lib/main.dart |
游戏核心代码 | 包含入口、状态、翻牌逻辑、胜利判断和页面 |
test/widget_test.dart |
Widget 测试入口 | 当前仍是默认计数器测试,需要按实际游戏改造 |
ohos |
鸿蒙工程目录 | 用于跨端构建与平台适配 |
1.4 技术关键词
| 技术点 | 项目体现 | 学习价值 |
|---|---|---|
StatefulWidget |
保存牌面、翻开状态和胜负 | 理解状态驱动 UI |
Random |
洗牌 | 掌握本地随机化 |
List.filled |
初始化翻开状态 | 学习固定长度状态列表 |
Future.delayed |
不匹配时延迟翻回 | 掌握异步 UI 处理 |
GridView.builder |
生成 4 列卡片网格 | 理解网格布局 |
CustomPainter |
无 | 本项目未使用自定义绘制 |
二、运行环境与依赖结构
2.1 SDK 声明
项目在 pubspec.yaml 中声明 Dart SDK:
environment:
sdk: ^3.9.2
这表示项目运行在较新的 Dart 环境中,可以使用空安全、集合字面量、级联操作和现代 Flutter API。
2.2 项目依赖
依赖保持轻量:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
| 依赖 | 用途 | 说明 |
|---|---|---|
flutter |
UI、渲染、手势和状态管理 | 核心能力来源 |
cupertino_icons |
图标资源 | 当前页面主要使用 Material 图标 |
flutter_test |
Widget 测试 | 可验证页面状态和交互 |
flutter_lints |
静态规则 | 约束代码风格 |
2.3 常用命令
开发和验证常用命令如下:
flutter pub get
flutter analyze
flutter test
flutter run
这些命令分别用于依赖获取、静态检查、测试执行和本地运行。对于鸿蒙适配,建议先把 Flutter 层逻辑跑通,再进入平台构建。
2.4 适配复杂度
memory_match_game 没有使用平台插件,因此跨端适配重点主要集中在:
- 网格布局是否稳定。
- 点击手势是否准确。
- 异步翻回是否按预期执行。
- 胜利状态卡片是否正确显示。
- AppBar 刷新是否可用。
三、应用入口与主题配置
3.1 main 函数
应用入口如下:
void main() {
runApp(const MyApp());
}
main() 只负责启动根组件,具体游戏逻辑都在页面状态中完成。
3.2 MyApp 根组件
根组件负责配置应用标题、主题和首页:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Memory Match',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber),
),
home: const MyHomePage(title: 'Memory Match'),
);
}
}
这里选择琥珀色作为主题种子色,和翻牌游戏中常见的暖色卡片视觉比较契合。
3.3 首页组件
MyHomePage 是一个 StatefulWidget:
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
State<MyHomePage> createState() => _MyHomePageState();
}
翻牌游戏需要维护大量动态状态,因此使用 StatefulWidget 很合理。
3.4 入口关系表
| 层级 | 类或函数 | 职责 |
|---|---|---|
| 启动层 | main() |
启动 Flutter 应用 |
| 应用层 | MyApp |
配置标题、主题和首页 |
| 页面层 | MyHomePage |
接收标题并创建状态 |
| 状态层 | _MyHomePageState |
管理牌面、翻开状态、步数和匹配结果 |
四、游戏状态设计
4.1 状态字段总览
源码中的状态字段如下:
final List<IconData> _icons = [
Icons.star,
Icons.favorite,
Icons.home,
Icons.pets,
Icons.music_note,
Icons.wb_sunny,
Icons.cake,
Icons.diamond,
];
late List<IconData> _cards;
late List<bool> _revealed;
int? _firstIndex;
int? _secondIndex;
int _moves = 0;
int _matchedPairs = 0;
bool _isProcessing = false;
4.2 字段职责
| 字段 | 类型 | 作用 |
|---|---|---|
_icons |
List<IconData> |
8 个基础图标 |
_cards |
List<IconData> |
复制并打乱后的 16 张牌 |
_revealed |
List<bool> |
每张牌是否翻开 |
_firstIndex |
int? |
第一张已翻牌索引 |
_secondIndex |
int? |
第二张已翻牌索引 |
_moves |
int |
尝试次数 |
_matchedPairs |
int |
已匹配对数 |
_isProcessing |
bool |
是否正在处理翻牌判断 |
4.3 状态组合关系
这些字段共同决定游戏进程:
_cards决定牌面内容。_revealed决定哪些牌可见。_firstIndex和_secondIndex决定当前轮次。_moves记录玩家尝试次数。_matchedPairs记录成功配对。_isProcessing防止异步期间重复点击。
4.4 late 字段的使用
late List<IconData> _cards;
late List<bool> _revealed;
这两个字段会在 _initGame() 中初始化,因此使用 late 很合适。它们在页面首次构建前就会被赋值。
五、初始化与重新开局
5.1 initState 生命周期
void initState() {
super.initState();
_initGame();
}
initState() 在状态对象创建后调用,适合执行游戏初始化。
5.2 初始化函数
void _initGame() {
_cards = [..._icons, ..._icons]..shuffle();
_revealed = List.filled(_cards.length, false);
_firstIndex = null;
_secondIndex = null;
_moves = 0;
_matchedPairs = 0;
_isProcessing = false;
}
这个函数会重建全部游戏初始状态。
5.3 牌组生成
_cards = [..._icons, ..._icons]..shuffle();
这行代码先把 8 个图标复制一份,形成 16 张牌,再调用 shuffle() 打乱顺序。
5.4 翻开状态初始化
_revealed = List.filled(_cards.length, false);
List.filled 会创建与牌数相同长度的布尔列表,初始值全为 false,表示所有牌都处于隐藏状态。
5.5 重开入口
页面提供两个重开入口:
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _initGame,
)
以及胜利后的 Play Again 按钮,它们都调用 _initGame()。
六、翻牌点击逻辑
6.1 方法入口
玩家点击卡片时会调用 _onCardTap():
void _onCardTap(int index) {
if (_isProcessing || _revealed[index] || _secondIndex != null) return;
setState(() {
_revealed[index] = true;
if (_firstIndex == null) {
_firstIndex = index;
} else {
_secondIndex = index;
_moves++;
_checkMatch();
}
});
}
6.2 点击拦截
if (_isProcessing || _revealed[index] || _secondIndex != null) return;
这行代码拦截三类非法点击:
- 正在处理上一轮翻牌时不允许继续点击。
- 已翻开的牌不允许再次点击。
- 同一轮次已经翻出两张牌时不允许继续点第三张。
6.3 第一张牌
如果 _firstIndex 为空,说明当前是第一张牌:
_firstIndex = index;
此时只翻开一张牌,等待玩家选择第二张。
6.4 第二张牌
第二次点击后:
_secondIndex = index;
_moves++;
_checkMatch();
系统记录第二张牌、增加一次尝试次数,并立即进入配对判断。
七、配对判断逻辑
7.1 判断入口
void _checkMatch() {
_isProcessing = true;
if (_cards[_firstIndex!] == _cards[_secondIndex!]) {
setState(() {
_matchedPairs++;
_firstIndex = null;
_secondIndex = null;
_isProcessing = false;
});
} else {
Future.delayed(const Duration(milliseconds: 800), () {
setState(() {
_revealed[_firstIndex!] = false;
_revealed[_secondIndex!] = false;
_firstIndex = null;
_secondIndex = null;
_isProcessing = false;
});
});
}
}
7.2 匹配成功
当两张牌图标相同:
_matchedPairs++;
_firstIndex = null;
_secondIndex = null;
_isProcessing = false;
两张牌会一直保持翻开状态,下一轮直接开始。
7.3 匹配失败
如果两张牌不同,系统不会立即翻回,而是延迟 800 毫秒:
Future.delayed(const Duration(milliseconds: 800), () {
setState(() {
_revealed[_firstIndex!] = false;
_revealed[_secondIndex!] = false;
_firstIndex = null;
_secondIndex = null;
_isProcessing = false;
});
});
这个短暂延迟给玩家留出记忆和确认时间。
7.4 异步处理意义
_isProcessing 的存在非常关键。它避免了延迟翻回期间玩家继续点击其他牌,从而破坏当前轮次状态。
记忆翻牌这类游戏最容易出问题的地方,不是“判断是否相同”,而是“异步等待期间如何锁住输入”。
_isProcessing就是在解决这个问题。
7.5 状态轮次表
| 阶段 | _firstIndex |
_secondIndex |
_isProcessing |
|---|---|---|---|
| 未开始 | null |
null |
false |
| 选中第一张 | 有值 | null |
false |
| 选中第二张并判断 | 有值 | 有值 | true |
| 匹配成功后 | null |
null |
false |
| 匹配失败等待翻回 | 有值 | 有值 | true |
八、胜利统计与提示
8.1 胜利条件
在 build() 中,源码通过以下条件判断是否赢了:
final isWon = _matchedPairs == _icons.length;
_icons.length 是 8,所以当匹配对数达到 8 时即游戏胜利。
8.2 统计卡片
顶部状态卡片展示两个关键指标:
Text('$_moves')
Text('$_matchedPairs/${_icons.length}')
| 指标 | 含义 |
|---|---|
| Moves | 当前尝试次数 |
| Matched | 已匹配对数 / 总对数 |
8.3 胜利提示卡
当 isWon 为 true 时,页面会额外显示胜利卡片:
Card(
color: Colors.green.shade100,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.emoji_events, size: 32, color: Colors.green),
const SizedBox(width: 8),
Text(
'You won in $_moves moves!',
),
],
),
),
)
8.4 胜负反馈层次
| 状态 | 反馈方式 |
|---|---|
| 进行中 | 统计卡片显示 Moves 和 Matched |
| 胜利 | 增加绿色奖杯提示卡 |
| 重开 | AppBar 刷新按钮和 Play Again 按钮 |
九、卡片网格布局
9.1 GridView.builder
卡片区使用 GridView.builder:
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _cards.length,
itemBuilder: (context, index) { ... },
)
9.2 4 列布局
| 配置 | 作用 |
|---|---|
crossAxisCount: 4 |
一行显示 4 张牌 |
crossAxisSpacing: 8 |
横向间距 |
mainAxisSpacing: 8 |
纵向间距 |
16 张牌以 4x4 方式布局,适合大多数手机屏幕。
9.3 卡片交互
每张牌使用 GestureDetector 包裹 AnimatedContainer:
GestureDetector(
onTap: () => _onCardTap(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
color: isRevealed ? _getCardColor(index) : Colors.amber.shade300,
borderRadius: BorderRadius.circular(12),
),
),
)
9.4 动画容器
AnimatedContainer 会在颜色或样式变化时自动插值,提升翻牌的视觉流畅度。
十、卡片视觉状态
10.1 颜色判断
源码通过 _getCardColor() 控制翻开卡片的背景色:
Color _getCardColor(int index) {
if (!_revealed[index]) {
return Colors.amber;
}
return _cards[index] == _cards[_firstIndex!] && _secondIndex != null
? Colors.green.shade100
: Colors.white;
}
10.2 颜色含义
| 状态 | 背景色 |
|---|---|
| 未翻开 | 琥珀色 |
| 翻开且当前轮次匹配成功 | 浅绿色 |
| 翻开但非当前轮次匹配状态 | 白色 |
10.3 问号图标
未翻开的卡片显示问号:
Icon(
Icons.question_mark,
size: 32,
color: Colors.amber.shade700,
)
10.4 牌面图标
翻开后直接显示目标图标:
Icon(
_cards[index],
size: 32,
color: Colors.amber.shade700,
)
这种设计让游戏规则非常直观:玩家记住的是图标位置,而不是抽象数字。
十一、页面布局结构
11.1 Scaffold 骨架
页面主体如下:
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _initGame,
),
],
),
body: Column(
children: [
// 状态卡片
// 胜利提示
// 牌面网格
],
),
);
11.2 三层主体
| 区域 | 作用 |
|---|---|
| 顶部卡片 | 显示 Moves 和 Matched |
| 胜利提示 | 游戏完成后展示 |
| 卡片网格 | 实际翻牌交互区域 |
11.3 Expanded 网格区域
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: GridView.builder(...),
),
)
Expanded 让卡片网格占据剩余空间,避免上方状态区和下方网格互相挤压。
11.4 小屏关注点
如果屏幕较小,4 列网格中的卡片尺寸会变得较紧凑。鸿蒙端适配时需要重点确认:
- 卡片是否仍可点击。
- 图标是否清晰。
- 网格是否发生溢出。
十二、鸿蒙适配关注点
12.1 适配优势
memory_match_game 的跨端适配难度较低,因为它:
- 没有平台插件。
- 没有网络请求。
- 没有音效和文件访问。
- 没有复杂动画控制器。
- 主要依赖 Flutter 基础组件和 Dart 状态。
12.2 网格布局验证
鸿蒙端需要重点验证:
| 项目 | 验证内容 |
|---|---|
GridView.builder |
4 列网格是否稳定 |
AnimatedContainer |
翻牌过渡是否正常 |
GestureDetector |
单击是否准确触发 |
Future.delayed |
延迟翻回是否稳定 |
| 胜利提示 | 绿色卡片是否正常显示 |
12.3 异步流程验证
尤其要确认 _isProcessing 是否能在鸿蒙端正确阻止重复点击。翻牌游戏很容易在异步等待期间出现状态冲突,这一项一定要验证。
12.4 刷新行为验证
点击 AppBar 刷新后需要检查:
- 卡片是否重新洗牌。
- 所有牌是否重新隐藏。
- Moves 是否回到 0。
- Matched 是否回到 0。
- 胜利提示是否消失。
十三、测试设计与现有测试问题
13.1 当前测试状态
test/widget_test.dart 目前仍是 Flutter 默认计数器测试,会查找 0、1 和加号按钮。但实际页面是翻牌游戏,没有计数器文本,也没有加号按钮。
所以测试文件必须按真实页面改造。
13.2 初始页面测试
可以先验证标题和初始统计:
testWidgets('shows memory match game initially', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Memory Match'), findsOneWidget);
expect(find.text('0'), findsWidgets);
});
13.3 游戏板渲染测试
testWidgets('renders 16 cards', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.byType(GestureDetector), findsWidgets);
});
13.4 刷新按钮测试
testWidgets('refresh button resets the game', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.refresh));
await tester.pump();
expect(find.textContaining('Moves'), findsOneWidget);
});
13.5 游戏胜利测试思路
由于牌面是随机洗牌的,完整胜利测试更适合拆成纯逻辑单元测试。例如测试匹配判断、计数增长和重置逻辑,而不是强依赖具体图标顺序。
十四、代码质量与可维护性
14.1 当前实现优点
memory_match_game 的源码有几个明显优点:
- 游戏逻辑集中,阅读顺序清晰。
- 状态字段数量不多。
- 配对判断直观。
- 延迟翻回逻辑简洁。
- 网格布局天然适合卡片游戏。
- 重开流程统一。
14.2 可抽离的牌组模型
如果继续扩展,可以把牌面抽象成模型:
class MemoryCard {
const MemoryCard({
required this.icon,
required this.revealed,
required this.matched,
});
final IconData icon;
final bool revealed;
final bool matched;
}
14.3 可抽离的配对函数
配对逻辑也可以做成纯函数:
bool isMatch(IconData first, IconData second) => first == second;
这样可以更容易做单元测试。
14.4 状态管理可演进
当前项目用本地 State 已经够用。如果未来要增加难度、计时、排行榜或音效,可以考虑把游戏状态抽到独立控制器或状态管理层。
十五、性能与体验优化
15.1 算法复杂度
翻牌游戏的主要操作都是常数级或线性级,整体性能压力很小:
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 随机洗牌 | O(n) | 对 16 张牌进行打乱 |
| 点击判断 | O(1) | 单张卡片状态变更 |
| 匹配检查 | O(1) | 比较两张 IconData |
| 胜利判断 | O(1) | 对已匹配对数进行比较 |
15.2 动画时长
duration: const Duration(milliseconds: 300)
300 毫秒的动画配合 800 毫秒的翻回延迟,整体节奏比较适中。
15.3 点击区域
当前卡片是整个 GestureDetector 容器可点击。这个设计比只点图标更友好,尤其在移动设备上更容易命中。
15.4 体验可优化点
可以继续增强:
- 增加开局倒计时。
- 增加配对音效。
- 增加难度模式。
- 增加关卡关卡词库。
- 增加翻牌失败动画。
十六、常见问题与优化建议
16.1 为什么牌面一开始都是问号
因为 _revealed 初始全是 false,卡片构建时会显示问号图标。
16.2 为什么同一轮只能点两张牌
_onCardTap() 通过 _secondIndex != null 拦截第三次点击,确保一轮只处理两张牌。
16.3 为什么不匹配后不会立刻翻回
源码用 Future.delayed(const Duration(milliseconds: 800)) 给玩家留出短暂观察时间。
16.4 为什么赢了之后还能点刷新
刷新按钮始终保留在 AppBar 中,可以随时重新开始一局。
16.5 为什么测试文件会失败
默认测试仍在查找计数器页面的文本和按钮,而实际页面是翻牌游戏,所以测试目标不匹配。
16.6 能不能加入计时器
可以,但当前源码没有实现。若要加计时器,需要在状态中维护开始时间和结束时间,并在胜利后计算总耗时。
十七、核心知识点速查
17.1 Widget 速查
| Widget | 使用位置 | 作用 |
|---|---|---|
MaterialApp |
根组件 | 应用配置 |
Scaffold |
页面骨架 | AppBar 和 Body |
Card |
状态和提示区域 | 信息分区 |
GridView.builder |
卡片网格 | 动态生成 16 张牌 |
GestureDetector |
卡片点击 | 捕获翻牌动作 |
AnimatedContainer |
卡片外观 | 提供翻牌过渡 |
IconButton |
AppBar | 刷新游戏 |
Expanded |
网格区域 | 占据剩余空间 |
17.2 方法速查
| 方法 | 作用 |
|---|---|
_initGame() |
随机生成牌组并重置状态 |
_onCardTap() |
处理卡片点击 |
_checkMatch() |
判断是否配对成功 |
_getCardColor() |
返回卡片背景色 |
17.3 状态速查
| 状态 | 变化来源 | 影响 UI |
|---|---|---|
_cards |
开局洗牌 | 决定牌面图标 |
_revealed |
点击卡片后更新 | 决定是否显示图标 |
_firstIndex |
第一次点击 | 记录第一张牌 |
_secondIndex |
第二次点击 | 记录第二张牌 |
_moves |
每轮第二次点击后增加 | 顶部统计 |
_matchedPairs |
匹配成功时增加 | 胜利判断 |
_isProcessing |
异步翻回期间为 true | 防止乱点 |
十八、扩展方向
18.1 功能扩展
可以在当前项目上继续增加:
- 计时器。
- 最高分记录。
- 难度模式。
- 关卡系统。
- 主题切换。
18.2 UI 扩展
界面层可以进一步增强:
- 加入翻牌动画。
- 加入匹配成功特效。
- 加入失败抖动效果。
- 加入开始页。
- 加入重新开始弹窗。
18.3 工程扩展
工程层可以继续演进:
- 抽离状态模型。
- 抽离纯逻辑函数。
- 增加更贴合页面的测试。
- 增加鸿蒙端专项验证。
18.4 跨端实践价值
memory_match_game 适合作为 Flutter 适配鸿蒙的小游戏样例。它覆盖了随机洗牌、网格布局、异步判断、状态卡片和胜利提示,能帮助开发者验证普通 Flutter 游戏交互在鸿蒙端的表现。
总结
memory_match_game 用一份简洁的 Flutter 源码实现了本地翻牌记忆游戏。它通过 _icons 提供基础牌面,通过 _cards 保存洗牌后的 16 张牌,通过 _revealed 控制翻开状态,通过 _firstIndex、_secondIndex 和 _isProcessing 管理一轮翻牌流程,并通过 _moves 和 _matchedPairs 完成胜利统计。
从工程角度看,这个项目最值得学习的是 双选配对状态管理 和 异步延迟翻回。它没有复杂依赖,适合用于 Flutter 小游戏入门、网格交互练习和鸿蒙端 UI 验证。需要注意的是,当前源码是本地单机演示,不包含计时器、排行榜、难度模式或网络功能。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)