compose-a-tetris 使用 compose API 在安卓上实现一个俄罗斯方块

Posted on May 26, 2022

写在前面

为了应付安卓课的大作业,又写了一个俄罗斯方块。很幸运的是,确实学到了很多知识,虽然这些知识可能没什么用,但是还是非常的有意思。上一次写俄罗斯方块是高三的时候是在 hp-39gii 图形机上拿着简陋的 hp-basic 写的,最后写出来的结果是这样的

陪我度过高三的 hp-39gii

遗憾的是当时的源代码已经丢失了,编译后的字节码倒还是在,重新导入回计算器还能把源码找回来,但是我已经懒得折腾这个了,属于是时代的眼泪了。

话说回来,这学期选了一节安卓移动开发的专业选修课,当时是有打算入门一下安卓,之后浏览器或者内核安全研究不下去了,也可以试试移动安全。不过上了课发现这课主要的可能还是偏向于让我们能写出来一个 app,而不是了解安卓的思想。学到的东西也是 api 怎么用,gui 怎么调。不能说没意思吧,其实做点实际的东西出来还是很有成就感的。期末作业有许多选择,都不太感兴趣。然后正好看到这篇文章:用Jetpack Compose做一个俄罗斯方块游戏机,我也不知道 jetpack 或者 compose 是什么,里面还提到了 MVI 架构,都是没接触过的名词,为了学习也好,为了以后吹牛皮也好,为了抄起来方便也好,我就选择了拿 compose 写俄罗斯方块这个作业。真的非常感谢这位作者:),我吹爆!

功能实现方面呢,参考的文章里面只实现了一个很炫酷的游戏界面,课程的要求为了让我们涉及更多的东西,还要求要做到

  • 只能有五种方块,这个好解决,生成的时候限制一下范围就行
  • 需要能把分数存到数据库里面
  • 需要能够按名称和按起止时间搜索记录
  • 挂了之后要能播放音乐

首先照着文章抄,把游戏主体逻辑实现了,这里我花的时间最少,(绝对不是因为有的抄!),俄罗斯方块这个游戏的特点就是不断地等待用户输入,输入了之后渲染界面(spirit 会持续下落,也可以理解为是用户的输入),这种模型据说非常符号前端的开发思想,数据驱动。

MVI

这里使用的主要就是 MVI 架构了,即

  • Model:主要指 UI 的状态。UI 本质就是一堆控件分布在各个位置上。我们稍微抽象一下就可以把 UI 状态存到一个数据类中,我们称之为一个 state。
  • View:与 MV* 中的 View 一样,指任意一个 Activity、Fragment 等 UI 承载单元。在这个项目中,使用了 compose 这套较新的 API,其自动、智能重组的特性也很适合做 View 层。
  • Intent:这个 intent 指的是用户的操作的意图(和 Activity 中的那个 Intent 不是一个东西)。把它封装到一个 Action 中再发送给 Model 进行数据请求(一次 reduce 操作)。

这个架构模式满足单向数据流,整个流向如下

mvi

也就是说流程为:用户输入封装为 intent 发送给 ViewModel,ViewModel 根据 intent 进行 reduce 更新 state,View 根据 state 刷新 UI,再显示给用户。

抄下一下网络上总结的架构优缺点

优点:

  • UI的所有变化来自State,所以只需聚焦State,架构更简单、易于调试
  • 数据单向流动,很容易对状态变化进行跟踪和回溯
  • state实例都是不可变的,确保线程安全
  • UI只是反应State的变化,没有额外逻辑,可以被轻松替换或复用

缺点:

  • 所有的操作最终都会转换成State,所以当复杂页面的State容易膨胀
  • state是不变的,每当state需要更新时都要创建新对象替代老对象,这会带来一定内存开销
  • 有些事件类的UI变化不适合用state描述,例如弹出一个 toast 或者 snackbar

安卓为开发者提供了非常多的基础类,让 MVI 的实现变得十分容易,接下来我以此俄罗斯方块项目为例展示一下。

View

首先来实现 view 层,这里使用 compose 这个函数式、声明式的 api。

先实现一个方块的绘制

