jetpack compose + mvi , Closure 开发记录

最近没时间精力肝游戏了,把方舟交给了挂机平台:https://arknights.host/
不过网页着实不方便,打算写个练手项目,顺便学习一下 Jetpack Compose 和 MVI

Github:https://github.com/heyanLE/Closure

需求

a.png

效果:

807A8D1C73BED78B2C55E8DFA53E7AA6 2.gif

技术选型

  • 界面使用 jetpack Compose 实现
  • 单 Activity 多页面模式
  • 页面跳转使用 navigation 管理
  • MVI 架构
  • 网络使用 okhttp + retrofit 并进行语法糖封装,序列化使用 Gson
  • 图片缓存使用 coil (对 Compose 支持比 Glide 好一点)
  • 包管理使用 Gradle + kts
  • 因为需求较少,只需要一个 module 因此这里依赖版本管理直接使用变量,不使用 buildSrc(纯纯看番就使用 buildSrc)

页面切换动画效果

这里使用 accompanist 的相关实现

val accompanistVersion = "0.28.0"
implementation("com.google.accompanist:accompanist-navigation-animation:$accompanistVersion")
const val LOGIN = "login"
const val HOME = "home"
const val INSTANCE = "instance"

// 缺省路由
const val DEFAULT = HOME


@OptIn(ExperimentalAnimationApi::class)
@Composable
fun Nav() {
    val nav = rememberAnimatedNavController()
    CompositionLocalProvider(LocalNavController provides nav) {
        AnimatedNavHost(nav, DEFAULT,
            // 动画效果
            enterTransition = { slideInHorizontally(tween()) { it } },
            exitTransition = { slideOutHorizontally(tween()) { -it } + fadeOut(tween()) },
            popEnterTransition = { slideInHorizontally(tween()) { -it } },
            popExitTransition = { slideOutHorizontally(tween()) { it } })
        {
            // 登录状态阻断
            composableWithTokenCheck(HOME){
                Home()
            }

            composableWithTokenCheck(INSTANCE) {
                Instance()
            }

            composable(
                LOGIN,
            ) { entry ->
                Login {
                    MainController.token.value = it.token
                    nav.popBackStack()
                }
            }
        }
    }
}

登录状态阻断

@ExperimentalAnimationApi
public fun NavGraphBuilder.composableWithTokenCheck(
    route: String,
    arguments: List<NamedNavArgument> = emptyList(),
    //…… 一堆不重要的参数
){

    // 其实是对原本的 composable 进行代理,参数透传
    composable(route, arguments, deepLinks, enterTransition, exitTransition, popEnterTransition, popExitTransition){
        val token by MainController.token.observeAsState("")
        val navController = LocalNavController.current

        // 如果存储了 Token 直接不做特殊处理
        if(token.isNotEmpty()){
            content.invoke(this, it)
        } else {
            // 没有储存 Token
            // 这里展示错误页面
            ErrorPage(
                modifier = Modifier.background(ColorScheme.background),
                errorMsg = stringResource(id = R.string.click_to_login),
                clickEnable = true,
                onClick = {
                    // 点击后跳转到登录页面
                    navController.navigate(LOGIN)
                }
            ) 

        }
    }

}

MVI 的设计和页面间数据流动

登录状态已经在 nav 做了阻断,但是实例选择状态相关流动还没有实现

可以看到需要从主页跳转到实例管理页面,然后加载实例,然后选择实例回到主页在加载详细数据。compose 里的 navigation 是没有类 startActivityForResult 的方法的,这里直接使用单例模式维护 MVI 的唯一信任源。

具体到这个需求,这里需要存一个当前选择的 liveData,并在主页的 ViewModel 里注册一个 Observer,在选择改变时触发刷新。

首先是 Status 基类,这里重点为数据本身要带有加载状态,便于管理。

