Android的MVI架构最佳实践(三):compose最佳拍档MVI

Android的MVI架构最佳实践(三):compose最佳拍档MVI

前言

声明式UI的最佳搭档肯定是MVI了,例如前端的Flux或Redux。compose作为android官方的声明式UI框架已经非常成熟了,虽然目前从性能上无法碾压旧版本但是也在不断提升了,未来可期。此篇中我们就实现一个简单的手脚架消除模版代码,满足我们快速的构建一个MVI的composable。

compose的state

首先我们处理UIState,在compose中为了在重组中订阅保持的数据,会使用State<out T>,我们在composable中使用函数val result = remember { }来保持数据,防止重组中的性能消耗,但是ViewModel的特性就是生命周期内保持数据,因此我们只需要定义androidx.compose.runtime.State,对于纯compose的项目我们修改BaseViewModel是再好不过的:

kotlin 复制代码
abstract class BaseViewModel<S : State> : ViewModel() {

    abstract fun initialState(): S
    
    private val _viewState: MutableState<S> by lazy { mutableStateOf(initialState()) }

    val viewState: androidx.compose.runtime.State<S> = _viewState
}

composable中使用示例

kotlin 复制代码
sealed class TestState : State {
    object Loading : TestState()
    object Content : TestState()
}

class ABaseViewModel : BViewModel<TestState>() {
    override fun initialState(): TestState  = TestState.Loading
}

@Composable
fun StateEffectScaffold() {
    val viewModel = hiltViewModel<ABaseViewModel>()
    when (viewModel.viewState) {
        TestState.Loading -> CircularProgressIndicator()
        TestState.Content -> Text("Content")
    }
}

兼容Fragment

大多时候我们还不是纯compose开发,我们还需要一个兼容的BaseViewModel该如何做呢?官方也提供了很多扩展函数来实现这些需求:

  • LiveData 转换 compose State
kotlin 复制代码
@Composable
fun <T> LiveData<T>.observeAsState(): State<T?> = ...
  • flow 转换 compose State
kotlin 复制代码
@Composable
fun <T> Flow<T>.collectAsStateWithLifecycle(
    initialValue: T,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T> = ...

这样我们之前篇幅中封装的BaseViewModel就可以通过这些api兼容到composable中了

通过ViewModel获得ComposeState

  1. BaseViewModel中的state使用的是StateFlow。StateFlow要求必须有默认值,因此compose的uiState始终有值。
kotlin 复制代码
// viewModel.state is StateFlow
val uiState = viewModel.state.collectAsStateWithLifecycle()
  1. BaseViewModel中的state使用的是SharedFlow(replay = 1)LiveData和SharedFlow一样没有默认值,因此compose的uiState可空,官方提供的函数就要求初始化一个默认值,否则state就可能为null。
kotlin 复制代码
val initialState: S = ..
// viewModel.state is LiveDate
val uiState = viewModel.state.observeAsState(initial = initialState)
// viewModel.state is SharedFlow
val uiState = viewModel.state.collectAsStateWithLifecycle(
    initialValue = initialState
)
  1. 优化2中默认值必传参数。LiveData和SharedFlow不传递默认值会让state可空,我们过滤null不处理,那么默认值就变成可选参数了。
kotlin 复制代码
val initialState: S? = null
val BaseViewModel<*,*,*>.replayState = state.replayCache.firstOrNull()
val uiState = viewModel.state.collectAsStateWithLifecycle(
    initialValue = replayState
)
(uiState.value ?: initialState)?.let { ... }

State模版代码封装

compose关注回调的state,而@Composable函数可以嵌套,那么我们可以简单封装获得一个手脚架让MVI使用更简单。甚至还可以套娃并共享同一个ViewModel来共享获取数据。

kotlin 复制代码
@Composable
fun <S : State, VM : BaseViewModel<*, S, *>> StateEffectScaffold(
    viewModel: VM,
    initialState: S? = null,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext,
    content: @Composable (VM, S) -> Unit
) {
    val uiState = viewModel.state.collectAsStateWithLifecycle(
        initialValue = viewModel.replayState,
        lifecycle = lifecycle,
        minActiveState = minActiveState,
        context = context
    )
    (uiState.value ?: initialState)?.let { content(viewModel, it) }
}

//实战效果:
StateEffectScaffold(
    viewModel = hiltViewModel<ABaseViewModel>()
) { viewModel, state ->
    when (state) {
        TestState.Loading -> CircularProgressIndicator()
        TestState.Content -> Text("Content")
    }
}

compose的Effect

MVI中UIState分为stateeffect,effect不需要state一样在重组中保持数据,在Compose中effect有专门的处理方式。再配合repeatOnLifecycle即可实现和Fragment中一样的效果。有时候我们不需要effect那么把这个函数参数作为可空传递。

kotlin 复制代码
@Composable
fun <E : Effect, VM : BaseViewModel<*, *, E>> StateEffectScaffold(
    viewModel: VM,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    sideEffect: (suspend (VM, E) -> Unit)? = null,
) {
    sideEffect?.let {
        val lambdaEffect by rememberUpdatedState(sideEffect)
        LaunchedEffect(viewModel.effect, lifecycle) {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.effect.collect { lambdaEffect(viewModel, it) }
            }
        }
    }
}

