Jetpack Compose 的简单 MVI 框架
在 Jetpack Compose 应用程序中管理状态的一种简单方法
选择正确的架构是至关重要的,因为架构变更后期代价高昂。MVP已被MVVM和MVI取代,而MVI更受欢迎。MVI通过强制实施结构化的状态管理方法,只在reducer中更新状态,并通过管道处理所有意图。相比之下,MVVM更新状态的位置更加自由,但理解和调试流程更加困难。状态管理对于应用程序的发展和扩展至关重要。
MVI简介
在MVI中有三个架构组件:
- Model:表示应用程序或特定屏幕的状态以及生成该状态的业务逻辑。
- View:表示用户交互的 UI。
- Intent:这些是触发新模型的操作,可以是来自用户或外部的操作。
需要注意的是,在MVI中状态是不可变的,并且MVI遵循单向数据流。这可以用以下图表表示:
基本流程如下:
生成初始模型,并将其推送到视图进行渲染。
用户或外部因素(例如网络加载完成)触发一个操作(即意图)。
意图在业务逻辑中进行处理,生成一个新的模型,并将其发布到视图。
循环无限重复。这种架构提供了明确的责任分离:视图负责渲染 UI,意图负责承载操作,而模型负责业务逻辑。
创建MVI框架的基本脚手架
我们将从为MVI框架创建基本脚手架开始。这里描述的解决方案是针对Android并专为Jetpack Compose应用程序量身定制的,但原则可以应用于任何移动应用或其他类型的应用。
我们将基于Android的ViewModel来构建模型,因为这个解决方案与Android框架很好地集成,并且具备生命周期感知能力,但需要注意的是,这并非必需,其他解决方案同样可行。
为了创建脚手架,我们需要以下组件:
- 一个不可变的状态(模型),供视图观察。
- 一个管道来推送意图(我将其称为操作,以避免与Android的Intent混淆)。
- 一个减速器,用于根据现有状态和当前操作生成新的状态。
由于这个解决方案专为Jetpack Compose而设计,我们将使用MutableState作为模型。对于管道,我们将使用MutableSharedFlow来提供给减速器。虽然不是必需的,但我还喜欢为状态和操作定义标记接口。让我们看看MVI脚手架的代码:
// 1
interface State
// 2
interface Action
// 3
interface Reducer<S : State, A : Action> {
/**
* Generates a new instance of the [State] based on the [Action]
*
* @param state the current [State]
* @param action the [Action] to reduce the [State] with
* @return the reduced [State]
*/
fun reduce(state: S, action: A): S
}
private const val BufferSize = 64
// 4
open class MviViewModel<S : State, A : Action>(
private val reducer: Reducer<S, A>,
initialState: S,
) : ViewModel() {
// 5
private val actions = MutableSharedFlow<A>(extraBufferCapacity = BufferSize)
// 6
var state: S by mutableStateOf(initialState)
private set
init {
// 7
viewModelScope.launch {
actions.collect { action ->
state = reducer.reduce(state, action)
}
}
}
// 8
fun dispatch(action: A) {
val success = actions.tryEmit(action)
if (!success) error("MVI action buffer overflow")
}
}
-
我们为状态定义一个标记接口。
-
对于操作,我们也是如此做。
-
减速器将负责更新状态,它有一个单一的函数,该函数接受当前状态和操作,并生成一个新的状态。需要注意的是,减速器的reduce方法必须是纯函数——生成的状态只能依赖于输入的状态和操作,并且状态必须同步生成。
-
MviViewModel是MVI框架的基础。 MviViewModel接收一个减速器和初始状态以启动MVI流程。
-
对于操作的管道,我们使用MutableSharedFlow,并设置了一个特定的缓冲容量。
-
状态保存在MutableState对象中,并作为只读属性公开。它在MviViewModel构造函数中初始化为提供的初始状态。
-
当ViewModel启动时,我们启动一个协程来收集来自MutableSharedFlow的操作,每次发出操作时,我们运行减速器并相应地更新状态。需要注意的是,对于这个简单的示例,我使用viewModelScope作为协程范围,但建议为了更好的可测试性提供一个专用的范围。本文末尾链接的完整示例展示了实现方式。
-
最后,我们需要一种方法将操作推送到管道中,我们使用dispatch方法来实现,它接受一个操作并将其推送到MutableSharedFlow中。如果缓冲区已满,这可能表示某种问题,因此我们选择在这种情况下抛出异常。
有了这个脚手架,我们现在可以创建一个简单的应用程序。我们将创建您选择的典型架构的示例应用程序,一个带有两个按钮的计数器,一个用于增加计数,一个用于减少计数。
基本示例应用程序
对于我们的示例应用程序,我们需要以下几个组件:
- State
- Actions
- Reducer
- ViewModel
- UI
让我们从定义我们的状态开始。对于这个非常简单的示例,我们的状态只需保存一个属性,即当前计数值:
// 1
data class CounterState(
// 2
val counter: Int,
// 3
) : State {
companion object {
// 4
val initial: CounterState = CounterState(
counter = 0,
)
}
}
-
我们使用数据类作为状态,这样我们可以利用生成的函数,比如我们将用它来从现有状态创建新状态的复制函数。
-
我们的状态只有一个属性,即计数器的值。
-
该状态继承了我们的标记接口。
-
最后,我们提供一个默认值,作为起点使用。
接下来,我们将定义操作。对于这个示例,我们只有两个操作,一个用于增加状态,一个用于减少状态:
// 1
sealed interface CounterAction : Action {
// 2
data object Increment : CounterAction
// 3
data object Decrement : CounterAction
}
-
我们为计数器操作使用了一个密封接口,该接口继承自我们的标记接口。
-
我们所需的操作不携带任何有效负载,因此我们为增加操作创建了一个数据对象。在大多数应用程序中,当需要有效负载时,我们会同时使用数据对象和数据类。
-
对于减少操作,我们也是同样的做法。
现在我们有了状态和操作,我们可以构建我们的减速器,它负责根据当前状态和操作生成一个新状态。让我们来看看代码:
// 1
class CounterReducer : Reducer<CounterState, CounterAction> {
// 2
override fun reduce(state: CounterState, action: CounterAction): CounterState {
// 3
return when (action) {
CounterAction.Decrement -> state.copy(counter = state.counter - 1)
CounterAction.Increment -> state.copy(counter = state.counter + 1)
}
}
}
-
CounterReducer实现了Reducer接口。
-
我们重写了reduce函数,它负责生成状态。
-
我们对操作进行了全面的when操作,在每个操作中,根据当前状态生成一个新状态。
我们只剩下两个部分了,ViewModel和UI。让我们首先创建我们的viewModel:
class CounterViewModel(
// 1
reducer: CounterReducer = CounterReducer(),
// 2
) : MviViewModel<CounterState, CounterAction>(
reducer = reducer,
initialState = CounterState.initial,
) {
// 3
fun onDecrement() {
dispatch(CounterAction.Decrement)
}
// 4
fun onIncrement() {
dispatch(CounterAction.Increment)
}
}
-
CounterviewModel
以CounterReducer
作为构造函数参数接收。在这个示例中,我们在构造函数中实例化了减速器,但在真实的应用程序中,我们将使用依赖注入。 -
CounterviewModel
继承自我们的基本MviViewModel
,提供了减速器和初始状态。 -
我们定义了一个名为
onDecrement
的方法,它将向MVI管道推送减少操作。 -
我们对增加操作也是同样处理,定义了相应的
onIncrement
方法。
我们所剩下的就是UI,我会简要介绍一下,因为当涉及到MVI框架时,我们实际上如何将状态呈现到UI中的细节并不重要。这里有一个简单的UI显示计数器和两个按钮来增加/减少它:
@Composable
fun CounterScreen(
viewModel: CounterViewModel,
modifier: Modifier = Modifier,
) {
CounterScreen(
state = viewModel.state,
onDecreaseClick = viewModel::onDecrement,
onIncreaseClick = viewModel::onIncrement,
modifier = modifier,
)
}
@Composable
fun CounterScreen(
state: CounterState,
onDecreaseClick: () -> Unit,
onIncreaseClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDecreaseClick) {
Icon(
imageVector = Icons.Default.RemoveCircleOutline,
contentDescription = "decrement"
)
}
Text(
text = state.counter.toString(),
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(horizontal = 16.dp),
)
IconButton(onClick = onIncreaseClick) {
Icon(
imageVector = Icons.Default.AddCircleOutline,
contentDescription = "increment"
)
}
}
}
通过这个基本的MVI框架和一个样例应用程序,我们已经搭建好了。但是我们的解决方案缺少处理异步(或长时间运行的)操作的能力,因为我们的减速器正在同步地更新状态。接下来,我们将看到如何增强我们的MVI框架以支持异步工作。
处理异步工作
为了在MVI框架中处理异步工作,我们将添加一个新概念,即Middleware。Middleware是一个组件,它被插入到MVI管道中,并可以异步执行操作。Middleware通常会在其工作开始、期间和结束时发出自己的操作(例如,如果我们有一个需要进行网络调用的操作,则Middleware可能会发出一个操作来指示网络加载已开始,可能会发出其他操作来更新网络加载中的进度指示器,并在网络加载完成时发出最终加载完成的操作)。
与其他组件一样,我们将为Middleware创建一个基类:
// 1
interface Dispatcher<A : Action> {
fun dispatch(action: A)
}
// 2
abstract class Middleware<S : State, A : Action>() {
// 3
private lateinit var dispatcher: Dispatcher<A>
// 4
abstract suspend fun process(state: S, action: A)
// 5
protected fun dispatch(action: A) = dispatcher.dispatch(action)
// 6
internal fun setDispatcher(
dispatcher: Dispatcher<A>,
) {
this.dispatcher = dispatcher
}
}
-
Middleware需要分发自己的Actions,因此我们定义了一个Dispatcher接口(稍后我们将看到如何使用它)。
-
Middleware类是在State和Action上进行参数化的,与减速器类似。
-
Middleware将接收Dispacher以将操作推送到MVI管道。
-
挂起的process方法是异步工作将要进行的地方。
-
这是一个将操作推送到MVI框架的实用方法,以便我们可以将Dispatcher保持私有。
-
最后,我们有一个方法用于初始化Middleware中使用的Dispatcher。
接下来,让我们看看如何更新我们的MviViewModel以在MVI流程中插入Middleware:
open class MviViewModel<S : State, A : Action>(
private val reducer: Reducer<S, A>,
// 1
private val middlewares: List<Middleware<S, A>> = emptyList(),
initialState: S,
) : ViewModel(), Dispatcher<A> {
// 2
private data class ActionImpl<S : State, A : Action>(
val state: S,
val action: A,
)
private val actions = MutableSharedFlow<ActionImpl<S, A>>(extraBufferCapacity = BufferSize)
var state: S by mutableStateOf(initialState)
private set
init {
// 3
middlewares.forEach { middleware -> middleware.setDispatcher(this) }
// 4
viewModelScope.launch {
actions
.onEach { actionImpl ->
// 5
middlewares.forEach { middleware ->
// 6
middleware.process(actionImpl.state, actionImpl.action)
}
}
.collect()
}
viewModelScope.launch {
actions.collect {
state = reducer.reduce(state, it.action)
}
}
}
override fun dispatch(action: A) {
val success = actions.tryEmit(action)
if (!success) error("MVI action buffer overflow")
}
}
-
现在,MviViewModel接收一个中间件列表,默认为空列表,因为并不是所有屏幕都有异步工作。ViewModel还实现了Dispatcher接口。
-
我们定义了一个包装器类,它包装了当前状态和操作,我们将其推送到管道上,以便在接收操作时拥有状态的副本。
-
在init块中,我们循环遍历中间件,并为每个中间件设置调度器,即ViewModel本身。
-
接下来,我们启动一个协程来观察MutableSharedFlow的Action和State的发射。
-
对于每个发射,我们遍历所有中间件。
-
对于每个中间件,我们调用其process方法来处理操作。
这种方法的思想是,我们将拥有一组中间件,每个中间件负责应用程序的一部分业务逻辑;每个中间件将观察来自MVI管道的操作,当它负责的操作被发射时,它将启动异步操作。在一个大型应用程序中,我们可以将屏幕分成几个部分,由各自独立的中间件处理,或者我们可以根据它们执行的业务逻辑来分离中间件。思想是拥有小而精细的中间件,每个中间件只执行一个或一小组操作,而不是一个处理所有异步工作的大型中间件。
更新计数器应用程序
通过中间件和更新的MviViewModel,MVI框架已经完成,但是通过一个示例可以更容易理解,因此我们将在计数器屏幕上添加一个按钮,用于为计数器生成一个随机值。我们假设生成这个随机值是一个需要在后台线程上运行的长时间运行过程,因此我们将为此操作创建一个中间件。由于这是一个长时间运行的操作,我们将在执行工作时显示进度指示器。
我们将首先更新计数器状态,以包括加载指示器:
data class CounterState(
// 1
val loading: Boolean,
val counter: Int,
) : State {
companion object {
val initial: CounterState = CounterState(
// 2
loading = false,
counter = 0,
)
}
}
- 我们给状态添加了一个加载标志。
- 并将初始值更新为将该标志设置为false。
接下来,我们需要一个新的操作来生成随机计数器值,因此我们将其添加到封闭的层次结构中。同样地,当数字准备好时,我们需要更新状态,所以我们需要另一个操作来触发更新。对于这个第二个操作,我们有一个有效载荷,即随机生成的计数器值,因此我们将使用一个数据类。最后,我们希望在后台任务运行时显示加载指示器,所以我们将添加第三个操作来显示进度指示器:
sealed interface CounterAction : Action {
data object Loading : CounterAction
data object Increment : CounterAction
data object Decrement : CounterAction
data object GenerateRandom : CounterAction
data class CounterUpdated(val value: Int) : CounterAction
}
接下来,我们将创建中间件,负责生成随机数。当我们开始时,我们将发出一个加载操作,并在结束时发出CounterUpdated
操作。我们将使用延迟来模拟长时间操作:
// 1
class CounterMiddleware : Middleware<CounterState, CounterAction>() {
// 2
override suspend fun process(state: CounterState, action: CounterAction) {
// 3
when (action) {
CounterAction.GenerateRandom -> generateRandom()
else -> { /* no-op */ }
}
}
private suspend fun generateRandom() {
// 4
dispatch(CounterAction.Loading)
// 5
delay(500L + Random.nextLong(2000L))
val counterValue = Random.nextInt(100)
// 6
dispatch(CounterAction.CounterUpdated(counterValue))
}
}
- CounterMiddleware扩展了我们的中间件基类。
- 我们重写了负责异步工作的process方法。
- 我们检查操作,并只处理GenerateRandom操作。
- 当我们收到正确的操作时,我们发出Loading操作,这将触发状态更新以显示进度指示器。
- 接下来,我们开始工作,在这里通过延迟操作进行模拟。
- 而当工作完成时,我们通过一个新的操作发出结果。
这就是CounterMiddleware的全部内容。接下来,我们需要更新reducer以处理我们之前定义的额外操作。reducer不必处理所有的操作,GenerateRandom操作仅在中间件处处理,因此它将不执行任何操作。让我们看一下代码:
class CounterReducer : Reducer<CounterState, CounterAction> {
override fun reduce(state: CounterState, action: CounterAction): CounterState {
return when (action) {
// 1
CounterAction.Loading -> state.copy(loading = true)
CounterAction.Decrement -> state.copy(counter = state.counter - 1)
CounterAction.Increment -> state.copy(counter = state.counter + 1)
// 2
is CounterAction.CounterUpdated -> state.copy(
loading = false,
counter = action.value,
)
// 3
CounterAction.GenerateRandom -> state
}
}
}
- 当接收到
Loading
操作时,状态会更新以指示正在进行的长时间运行操作。 - 当接收到
CounterUpdated
操作时,我们清除加载标志,并使用操作有效载荷更新计数器值。 GenerateRandom
不在reducer
中处理,因此返回现有状态。
接下来,我们需要更新viewmodel
,将中间件提供给基类,并添加一个新方法来处理生成随机数的操作。让我们看一下更新:
class CounterViewModel(
// 1
middleware: CounterMiddleware = CounterMiddleware(),
reducer: CounterReducer = CounterReducer(),
) : MviViewModel<CounterState, CounterAction>(
reducer = reducer,
// 2
middlewares = listOf(middleware),
initialState = CounterState.initial,
) {
fun onDecrement() {
dispatch(CounterAction.Decrement)
}
fun onIncrement() {
dispatch(CounterAction.Increment)
}
// 3
fun onGenerateRandom() {
dispatch(CounterAction.GenerateRandom)
}
}
- 我们在构造函数中提供CounterMiddleware。像reducer一样,这通常是注入的,但为了简单起见,我们在此处实例化。
- 我们将中间件(在我们的情况下只有一个)提供给基类,以插入到MVI流中。
- 最后,我们有一个新的方法来处理生成随机计数器值。
这基本上结束了示例。最后一步是更新UI,以提供一个触发器来生成随机数字,并在应用程序忙于长时间运行的操作时显示进度指示器。以下代码展示了一个可能的实现方式:
@Composable
fun CounterScreen(
state: CounterState,
onDecreaseClick: () -> Unit,
onIncreaseClick: () -> Unit,
onGenerateRandom: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDecreaseClick,
enabled = !state.loading,
) {
Icon(
imageVector = Icons.Default.RemoveCircleOutline,
contentDescription = "decrement"
)
}
Text(
text = state.counter.toString(),
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(horizontal = 16.dp),
)
IconButton(
onClick = onIncreaseClick,
enabled = !state.loading,
) {
Icon(
imageVector = Icons.Default.AddCircleOutline,
contentDescription = "increment"
)
}
}
Button(
onClick = onGenerateRandom,
enabled = !state.loading,
modifier = Modifier.padding(top = 16.dp),
) {
Text(
text = "Generate Random"
)
}
}
if (state.loading) {
CircularProgressIndicator()
}
}
}
下面是一个MVI架构的示例代码,给感兴趣的读者作为参考。
GitHub
https://github.com/fvilarino/Weather-Sample