// 密封类带泛型
 sealed class StatusData<T>{
    // 初始化
    class None<T>: StatusData<T>()

    // 加载中
    data class Loading<T>(
        val loadingText: String = stringRes(R.string.loading)
    ): StatusData<T>()

    // 错误
    data class Error<T>(
        val errorMsg: String = "",
        val throwable: Throwable? = null,
    ): StatusData<T>()

    // 数据
    data class Data<T>(
        val data: T
    ): StatusData<T>()
}

当然这里为了使用方便,添加了一系列语法糖:

fun isLoading(): Boolean {
    return this is Loading
}
// 其他子类类似

@Composable
fun onLoading(content: @Composable (Loading<T>)->Unit): StatusData<T> {
    (this as? Loading)?.let {
        content(it)
    }
    return this
}

// 其他子类类似

fun <T> MutableLiveData<MainController.StatusData<T>>.loading(loadingText: String = stringRes(R.string.loading)){
    value = MainController.StatusData.Loading(loadingText)
}

fun <T> MutableLiveData<MainController.StatusData<T>>.error(errorMsg: String = "", throwable: Throwable? = null,){
    value = MainController.StatusData.Error(errorMsg, throwable)
}

fun <T> MutableLiveData<MainController.StatusData<T>>.data(data: T){
    value = MainController.StatusData.Data(data)
}

然后我们在单例模式定义一系列 LiveData,也可以使用 绑定 Activity 的 MainViewModel 然后通过 LocalCompoment 隐式传递,不过这里需求简单直接单例了

object MainController {

    // 使用 okkv 持久化
    var okkvToken by okkv("token", "")

    // 选择的实例只跟 账号和 所在服务器(platform)有关
    var okkvCurrentAccount by okkv("currentAccount", "")
    var okkvCurrentPlatform by okkv("currentPlatform", -1L)

    val token = MutableLiveData<String>(okkvToken)

    // 当前选择,主页据此获取在实例管理页选择的管理
    val current = MutableLiveData<InstanceSelect>(InstanceSelect(okkvCurrentAccount, okkvCurrentPlatform))

    // 所有实例列表
    val instance = MutableLiveData<StatusData<List<GameResp>>>(StatusData.None())

    // 当前选择实例的详细信息
    val currentGetGame = MutableLiveData<StatusData<GetGameResp>>(StatusData.None())

}

然后是 HomeViewModel,这里使用了监听器监听当前选择并进行主页详细数据加载

class HomeViewModel: ViewModel() {
    val observer = Observer<MainController.InstanceSelect> {
        viewModelScope.launch {
            loadGetGameResp()
            loadLog()
        }
    }

    init {
        // 将 observer 注册到当前选择的 liveData 上
        MainController.current.observeForever(observer)
        // 这里是初始化检查,如果当前已经有选择则直接触发加载
        if(MainController.currentGetGame.value?.isNone() == true){
            // 省略检查代码
            viewModelScope.launch {
                loadGetGameResp()
            }
        }
    }

    // 别忘了反注册
    override fun onCleared() {
        super.onCleared()
        MainController.current.removeObserver(observer)
    }

    suspend fun loadGetGameResp(){
        // 将数据转为 Loading 状态
        MainController.currentGetGame.loading()
        Net.game.game(token, platform, account).awaitResponseOK()
            .onSuccessful {
                withContext(Dispatchers.Main){
                    if(it == null){
                        // 数据为空,转为 Error 状态
                        MainController.currentGetGame.error(stringRes(R.string.load_error))
                    }else{
                        // 将数据转为 Data 态
                        MainController.currentGetGame.data(it)
                    }

                }
            }.onFailed { b, s ->
                def()
                // 将数据转为 Error 态
                MainController.currentGetGame.error(s)
            }
    }

}
// 省略无关代码
@Composable
fun Home(){
    // 监听当前选择情况
    val current by MainController.current.observeAsState()

    val account = current?.account?:""
    val platform = current?.platform?:-1L
    // 如果当前没选择实例,引导选择
    if(account.isEmpty() || platform == -1L){
        // 错误页面的封装
        ErrorPage(
            // 省略其他参数
            onClick = {
                // 路由到 INSTANCE
                nav.navigate(INSTANCE)
            }
        )
    }else{
        getGameRep.onError {
            // 错误态可组合函数
        }.onLoading { 
            // 加载中可组合函数
        }.onData {
            // 详细数据展示
        }
    }
}

