本文依据 Jetpack Compose 结构,采用 MVI 架构完结了一个简略的贪吃蛇游戏,展现了 MVI 在 Jetpack Compose 中的方式,并依据 CompositionLocal 完结了简略的换肤功用(可保存至本地)

点此下载 demo:app-debug.apk

运转效果

Jetpack Compose + MVI 实现一个简易贪吃蛇

Jetpack Compose + MVI 实现一个简易贪吃蛇
Jetpack Compose + MVI 实现一个简易贪吃蛇

环境

  • Gradle 8.0,这需求 Java17 及以上版别
  • Jetpack Compose BOM: 2023.03.00,我之后也会写一篇文章介绍这个版别的更新内容
  • Compose 编译器版别:1.4.0

什么是 MVI

Jetpack Compose + 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 根本结构如下:

class SnakeGameViewModel : ViewModel() {
    // snakeState,UI 观察它的改动来展现不同的画面  
    val snakeState = mutableStateOf(
        SnakeState(
            snake = INITIAL_SNAKE,
            size = 400 to 400,
            blockSize = Size(20f, 20f),
            food = generateFood(INITIAL_SNAKE.body)
        )
    )
    // 分发 GameAction
    fun dispatch(gameAction: GameAction) {
        snakeState.value = reduce(snakeState.value, gameAction)
    }
    // 依据不同的 gameAction 做不同的处理,并回来新的 snakeState(经过 copy)
    private fun reduce(state: SnakeState, gameAction: GameAction): SnakeState {
        val snake = state.snake
        return when (gameAction) {
            GameAction.GameTick -> state.copy(/*...*/)
            GameAction.StartGame -> state.copy(gameState = GameState.PLAYING)
            //  ...
        }
    }
}

完好代码见:SnakeGameViewModel.kt

UI

因为代码的逻辑均交给了 ViewModel,所以 UI 层的代码量十分少,只需求关注 UI 的展现即可。

@Composable
fun ColumnScope.Playing(
    snakeState: SnakeState,
    snakeAssets: SnakeAssets,
    dispatchAction: (GameAction) -> Unit
) {
    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .square()
            .onGloballyPositioned {
                val size = it.size
                dispatchAction(GameAction.ChangeSize(size.width to size.height))
            }
            .detectDirectionalMove {
                dispatchAction(GameAction.MoveSnake(it))
            }
    ) {
        drawBackgroundGrid(snakeState, snakeAssets)
        drawSnake(snakeState, snakeAssets)
        drawFood(snakeState, snakeAssets)
    }
}

上面的代码运用 Canvas 作为画布,经过自界说的 square 修饰符使其长宽相等,经过 drawBackgroundGriddrawSnakedrawFood 绘制游戏的背景、蛇和食物。 完好代码见:SnakeGame.kt

主题

本项目自带了一个简略的主题示例,设置不同的主题能够更改蛇的色彩、食物的色彩等

Jetpack Compose + MVI 实现一个简易贪吃蛇
Jetpack Compose + MVI 实现一个简易贪吃蛇

看起来差异不大,但是主要目的在于演示 CompositionLocal 的根本用法

主题功用的完结依据 CompositionLocal,详细介绍能够参阅 官方文档:运用 CompositionLocal 将数据的效果域限定在局部 。简略来说,父 Composable 运用它,一切子 Composable 中都能获取到对应值,咱们所熟悉的 MaterialTheme 便是经过它完结的。 详细完结如下:

界说类

咱们先界说一个密闭类,表示咱们的主题

sealed class SnakeAssets(
    val foodColor: Color= MaterialColors.Orange700,
    val lineColor: Color= Color.LightGray.copy(alpha = 0.8f),
    val headColor: Color= MaterialColors.Red700,
    val bodyColor: Color= MaterialColors.Blue200
) {
    object SnakeAssets1: SnakeAssets()
    object SnakeAssets2: SnakeAssets(
        foodColor = MaterialColors.Purple700,
        lineColor = MaterialColors.Brown200.copy(alpha = 0.8f),
        headColor = MaterialColors.Blue700,
        bodyColor = MaterialColors.Pink300
    )
}