// this function can draw a single Brick
// to a single Brick, there is there part
// |--------|
// ||------||
// |||----|||
// |||    |||
// |||----|||
// ||------||
// |--------|
// 1.0 0.8 0.5
// here we should draw the inner two part
// named inner part and outer part
private fun DrawScope.drawBrick(
    brickSize : Float,
    relativeOffset : Offset,
    color : Color
) {
    val location = Offset(
        relativeOffset.x * brickSize,
        relativeOffset.y * brickSize
    )

    val outerSize = brickSize * 0.8f
    val outerOffset = (brickSize - outerSize) / 2f

    drawRect(
        color = color,
        topLeft = location + Offset(outerOffset, outerOffset),
        size = Size(outerSize, outerSize),
        style = Stroke(outerSize / 10f)
    )

    val innerSize = brickSize * 0.5f
    val innerOffset = (brickSize - innerSize) / 2f

    drawRect(
        color = color,
        topLeft = location + Offset(innerOffset, innerOffset),
        size = Size(innerSize, innerSize),
    )
}

preview 出来看看

@Preview(showBackground = true)
@Composable
fun BrickPreview() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        drawBrick(size.width, Offset(0f, 0f), Color.Green)
    }
}

single brick

效果不错。compose 的一大优点就是能够实时预览 UI 形状,效果和真机的差距也不是很大。

然后把整个背景板画出来

fun DrawScope.drawGrid(
    blockSize : Float,
    gridSize : Pair<Int, Int>
) {
    (0 until gridSize.first).forEach { x ->
        (0 until gridSize.second).forEach { y ->
            drawBrick(
                blockSize,
                Offset(x.toFloat(), y.toFloat()),
                BrickGrid
            )
        }
    }
}

preview 一下效果如下

background

然后我们定义所有的下落方块(spirit)的形状,形状通过一个 Offset 链表描述

val SpiritType = listOf(
    listOf(Offset(1f, -1f), Offset(1f, 0f), Offset(0f, 0f), Offset(0f, 1f)),//Z
    listOf(Offset(0f, -1f), Offset(0f, 0f), Offset(0f, 1f), Offset(0f, 2f)),//I
    listOf(Offset(0f, 1f), Offset(0f, 0f), Offset(0f, -1f), Offset(1f, 0f)),//T
    listOf(Offset(1f, 0f), Offset(0f, 0f), Offset(1f, -1f), Offset(0f, -1f)),//O
    listOf(Offset(1f, -1f), Offset(0f, -1f), Offset(0f, 0f), Offset(0f, 1f)),//J

    // here starts the unwanted
    listOf(Offset(0f, -1f), Offset(1f, -1f), Offset(1f, 0f), Offset(1f, 1f)),//L
    listOf(Offset(0f, -1f), Offset(0f, 0f), Offset(1f, 0f), Offset(1f, 1f)),//S

)

课程的作业要求只能有前五种方块,很好解决,生成的时候限制一下随机数的范围即可。然后定义一下他们的颜色

val SpiritColor = listOf(
    Color.Blue,
    Color.Red,
    Color.Yellow,
    Color.Green,
    Color.Magenta,
    Color.Cyan,
    Color.Black
)

实现 spirit 的绘制

fun DrawScope.drawSpirit(spirit: Spirit, brickSize: Float, gridSize: Pair<Int, Int>) {
    clipRect(0f, 0f, gridSize.first * brickSize, gridSize.second * brickSize) {
        spirit.location.forEach {
            drawBrick(
                brickSize,
                it,
                spirit.color
            )
        }
    }
}

遍历一遍链表就可以了。

然后我们把三者结合起来

@Composable
fun GridScreen(modifier: Modifier = Modifier) {
    val viewModel = viewModel<GameViewModel>()
    val viewState = viewModel.viewState.value


    Box(
        modifier = modifier
            .background(Color.White)
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            val brickSize = min(
                size.width / viewState.grid.first,
                size.height / viewState.grid.second)


            drawGrid(blockSize = brickSize, gridSize = viewState.grid)
            drawSpirit(
                spirit = viewState.spirit,
                brickSize = brickSize,
                gridSize = viewState.grid)
            drawBricks(brickSize = brickSize, bricks = viewState.bricks)
        }
    }
}

注意开头取得了 viewModel。我没有深究过其实现,因为不懂 Java,kotlin 对我来说更像是黑魔法。不过大概能猜出来大概是一个单例+被观察者(即主题)。编译器可能会做点什么操作让这个函数自动变成一个 observer,然后每当 viewModel 改变的时候都会重新调用这个函数(也就是进行 compose 中的重组)。这里也能感受到 kotlin 真的是一个很方便的语言,很多设计模式都隐藏好了,甚至已经做到了对开发者透明(代价就是不知道他到底做了什么,让人感觉就是某种黑魔法)。这样 view 层基本完事了,还差一些按钮来与用户交互,首先定义一个 clickable 类,描述所有的可点击操作

