jetpack compose + mvi , Closure 开发记录
最近没时间精力肝游戏了,把方舟交给了挂机平台:https://arknights.host/
不过网页着实不方便,打算写个练手项目,顺便学习一下 Jetpack Compose 和 MVI
Github:https://github.com/heyanLE/Closure
需求
效果:
技术选型
- 界面使用 jetpack Compose 实现
- 单 Activity 多页面模式
- 页面跳转使用 navigation 管理
- MVI 架构
- 网络使用 okhttp + retrofit 并进行语法糖封装,序列化使用 Gson
- 图片缓存使用 coil (对 Compose 支持比 Glide 好一点)
- 包管理使用 Gradle + kts
- 因为需求较少,只需要一个 module 因此这里依赖版本管理直接使用变量,不使用 buildSrc(纯纯看番就使用 buildSrc)
Navigation
页面切换动画效果
这里使用 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 用于返回所有清单数据:
在客户端中,这种数据一般启动时到网络请求一次,然后保存到内存里。
这里使用一个 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())
固有特性测量
在实例启动时需要展示一个蒙版:
该蒙版的高度和卡片本身高度一致,这在原生安卓里有手就行,可惜在 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
){
// 蒙版
}
}
}