上面的 MaterialColors 来自库 FunnySaltyFish/CMaterialColors: 在 Jetpack Compose 中运用 Material Design Color

运用

咱们需求先界说一个 ProvidableCompositionLocal,在这里,因为主题的变化频率相对较低,因而选用 staticCompositionLocalOf 。之后,在 SnakeGame 外面经过 provide 中缀函数指定咱们的 Assets

internal val LocalSnakeAssets: ProvidableCompositionLocal<SnakeAssets> = staticCompositionLocalOf { SnakeAssets.SnakeAssets1 }
// ....
val snakeAssets by ThemeConfig.savedSnakeAssets
CompositionLocalProvider(LocalSnakeAssets provides snakeAssets) {
    SnakeGame()
}

只需求改动 ThemeConfig.savedSnakeAssets 的值,即可全局更改主题款式啦

保存装备到本地(耐久化)

咱们希望用户选择的主题能在下一次翻开应用时依然收效,因而能够把它保存到本地。这里借助的是开源库 FunnySaltyFish/ComposeDataSaver: 在Jetpack Compose中优雅完结数据耐久化。经过它,能够用类似于 rememberState 的方式轻松做到这一点

结构自带了关于根本数据类型的支持,不过因为要保存 SnakeAssets 这个自界说类型,咱们需求提前注册下类型转换器。

class App: Application() {
    override fun onCreate() {
        super.onCreate()
        DataSaverUtils = DataSaverPreferences(this)
        // SnakeAssets 使咱们自界说的类型,因而先注册一下转换器,能让它保存时主动转化为 String,读取时主动从 String 恢复成 SnakeAssets
        DataSaverConverter.registerTypeConverters(save = SnakeAssets.Saver, restore = SnakeAssets.Restorer)
    }
    companion object {
        lateinit var DataSaverUtils: DataSaverInterface
    }
}

然后在 ThemeConfig 中创立一个 DataSaverState 即可

val savedSnakeAssets: MutableState<SnakeAssets> = mutableDataSaverStateOf(DataSaverUtils ,key = "saved_snake_assets", initialValue = SnakeAssets.SnakeAssets1)

之后,对 savedSnakeAssets 的赋值都会主动触发 异步的耐久化操作,下次翻开应用时也会主动读取。

其他

其实这个项目最早创立于 2022-02 ,作为学习 Compose MVI 的项目。但鉴于当时对 Compose 不那么熟练,写着写着抛弃了;直到 2023-03-31,我在收拾 FunnySaltyFish/JetpackComposeStudy: 本人 Jetpack Compose 主题文章所包含的示例,包含自界说布局、部分组件用法等 时又翻到了这个被我遗忘多时的老项目,于是一时兴起,花了两三个小时把它完结了,并写下了这个 README。或许对后人有些参阅?

项目还附带了一份 Python 的 Pygame 完结的版别,见 python_version 文件夹,运转 main.py 即可

还有一点有趣的工作,当我把 AS 升级到 F(火烈鸟)RC 版别时,发现新建项目时,现已把 Material3 的 Compose 模块放到了第一位了。Google 官方关于推广 Jetpack Compose 的态度,看起来仍是很高涨的。或许这样看来,Compose 仍是有必要学一学的;毕竟即使是 ChatGPT,因为练习集只到 21 年,在写 Compose 的代码上表现也还不尽善尽美呢。(当然,关于下一代、下下一代来说,或许这都不是问题了。但至少不是现在。)

Jetpack Compose + MVI 实现一个简易贪吃蛇

源码

  • github.com/FunnySaltyF…

参阅

  • 爷童回!Compose + MVI 打造经典版的俄罗斯方块 – ()
  • 100 行写一个 Compose 版华容道 – ()

额定感谢

Copilot、ChatGPT

本文正在参与「金石计划」