data class Clickable constructor(
    val onMove: (Direction) -> Unit,
    val onRotate: () -> Unit,
    val onPause: () -> Unit,
    val onReset: () -> Unit,
    val onExit: () -> Unit,
)

fun combineClickable (
    onMove: (Direction) -> Unit = {},
    onRotate: () -> Unit = {},
    onPause: () -> Unit = {},
    onReset: () -> Unit = {},
    onExit: () -> Unit = {},
) = Clickable(onMove, onRotate, onPause, onReset, onExit)

然后绘制出“状态改变按钮”,并注册相应的 onClick 方法

@Composable
fun GameStateController(
    clickable: Clickable = combineClickable(),
    modifier: Modifier = Modifier
) {
    Box(modifier = modifier
        .padding(5.dp)
        .width(StateButtonWidth * 3f * 1.5f)
        .height(StateButtonHeight)
    ) {
        Button(
            onClick = clickable.onPause,
            modifier = Modifier
                .align(Alignment.Center)
                .height(StateButtonHeight)
                .width(StateButtonWidth)
        ) {
            Text(
                text = stringResource(id = R.string.button_pause_str),
                fontSize = 10.sp
            )
        }
        Button(
            onClick = clickable.onReset,
            modifier = Modifier
                .align(Alignment.CenterStart)
                .height(StateButtonHeight)
                .width(StateButtonWidth)
        ) {
            Text(
                text = stringResource(id = R.string.button_reset_str),
                fontSize = 12.sp
            )
        }
        Button(
            onClick = clickable.onExit,
            modifier = Modifier
                .align(Alignment.CenterEnd)
                .height(StateButtonHeight)
                .width(StateButtonWidth)
        ) {
            Text(
                text = stringResource(id = R.string.button_exit_str),
                fontSize = 12.sp
            )
        }
    }
}

再绘制出方向键

@Composable
fun DirectionButtonAssembly(
    directionButtonSize : Dp,
    modifier: Modifier = Modifier,
    onMove: (Direction) -> Unit = {}
) {

    val buttonText = @Composable {
            textModifier : Modifier,
            text : String ->
        Text(
            text = text,
            color = Color.White,
            fontSize = 25.sp,
            modifier = textModifier
        )
    }

    Box(modifier = modifier.size(directionButtonSize * 2.5f)
        ) {
        BasicButton(
            modifier = Modifier.align(Alignment.TopCenter),
            size = directionButtonSize,
            onClick = { onMove(Direction.Up) }
        ) {
            buttonText(it.align(Alignment.Center), stringResource(id = R.string.button_up_str))
        }
        BasicButton(
            modifier = Modifier.align(Alignment.CenterStart),
            size = directionButtonSize,
            onClick = { onMove(Direction.Left) }
        ) {
            buttonText(it, stringResource(id = R.string.button_left_str))
        }
        BasicButton(
            modifier = Modifier.align(Alignment.CenterEnd),
            size = directionButtonSize,
            onClick = { onMove(Direction.Right) }
        ) {
            buttonText(it, stringResource(id = R.string.button_right_str))
        }
        BasicButton(
            modifier = Modifier.align(Alignment.BottomCenter),
            size = directionButtonSize,
            onClick = { onMove(Direction.Down) }
        ) {
            buttonText(it, stringResource(id = R.string.button_down_str))
        }
    }
}

还有 Rotate

@Composable
fun RotateButton(rotateButtonSize : Dp, modifier: Modifier, onRotate: () -> Unit = {}) {
    BasicButton(
        modifier = modifier,
        size = rotateButtonSize,
        onClick = onRotate
    ) {
        Text(
            text = stringResource(id = R.string.button_rotate_str),
            color = Color.White,
            fontSize = 22.sp,
            modifier = it
        )
    }
}

@Composable
fun GameMoveController(
    clickable: Clickable = combineClickable(),
    modifier: Modifier = Modifier
) {
    Box(modifier = modifier
        .width(DirectionButtonSize * 2.5f + RotateButtonSize * 1.5f)
        .height(DirectionButtonSize * 2.5f)
    ) {
        DirectionButtonAssembly(
            directionButtonSize = DirectionButtonSize,
            modifier = Modifier.align(Alignment.CenterStart),
            onMove = clickable.onMove
        )

        RotateButton(
            rotateButtonSize = RotateButtonSize,
            modifier = Modifier.align(Alignment.CenterEnd),
            onRotate = clickable.onRotate
        )
    }
}