主页面理智实时更新

明日方舟里的体力叫做理智,每六分钟增加 1,在主页里需要进行展示,但是后台不会返回实时离职,只会返回过去最后一次刷新数据时的理智和时间,因此我们需要进行计算。计算没什么,重点是需要在可组合函数里轮询更新。

这里交给协程和 DisposableEffect

/**
 * 理智等级面板
 */
@Composable
fun APLVPanel(getGameResp: GetGameResp){
    val scope = rememberCoroutineScope()
    var nowAp by remember {
        // 服务器数据的理智
    }

    DisposableEffect(Unit){
        scope.launch {
            // 循环解决
            while(scope.isActive){
                // 每隔增加 1 理智时间 *2 时间刷新一下理智数
                delay((APUtils.AP_UP_TIME*2).toLong())
                nowAp = //计算新理智
            }
        }
        onDispose {
            // 当该可组合函数没显示时将协程 cancel 调即可跳出循环
            // 这里 delay 方法本身就会跳出,循环条件只是一个个人习惯
            scope.cancel()
        }
    }
}

清单数据缓存

有一些名词为清单数据,比如物品名称,关卡名字,在该平台中有一个 api 用于返回所有清单数据:

c5ea684db8b9fdf68228f8eb943fe31.png

在客户端中,这种数据一般启动时到网络请求一次,然后保存到内存里。
这里使用一个 LiveData 对外暴露整个 Map,这样设计对 compose 更友好。对于刷新过程,使用 Cas 锁控制,允许重试 3 次:

// 数据定义
private val map: HashMap<String, Stage> = hashMapOf()
val mapLiveData = MutableLiveData<Map<String, Stage>>(emptyMap())
@Volatile
private var retryCount = 3

private val isRefresh = AtomicBoolean(false)

fun refresh(){
    if(isRefresh.compareAndSet(false, true)){
        retryCount = 3
        tryRefresh()
    }
}
@OptIn(DelicateCoroutinesApi::class)
private fun tryRefresh(){
    if(retryCount > 0){
        GlobalScope.launch {
            // 网络请求
            Net.okHttpClient.get(URL).onSuccessful {
                retryCount = 3
                map.clear()
                kotlin.runCatching {
                    map.putAll(Stage.parsonFromResp(it))
                }.onFailure {
                    it.printStackTrace()
                    map.clear()
                }
                mapLiveData.postValue(map)
                isRefresh.set(false)
            }.onFailed { _, _ ->
                retryCount --
                tryRefresh()
            }
        }
    }else{
        // 刷新失败
        map.clear()
        isRefresh.set(false)
    }
}
// 使用时直接在可组合函数中监听该 liveData 即可
val map by StageModel.mapLiveData.observeAsState(emptyMap())

固有特性测量

在实例启动时需要展示一个蒙版:

621d8c06f66e78e41075cc14b0a95f1.png

该蒙版的高度和卡片本身高度一致,这在原生安卓里有手就行,可惜在 compose 里有问题。因为 Compose 不允许多次测量的。这里直接给出最终代码,具体细节可以参考 谷歌官网给出的文档:
https://developer.android.google.cn/jetpack/compose/layouts/intrinsic-measurements?hl=zh-cn

Box(
    modifier = Modifier
        .background(ColorScheme.surface)
        .height(IntrinsicSize.Min) // 固有测量,重点
){
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ){
        // 卡片可组合函数
    }

    if(resp.status.code == 1){
        Box(
            modifier = Modifier
                .fillMaxSize() // 父亲需要添加 IntrinsicSize.Min 才生效
                .background(Color(0xB3000000)),
            contentAlignment = Alignment.Center
        ){
            // 蒙版
        }
    }
}