Jetpack Compose + MVI 实现一个简易贪吃蛇
本文基于 Jetpack Compose 框架,采用 MVI 架构实现了一个简单的贪吃蛇游戏,展示了 MVI 在 Jetpack Compose 中的形式,并基于 CompositionLocal 实现了简单的换肤功能(可保存至本地)
点此下载 demo:app-debug.apk
运行效果
环境
- Gradle 8.0,这需要 Java17 及以上版本
- Jetpack Compose BOM: 2023.03.00
- Compose 编译器版本:1.4.0
什么是 MVI
MVI 是 Model-View-Intent 的缩写,是一种架构模式,它的核心思想是将 UI 的状态抽象为一个单一的数据流,这个数据流由 View 发出的 Intent 作为输入,经过 Model 处理后,再由 View 显示出来。
具体到本项目,View 是贪吃蛇的游戏界面,Model 是游戏的逻辑,Intent 是用户和系统的操作,比如开始游戏、更改方向等。
- View层:基于 Compose 打造,所有 UI 元素都由代码实现
- Model层:ViewModel 维护 State 的变化,游戏逻辑交由 reduce 处理
- V-M通信:通过 State 驱动 Compose 刷新,事件由 Action 分发至 ViewModel
ViewModel 基本结构如下:
1 | class SnakeGameViewModel : ViewModel() { |
完整代码见:SnakeGameViewModel.kt
UI
由于代码的逻辑均交给了 ViewModel,所以 UI 层的代码量非常少,只需要关注 UI 的展示即可。
1 |
|
上面的代码使用 Canvas
作为画布,通过自定义的 square
修饰符使其长宽相等,通过 drawBackgroundGrid
、drawSnake
、drawFood
绘制游戏的背景、蛇和食物。
完整代码见:SnakeGame.kt
游戏逻辑
蛇的定义
我们定义一个类 Snake
用于表示蛇,在这里,出于实现上的便捷,我们假定蛇的身体由“一个个方块”组成,且相邻两个方块之间是紧挨着的(转弯的时候不会有弧度)。Snake
类的定义如下:
1 |
|
蛇的身体由双向链表组成,这样方便在移动的时候修改位置;具体来说,从 index 0 -> index N-1 分别表示蛇的头和其余身体。它还额外有一个变量 direction
表示当前的移动方向。
移动
贪吃蛇的主体逻辑,最困难的地方在于蛇的移动。举个栗子,假设蛇向右移动一步,则:
观察上图,我们可以发现,其实真正变化的只有两个地方:
- 蛇的尾部“消失”
- 在蛇的移动方向上,新增一个方块,成为新的蛇头
因此,蛇的移动可以写成:
1 | fun move(pos: Point) = this.copy(body = this.body.apply { |
那么吃了食物呢?在那种情况下,蛇的身体会“生长”一节。
仔细观察上图,我们发现,其实蛇的“生长”相比“移动”,就是少了一个“去掉尾巴”的过程。代码可以表示成:
1 | fun grow(pos: Point) = this.apply { |
不断移动
明确了“蛇”的生长和移动,接下来的游戏就简单了,我们只需要让蛇每一段时间移动(或生长)一次,就完成了让蛇动起来的目标。
在 Composable 中,利用 LaunchedEffect
和 while
循环就可以完成
1 | val vm: SnakeGameViewModel = viewModel() |
snakeState.getSleepTime()
和蛇的长度负相关,蛇越长,sleep
时间越短,达到加快速度的效果
主题
本项目自带了一个简单的主题示例,设置不同的主题可以更改蛇的颜色、食物的颜色等
看起来区别不大,但是主要目的在于演示 CompositionLocal 的基本用法
主题功能的实现基于 CompositionLocal
,具体介绍可以参考 官方文档:使用 CompositionLocal 将数据的作用域限定在局部 。简单来说,父 Composable 使用它,所有子 Composable 中都能获取到对应值,我们所熟悉的 MaterialTheme
就是通过它实现的。
具体实现如下:
定义类
我们先定义一个密闭类,表示我们的主题
1 | sealed class SnakeAssets( |
上面的 MaterialColors
来自库 FunnySaltyFish/CMaterialColors: 在 Jetpack Compose 中使用 Material Design Color
使用
我们需要先定义一个 ProvidableCompositionLocal
,在这里,因为主题的变动频率相对较低,因此选用 staticCompositionLocalOf
。之后,在 SnakeGame
外面通过 provide
中缀函数指定我们的 Assets
1 | internal val LocalSnakeAssets: ProvidableCompositionLocal<SnakeAssets> = staticCompositionLocalOf { SnakeAssets.SnakeAssets1 } |
只需要改变 ThemeConfig.savedSnakeAssets
的值,即可全局更改主题样式啦
保存配置到本地(持久化)
我们希望用户选择的主题能在下一次打开应用时仍然生效,因此可以把它保存到本地。这里借助的是开源库 FunnySaltyFish/ComposeDataSaver: 在Jetpack Compose中优雅完成数据持久化。通过它,可以用类似于 rememberState
的方式轻松做到这一点
框架自带了对于基本数据类型的支持,不过由于要保存 SnakeAssets
这个自定义类型,我们需要提前注册下类型转换器。
1 | class App: Application() { |
然后在 ThemeConfig
中创建一个 DataSaverState
即可
1 | val savedSnakeAssets: MutableState<SnakeAssets> = mutableDataSaverStateOf(DataSaverUtils ,key = "saved_snake_assets", initialValue = SnakeAssets.SnakeAssets1) |
之后,对 savedSnakeAssets
的赋值都会自动触发 异步的持久化操作
,下次打开应用时也会自动读取。
其他
项目还附带了一份 Python 的 Pygame 实现的版本,见仓库的 python_version
文件夹,运行 main.py
即可
还有一点有趣的事情,当我把 AS 升级到 F(火烈鸟)RC 版本时,发现新建项目时,已经把 Material3 的 Compose 模板放到了第一位了。Google 官方对于推行 Jetpack Compose 的态度,看起来还是很高涨的。所以,各位开发者们,学起来吧!
源码
参考
额外感谢
Github Copilot、ChatGPT