还有一些对于分数、下一个 spirit 的绘制,这里不一一赘述了,最后的 preview 是这样

game screen

ViewModel

然后我们实现 viewModel。首先定义 UI 样式的 state

data class ViewState(
    val bricks : List<Brick> = emptyList(),
    val spirit: Spirit = Empty,
    val nextSpirit : Spirit = Empty,
    val grid : Pair<Int, Int> = GridWidth to GridHeight,
    val gameStatus : GameStatus = GameStatus.OnBoard,
    val score : Int = 0,
    val linesCleared : Int = 0,
) {
    val isRunning
        get() = gameStatus == GameStatus.Running

    val isPaused
        get() = gameStatus == GameStatus.Paused

    val isOnBoard
        get() = gameStatus == GameStatus.OnBoard

    val isGameOver
        get() = gameStatus == GameStatus.GameOver

    val isOnGameOverAnimation
        get() = gameStatus == GameStatus.OnGameOverAnimation
}

然后在 viewModel 里面存上这个 mutableState

class GameViewModel : ViewModel() {
    private val _viewState : MutableState<ViewState> = mutableStateOf(ViewState())
    val viewState : State<ViewState> = _viewState

暴露出一个只读的 viewState 让 UI 层读取。

然后只要实现 reduce 方法来更新数据就可以了

    fun dispatch(action: Action) {
        _viewState.value = reduce(viewState.value, action)
    }

    private fun reduce(state: ViewState, action: Action): ViewState =
        when(action) {
            Action.Reset -> {
                ...
            }
            Action.Move -> {
                ...
            }
            ...
        }

具体代码这里就不放了,俄罗斯方块的底层逻辑不在我们的讨论范围内

然后就可以写出所有的 click 方法了

combineClickable (
    onMove = {direction : Direction ->
        if(direction == Direction.Up) viewModel.dispatch(Action.DropImm)
        else viewModel.dispatch(Action.Move(direction))
    },
    onReset = {
        viewModel.dispatch(Action.Reset)
    },
    onRotate = {
        viewModel.dispatch(Action.Rotate)
    },
    onPause = {
        viewModel.dispatch(Action.Pause)
    },
    onExit = {
        // go the main activity
        val intent = Intent(context, MainActivity::class.java)
        context.startActivity(intent)
    }

Intent

这里 intent 就比较简单了,一个 action 即可描述

sealed interface Action {
    data class Move(val direction: Direction) : Action
    object Reset : Action
    object Pause : Action
    object Rotate : Action
    object DropImm : Action
    object Exit : Action
    object Tick: Action
}

其他主题

MVI 到这里就结束了,这个俄罗斯方块写到这里就可以跑了。不过之后还有一些别的需求,这里就没有代码可以参考了,我们一个个来讨论

分数存储

首先挂的时候,弹一个 alert 出来要求用户输入名字,这个 alert 在 jetpack compose 中也有,也就是 AlertDialog

@Preview(showBackground = true)
@Composable
fun GameOverAlert() {
    val context = LocalContext.current
    val openDialog = remember { mutableStateOf(true) }
    val viewModel = viewModel<GameViewModel>()
    val viewState = viewModel.viewState.value
    val userName = remember { mutableStateOf("") }
    val dbHelper = ScoreDBHelper(context)

    if (openDialog.value) {
        AlertDialog(
            onDismissRequest = {
                openDialog.value = false
            },
            title = {
                Text(text = GAME_OVER_ALERT_TITLE)
            },
            text = {
                Column {
                    Text(text = INPUT_NAME_HINT)
                    TextField(
                        value = userName.value,
                        onValueChange = {
                            userName.value = it
                        },
                        maxLines = 1
                    )
                }
            },
            confirmButton = {
                TextButton(onClick = {
                    openDialog.value = false
                    Toast.makeText(context, "saving..", Toast.LENGTH_SHORT).show()
                    val record = ScoreContract.Record(
                        viewState.score,
                        System.currentTimeMillis(),
                        userName.value
                    )
                    dbHelper.insertScore(record)
                }) {
                    Text("Save")
                }
            },
            dismissButton = {
                TextButton(onClick = {
                    openDialog.value = false
                }
                ) {
                    Text("Don't Save")
                }
            }
        )
    } else {
        viewModel.reset()
    }
}

preview 的效果如下

lost alert

在 text 中组合一个 TextField 用于输入名字,用一个 mutableState 来维护名字。存储的时候,在 onClick 方法中插入数据库

                    openDialog.value = false
                    Toast.makeText(context, "saving..", Toast.LENGTH_SHORT).show()
                    val record = ScoreContract.Record(
                        viewState.score,
                        System.currentTimeMillis(),
                        userName.value
                    )
                    dbHelper.insertScore(record)

这里没有搞协程来做,因为懒。

然后是数据库操作,参考着谷歌的文档,首先搞个 contract

object ScoreContract {

    object ScoreEntry : BaseColumns {
        const val TABLE_NAME = "TetrisScore"
        const val COLUMN_TIME = "time"
        const val COLUMN_NAME = "name"
        const val COLUMN_SCORE = "score"
    }

    data class Record(val score : Int, val currentTimeMillis : Long, val name : String)
}

然后实现 dbHelper

class ScoreDBHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION){
    private val SQL_CREATE_ENTRYS =
        "CREATE TABLE ${ScoreContract.ScoreEntry.TABLE_NAME} (" +
            "${ScoreContract.ScoreEntry.COLUMN_TIME} INTEGER PRIMARY KEY," +
            "${ScoreContract.ScoreEntry.COLUMN_NAME} TEXT," +
            "${ScoreContract.ScoreEntry.COLUMN_SCORE} INTEGER)"