Composable手脚架

上面我们分别处理了State和Effect,只需要稍加组合并且把一些参数全部暴露出来,就得到一个快速开发的手脚架了。这里用state是SharedFlow来演示代码

kotlin 复制代码
@Composable
fun <S : State, E : Effect, V : BaseViewModel<*, S, E>> StateEffectScaffold(
    viewModel: V,
    initialState: S? = null,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext,
    sideEffect: (suspend (V, E) -> Unit)? = null,
    content: @Composable (V, S) -> Unit
) {
    sideEffect?.let {
        val lambdaEffect by rememberUpdatedState(sideEffect)
        LaunchedEffect(viewModel.effect, lifecycle, minActiveState) {
            lifecycle.repeatOnLifecycle(minActiveState) {
                if (context == EmptyCoroutineContext) {
                    viewModel.effect.collect { lambdaEffect(viewModel, it) }
                } else withContext(context) {
                    viewModel.effect.collect { lambdaEffect(viewModel, it) }
                }
            }
        }
    }
    // collectAsStateWithLifecycle 在横竖屏变化时会先回调initialState 所以必须把replay latest state传递过去
    val uiState = viewModel.state.collectAsStateWithLifecycle(
        initialValue = viewModel.replayState,
        lifecycle = lifecycle,
        minActiveState = minActiveState,
        context = context
    )
    (uiState.value ?: initialState)?.let { content(viewModel, it) }
}

手脚架使用演示

kotlin 复制代码
@Composable
fun TemplateScreen() {
    StateEffectScaffold(
        viewModel = hiltViewModel<TemplateViewModel>(),
        sideEffect = { viewModel, sideEffect ->
            when (sideEffect) {
                is TemplateEffect.ShowToast -> {
                    TODO("ShowToast ${sideEffect.content}")
                }
            }
        }
    ) { viewModel, state ->
        when (state) {
            TemplateState.Loading -> Loading()
            TemplateState.Empty -> Empty()
        }
    }
}

AndroidStudio Live Template提升开发效率

IDEA 可以通过 NEW -> Activity/Fragment来选择一个模版快速生成一些代码,但是新版的AndroidStudio如果要自定义模版需要自己开发一个IDEA Plugin才可以做到。怎么既简单又能快速满足这个要求呢,那Live Template就可以发挥一定作用了,但是生成的代码在一个文件中需要自己手动分包。复制下面提供的模版到剪贴板,按图顺序操作。

你输入mvi后立马自动获得下面模版中的代码,并且会等待你进一步输入操作。$NAME$是要输入主命名的占位符,你输入Home按下回车后,所有占位符位置会自动以Home替换,演示实战请看下节。

kotlin 复制代码
import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import com.arch.mvi.intent.Action
import com.arch.mvi.model.Effect
import com.arch.mvi.model.State
import com.arch.mvi.view.StateEffectScaffold
import com.arch.mvi.viewmodel.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

/**
 * - new build package and dirs
 *   - contract
 *   - viewmodel
 *   - view
 */

/** - contract */
sealed class $NAME$Action : Action {
    data object LoadData : $NAME$Action()
    data class OnButtonClicked(val id: Int) : $NAME$Action()
}

sealed class $NAME$State : State {
    data object Loading : $NAME$State()
    data object Empty : $NAME$State()
}

sealed class $NAME$Effect : Effect {
    data class ShowToast(val content: String) : $NAME$Effect()
}

/** - viewmodel */
@HiltViewModel
class $NAME$ViewModel @Inject constructor(
//    private val reducer: $NAME$Reducer,
//    private val repository: $NAME$Repository,
//    private val dispatcherProvider: CoroutineDispatcherProvider
) : BaseViewModel<$NAME$Action, $NAME$State, $NAME$Effect>() {

    init {
        sendAction($NAME$Action.LoadData)
    }

    override fun onAction(action: $NAME$Action, currentState: $NAME$State?) {
        when (action) {
            $NAME$Action.LoadData -> {
                /*viewModelScope.launch {
                    withContext(dispatcherProvider.io()) {
                        runCatching { repository.fetchRemoteOrLocalData() }
                    }.onSuccess {
                        emitState(reducer.reduceRemoteOrLocalData())
                    }.onFailure {
                        emitState($NAME$State.Empty)
                    }
                }*/$END$
            }

            is $NAME$Action.OnButtonClicked -> {
                emitEffect { $NAME$Effect.ShowToast("Clicked ${action.id}") }
            }
        }
    }
}

