Android的MVI架构最佳实践(三):Compose封装

  • Android的MVI架构最佳实践(一):Model和Intent
  • Android的MVI架构最佳实践(二):View和repeatOnLifecycle
  • Android的MVI架构最佳实践(四):单元测试
  • Android的MVI架构最佳实践(四):UI测试

前言

声明式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是再好不过:

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中运用示例

sealed class TestState : State {
    object Loading : TestState()
    object Content : TestState()
}
class SampleViewModel : BaseViewModel<TestState>() {
    override fun initialState(): TestState  = TestState.Loading
}
@Composable
fun StateEffectScaffold() {
    val viewModel = viewModel<SampleViewModel>()
    when (viewModel.viewState) {
        TestState.Loading -> CircularProgressIndicator()
        TestState.Content -> Text("Content")
    }
}

兼容Fragment

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

  • LiveData 转化 Compose State
@Composable
fun <T> LiveData<T>.observeAsState(): State<T?> = ...
  • flow 转化 Compose State
@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始终有值。
// viewModel.state is StateFlow
val uiState = viewModel.state.collectAsStateWithLifecycle()
  1. BaseViewModel中的state运用的是SharedFlow(replay = 1)LiveData和SharedFlow相同没有默认值,因此Compose的uiState可空,官方供给的函数就要求初始化一个默认值,不然state就可能为null
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. 优化option2中必传默认值参数问题。LiveData和SharedFlow不传递默认值会让state可空,咱们过滤null不处理,那么默认值就变成可选参数了。
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来同享获取数据。

@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那么把这个函数参数作为可空传递。

@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来演示代码

@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) }
}

脚手架运用演示

@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就能够发挥一定作用了,可是生成的代码在一个文件中需要自己手动分包。复制下面供给的模版到剪贴板,按图顺序操作。

Android的MVI架构最佳实践(三):Compose封装脚手架

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

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

    Android的MVI架构最佳实践(三):Compose封装脚手架

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

    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代码

    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处理导航事件

    @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. 展现

    Android的MVI架构最佳实践(三):Compose封装脚手架

总结

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