    private val SQL_DELETE_ENTRYS = "DROP TABLE IF EXISTS ${ScoreContract.ScoreEntry.TABLE_NAME}"

    override fun onCreate(p0: SQLiteDatabase) {
        p0.execSQL(SQL_CREATE_ENTRYS)
    }

    override fun onUpgrade(p0: SQLiteDatabase, p1: Int, p2: Int) {
        p0.execSQL(SQL_DELETE_ENTRYS)
        onCreate(p0)
    }

    override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        onUpgrade(db, oldVersion, newVersion)
    }

    fun insertScore(record : ScoreContract.Record) {
        val db = writableDatabase
        val values = ContentValues().apply {
            put(ScoreContract.ScoreEntry.COLUMN_SCORE, record.score)
            put(ScoreContract.ScoreEntry.COLUMN_TIME, record.currentTimeMillis)
            put(ScoreContract.ScoreEntry.COLUMN_NAME, record.name)
        }
        db.insert(ScoreContract.ScoreEntry.TABLE_NAME, null, values)
        db.close()
    }

    private fun rawSearch(selection : String) : MutableList<ScoreContract.Record> {
        val db = writableDatabase
        try {
            val cursor = db.query(
                ScoreContract.ScoreEntry.TABLE_NAME,
                null,
                selection,
                null,
                null,
                null,
                null
            )
            val records = mutableListOf<ScoreContract.Record>()
            with(cursor) {
                while (moveToNext()) {
                    val record = ScoreContract.Record(
                        getInt(getColumnIndexOrThrow(ScoreContract.ScoreEntry.COLUMN_SCORE)),
                        getLong(getColumnIndexOrThrow(ScoreContract.ScoreEntry.COLUMN_TIME)),
                        getString(getColumnIndexOrThrow(ScoreContract.ScoreEntry.COLUMN_NAME))
                    )
                    records.add(record)
                }
            }
            return records
        } catch (e : SQLiteException) {
            db.close()
            return mutableListOf()
        }
    }

    fun searchScoreByName(name : String) : MutableList<ScoreContract.Record> {
        val selection = "${ScoreContract.ScoreEntry.COLUMN_NAME} = \"$name\""

        return rawSearch(selection)
    }

    fun searchScoreByTime(start : Long, end : Long) : MutableList<ScoreContract.Record> {
        val selection = "${ScoreContract.ScoreEntry.COLUMN_TIME} <= $end and " +
                "${ScoreContract.ScoreEntry.COLUMN_TIME} >= $start"

        return rawSearch(selection)
    }