/** - view */
@Composable
fun $NAME$Screen() {
    StateEffectScaffold(
        viewModel = hiltViewModel<$NAME$ViewModel>(),
        sideEffect = { viewModel, sideEffect ->
            when (sideEffect) {
                is $NAME$Effect.ShowToast -> {
                    TODO("ShowToast ${sideEffect.content}")
                }
            }
        }
    ) { viewModel, state ->
        when (state) {
            $NAME$State.Loading -> {
                TODO("Loading")
            }
            $NAME$State.Empty -> {
                TODO("Empty")
            }
        }
    }
}

用Live Template快速开发

需求和最佳实践第一篇中一致: 登录页面点击登录按钮,请求网络返回登录结果,登录成功跳转,登录失败展示错误页面。因此MVI中ViewModel和Model的定义完全一样,唯一不同就是View使用Composable。

  1. 在AS新建一个空的kotlin文件,输入mvi获得模版,输入Logon

  2. 修改Action的模版代码为需求中的action,修改State和Effect和需求中UIState匹配

    kotlin 复制代码
    sealed class LogonAction : Action {
        data object OnButtonClicked : LogonAction()
    }
    
    sealed class LogonState : State {
        data object LogonHub : LogonState()
        data object Loading : LogonState()
        data object Error : LogonState()
    }
    
    sealed class LogonEffect : Effect {
        data object Navigate : LogonEffect()
    }
  3. 修改ViewModel代码

    kotlin 复制代码
    class LogonViewModel : BaseViewModel<LogonAction, LogonState, LogonEffect>() {
        override fun onAction(action: LogonAction, currentState: LogonState?) {
            when (action) {
                LogonAction.OnButtonClicked -> {
                    flow {
                        kotlinx.coroutines.delay(2000)
                        emit(Unit)
                    }.onStart { 
                        emitState(LogonState.Loading)
                    }.onEach {
                        emitEffect(LogonEffect.Navigate)
                    }.catch {
                        emitState(LogonState.Error)
                    }.launchIn(viewModelScope)
                }
            }
        }
    }
  4. 构建的不同state下的composable,实现sideEffect处理导航事件

    kotlin 复制代码
    @Preview
    @Composable
    fun LogonScreen() {
        val scaffoldState = rememberScaffoldState()
        StateEffectScaffold(viewModel = hiltViewModel<LogonViewModel>(),
            initialState = LogonState.LogonHub,
            sideEffect = { viewModel, sideEffect ->
                when (sideEffect) {
                    LogonEffect.Navigate -> {
                        scaffoldState.snackbarHostState.showSnackbar("navigate")
                    }
                }
            }
        ) { viewModel, state ->
            Scaffold(
                scaffoldState = scaffoldState
            ) {
                Box(modifier = Modifier.fillMaxSize().padding(it), contentAlignment = Alignment.Center) {
                    when (state) {
                        LogonState.Loading -> CircularProgressIndicator()
                        LogonState.Error -> Text(text = "error")
                        LogonState.LogonHub -> Button(onClick = {
                            viewModel.sendAction(LogonAction.OnButtonClicked)
                        }) {
                            Text(text = "logon")
                        }
                    }
                }
            }
        }
    }
  5. 展示

总结

此篇中我们对compose中使用MVI架构模式做了详细讲解,并且考虑非纯compose的项目的兼容。最后对composable简单的封装一个手脚架用于快速构建compose的MVI结构,考虑到每次书写的模版代码太多又使用了Live Template来在吃提升构建这些代码的速度。最终我们用实战项目来检验他们。由于大部分开发者并不会再实际开发环节书写UT,导致大家对MVI的优点易于测试感知不强,下一章开始主要讲代码质量管理环节中的MVI的Unit test(单元测试)

相关推荐
DengDongQi5 分钟前
Jetpack Compose 滚轮选择器
android
stevenzqzq6 分钟前
Android Studio Logcat 基础认知
android·ide·android studio·日志
代码不停14 分钟前
MySQL事务
android·数据库·mysql
朝花不迟暮20 分钟前
使用Android Studio生成apk,卡在Running Gradle task ‘assembleDebug...解决方法
android·ide·android studio
yngsqq35 分钟前
使用VS(.NET MAUI)开发第一个安卓APP
android·.net
Android-Flutter1 小时前
android compose LazyVerticalGrid上下滚动的网格布局 使用
android·kotlin
Android-Flutter1 小时前
android compose LazyHorizontalGrid水平滚动的网格布局 使用
android·kotlin
千里马-horse1 小时前
RK3399E Android 11 将自己的库放到系统库方法
android·so·设置系统库
美狐美颜sdk1 小时前
Android直播美颜SDK:选择指南与开发方案
android·人工智能·计算机视觉·第三方美颜sdk·视频美颜sdk·人脸美型sdk
我命由我123451 小时前
Kotlin 面向对象 - 装箱与拆箱
android·java·开发语言·kotlin·android studio·android jetpack·android-studio