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(单元测试)

相关推荐
哲科软件9 小时前
跨平台开发的抉择:Flutter vs 原生安卓(Kotlin)的优劣对比与选型建议
android·flutter·kotlin
jyan_敬言15 小时前
【C++】string类(二)相关接口介绍及其使用
android·开发语言·c++·青少年编程·visual studio
程序员老刘15 小时前
Android 16开发者全解读
android·flutter·客户端
福柯柯16 小时前
Android ContentProvider的使用
android·contenprovider
不想迷路的小男孩16 小时前
Android Studio 中Palette跟Component Tree面板消失怎么恢复正常
android·ide·android studio
餐桌上的王子16 小时前
Android 构建可管理生命周期的应用(一)
android
菠萝加点糖16 小时前
Android Camera2 + OpenGL离屏渲染示例
android·opengl·camera
用户20187928316716 小时前
🌟 童话:四大Context徽章诞生记
android
yzpyzp16 小时前
Android studio在点击运行按钮时执行过程中输出的compileDebugKotlin 这个任务是由gradle执行的吗
android·gradle·android studio
aningxiaoxixi17 小时前
安卓之service
android