    fun selectAll() : List<ScoreContract.Record> {
        val selection = "SELECT * FROM ${ScoreContract.ScoreEntry.TABLE_NAME}"
        val db = writableDatabase
        try {
            val cursor = db.rawQuery(selection, null)
            val records = mutableListOf<ScoreContract.Record>()
            with(cursor) {
                while (moveToNext()) {
                    val record = ScoreContract.Record(
                        getInt(getColumnIndexOrThrow(ScoreContract.ScoreEntry.COLUMN_SCORE)),
                        getLong(getColumnIndexOrThrow(ScoreContract.ScoreEntry.COLUMN_TIME)),
                        getString(getColumnIndexOrThrow(ScoreContract.ScoreEntry.COLUMN_NAME))
                    )
                    records.add(record)
                }
            }
            return records
        } catch (e : SQLiteException) {
            db.close()
            return mutableListOf()
        }
    }

    companion object {
        const val DATABASE_NAME = "tetris_score"
        const val DATABASE_VERSION = 1
    }
}

这里面实现了朴素的查询插入方法。

存储就这么解决了。

分数显示

为了显示分数,自然的想法是用一个 list 显示出来,在传统的 view 系统里面,有 LIstView 和 RecycleView,compose 中则类似的,有 Column 和 LazyColumn,如此即可

@Composable
fun ScoreList(modifier: Modifier = Modifier) {
    val viewModel = viewModel<ScoreSearchViewModel>()
    val viewState = viewModel.viewState

    val records = remember { mutableStateListOf<ScoreContract.Record>() }

    records.clear()
    records.addAll(viewState.value.records)

    LazyColumn(
        modifier = modifier,

    ) {
        items(records) { record ->
            ScoreItem(score = record, modifier = Modifier
                .fillMaxWidth()
                .height(60.dp))
        }
    }
}

他会自动根据 modifier 显示出对应大小的区域,滚动时也会自动刷新,比起 RecycleView 要实现一个 adapter 来说,要方便许多。

然后单个分数,compose 一个框就行了

@Preview(showBackground = true)
@Composable
@SuppressLint("SimpleDateFormat")
fun ScoreItem(
    modifier: Modifier = Modifier,
    score : ScoreContract.Record = ScoreContract.Record(100, 1653213538, "chuj")
) {
    val gradientBrush = Brush.horizontalGradient(
        colors = listOf(Color.Red, Color.Blue, Color.Green),
    )

    Column(
        modifier = modifier
            .fillMaxSize()
            .border(
                brush = gradientBrush,
                width = 2.dp,
                shape = CircleShape
            ),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.SpaceBetween
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceAround
        ) {
            Text(
                text = score.name,
                fontSize = 22.sp
            )
            Text(
                text = score.score.toString(),
                fontSize = 20.sp,
            )
        }

        Text(text = SimpleDateFormat("yy/MM/dd HH:mm:ss").format(score.currentTimeMillis))
    }
}

预览出来是这样的效果

single score item

嗯,看起来效果很差,主要是 Modifer.fillMaxSize 了,占满了整个屏幕,之后调用的时候会限制大小,效果还是可以的。

这个彩色的渐变也是通过 brush 来的,挺有意思的。

显示分数我是实现了一个新的 activity 专门实现(实际上 compose 的时候,并不需要用多 activity,可以通过 mutableState 来直接 switch 多个屏幕,实现 activity 的效果。为什么我没这么做呢,emmm,好问题)。这里我也新弄了一个 viewModel 来做,

class ScoreSearchViewModel : ViewModel() {
    private val _viewState = mutableStateOf(ScoreSearchViewState())
    val viewState : State<ScoreSearchViewState> = _viewState

    private fun reduce(newViewState: ScoreSearchViewState) {
        _viewState.value = newViewState
    }

    fun reduceSearchAlert(onSearchAlert: Boolean) {
        reduce(viewState.value.copy(onSearchAlert = onSearchAlert))
    }

    fun reduceRecords(records: List<ScoreContract.Record>) {
        reduce(viewState.value.copy(
            records = records,
            recordsWasSet = true,
        ))
    }

    fun reduceSearchTime(
        timeStart : Long = viewState.value.searchTimeStart,
        timeEnd : Long = viewState.value.searchTimeEnd
    ) {
        _viewState.value = viewState.value.copy(
            searchTimeEnd = timeEnd,
            searchTimeStart = timeStart
        )
    }
}

data class ScoreSearchViewState (
    val records: List<ScoreContract.Record> = emptyList(),
    val searchTimeStart : Long = 0L,
    val searchTimeEnd : Long = 0L,
    val onSearchAlert : Boolean = false,
    val recordsWasSet : Boolean = false,
)

*最后没用上这个 searchTimeStartsearchTimeEnd,因为整个搜索都在之后的 alertDialog 里面实现了,实际上违反了单向数据流,对于用户的输入在 View 层内解决了,Intent 变成了 records。不过懒得重构了。

@Preview(showBackground = true)
@Composable
@SuppressLint("SimpleDateFormat")
fun SearchAlert() {
    val context = LocalContext.current
    var searchType by remember { mutableStateOf(SearchType.OnDeciding)}
    val viewModel = viewModel<ScoreSearchViewModel>()
    val viewState = viewModel.viewState
    val dbHelper = ScoreDBHelper(context)
    var searchName by remember { mutableStateOf("") }
    var timeSetState by remember { mutableStateOf(TimeSetState.OnInit) }
    var timeInput by remember { mutableStateOf("00:00") }
    var dateInput by remember { mutableStateOf("2000/01/01") }
    val currentStartYear = dateInput.subSequence(0, 4).toString().toInt()
    val currentStartMonth = dateInput.subSequence(5, 7).toString().toInt()
    val currentStartDay = dateInput.subSequence(8, 10).toString().toInt()
    val currentStartHour = timeInput.subSequence(0, 2).toString().toInt()
    val currentStartMinute = timeInput.subSequence(3, 5).toString().toInt()
    val timePickerDialog = TimePickerDialog(
        context,
        {_, hour : Int, minute : Int ->
            timeInput = "${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}"
        }, currentStartHour, currentStartMinute, false
    )
    val datePickerDialog = DatePickerDialog(
        context,
        {_, year : Int, month : Int, dayOfMonth : Int ->
            dateInput = "$year/${(month + 1).toString().padStart(2, '0')}/${dayOfMonth.toString().padStart(2, '0')}"
        }, currentStartYear, currentStartMonth, currentStartDay
    )

    var searchStartTime by remember { mutableStateOf(0L) }
    var searchEndTime by remember { mutableStateOf(0L) }

    val timeToParse = "$dateInput $timeInput"
    println("[!] $timeToParse")
    if (timeSetState == TimeSetState.OnSetStartTime || timeSetState == TimeSetState.OnInit) {
        searchStartTime = SimpleDateFormat("yyyy/MM/dd HH:mm").parse(timeToParse).time
        println("[+] start time set $searchStartTime")
    }
    if (timeSetState == TimeSetState.OnSetEndTime || timeSetState == TimeSetState.OnInit) {
        searchEndTime = SimpleDateFormat("yyyy/MM/dd HH:mm").parse(timeToParse).time
        println("[+] end time set $searchStartTime")
    }

    if (viewState.value.onSearchAlert) {
        when(searchType) {
            SearchType.OnDeciding -> AlertDialog(
                onDismissRequest = { viewModel.reduceSearchAlert(false) },
                title = {
                    Text(text = "Search")
                },
                text = {
                    Text(text = "What you want to search by?")
                },
                confirmButton = {
                    TextButton(
                        onClick = { searchType = SearchType.ByName }
                    ) {
                        Text(text = "By Name")
                    }
                },
                dismissButton = {
                    TextButton(
                        onClick = { searchType = SearchType.ByTime }
                    ) {
                        Text(text = "By Time")
                    }
                }
            )
            SearchType.ByTime -> AlertDialog(
                onDismissRequest = { viewModel.reduceSearchAlert(false) },
                title = {
                    Text(text = "Search By Time")
                },
                text = {
                    Row(
                        horizontalArrangement = Arrangement.SpaceAround,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        ClickableText(
                            text = AnnotatedString(SimpleDateFormat("yy/MM/dd ")
                                .format(searchStartTime)),
                            onClick = {
                                timeSetState = TimeSetState.OnSetStartTime
                                datePickerDialog.show()
                            }
                        )
                        ClickableText(
                            text = AnnotatedString(SimpleDateFormat("HH:mm")
                                .format(searchStartTime)),
                            onClick = {
                                timeSetState = TimeSetState.OnSetStartTime
                                timePickerDialog.show()
                            }
                        )
                        Text(
                            text = "   To   "
                        )
                        ClickableText(
                            text = AnnotatedString(SimpleDateFormat("yy/MM/dd ")
                                .format(searchEndTime)),
                            onClick = {
                                timeSetState = TimeSetState.OnSetEndTime
                                datePickerDialog.show()
                            }
                        )
                        ClickableText(
                            text = AnnotatedString(SimpleDateFormat("HH:mm")
                                .format(searchEndTime)),
                            onClick = {
                                timeSetState = TimeSetState.OnSetEndTime
                                timePickerDialog.show()
                            }
                        )
                    }
                },
                confirmButton = {
                    TextButton(onClick = {
                        val records = dbHelper.searchScoreByTime(searchStartTime, searchEndTime)
                        viewModel.reduceRecords(records = records)
                        viewModel.reduceSearchAlert(false)
                    }) {
                        Text(text = "Search!")
                    }
                },
                dismissButton = {
                    TextButton(onClick = { viewModel.reduceSearchAlert(false) }) {
                        Text(text = "Cancel")
                    }
                }
            )
            SearchType.ByName -> AlertDialog(
                onDismissRequest = { viewModel.reduceSearchAlert(false) },
                title = {
                    Text(text = "Search By Name")
                },
                text = {
                    Text(text = "Enter a name")
                    TextField(
                        value = searchName,
                        onValueChange = {
                            searchName = it
                        }
                    )
                },
                confirmButton = {
                    TextButton(onClick = {
                        val records = dbHelper.searchScoreByName(name = searchName)
                        viewModel.reduceRecords(records = records)
                        viewModel.reduceSearchAlert(false)
                    }) {
                        Text(text = "Search!")
                    }
                },
                dismissButton = {
                    TextButton(onClick = { viewModel.reduceSearchAlert(false) }) {
                        Text(text = "Cancel")
                    }
                }
            )
        }
    }
}

这就是整个搜索的 dialog。在 compose 中还没有 Material Design 的 time 和 date picker,查了点资料发现了 DatePickerDialog 和 TimePickerDialog,感觉长得挺像 Material Design 的,我也不知道是什么,anyway,用起来比较容易

val timePickerDialog = TimePickerDialog(
    context,
    {_, hour : Int, minute : Int ->
        timeInput = "${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}"
    }, currentStartHour, currentStartMinute, false
)
val datePickerDialog = DatePickerDialog(
    context,
    {_, year : Int, month : Int, dayOfMonth : Int ->
        dateInput = "$year/${(month + 1).toString().padStart(2, '0')}/${dayOfMonth.toString().padStart(2, '0')}"
    }, currentStartYear, currentStartMonth, currentStartDay
)

第二个参数就是一个 listener 了,在这里根据输入更新相关的变量即可。

播放音乐

挂了之后要放个小曲,本来是想在失败动画的协程中放的,也就是这里面

viewModelScope.launch {
    (0 until viewState.value.grid.second).reversed().forEach { y ->
        delay(120)
        // A. create black bricks
        val brickLine = mutableListOf<Brick>()
        (0 until viewState.value.grid.first).forEach { x ->
            brickLine.add(Brick(Offset(x.toFloat(), y.toFloat()), Color.Black))
        }
        val bricks = _viewState.value.bricks.toMutableList()
        bricks.addAll(brickLine)

        _viewState.value = _viewState.value.copy(
            bricks = bricks
        )
    }
    _viewState.value = _viewState.value.copy(gameStatus = GameStatus.GameOver)
}

但是我拿不到 context,起不来,没搞懂,可能音乐播放不应该在这里面做,最后我给 state 加了 onGameOverAnimation 状态,在持续发送下落的 game ticking 协程里面才起了 mediaPlay,也就是

LaunchedEffect(key1 = Unit) {
    var musicPlaying = false
    while (true) {
        delay(
            450 - min(viewState.linesCleared.toLong() * 10, 250)
        )
        viewModel.dispatch(Action.Tick)
        if (!musicPlaying && viewModel.viewState.value.isOnGameOverAnimation) {
            musicPlaying = true
            val lostMusicPlayer = MediaPlayer.create(context, R.raw.on_lost_animation_music)
            lostMusicPlayer.start()
        }
    }
}

最后

好了,到这里差不多就结束了。前前后后可能花了三四十个小时写这个项目(还不包括查资料的时间)。幸运的是,学到了许多知识。之前一直在弄安全,二进制安全其实是比较偏向底层的,很多时候可能并不会关心宏观的架构,只会深入研究一个子模块,而且在开发方面的经验也是比较少。通过这个项目,受着 MVI 的指导,踩着谷歌 jetpack compose 的大火箭,感受了便捷开发的快乐。也是把以前学的抽象的设计模式用到了实际的工程中。收获颇多,非常开心hhh。

文章写的比较简略,等我过段时间再来看看,发现我看不懂了的话就再补充一点

src