MVI架构:Compose中的响应式状态管理

本文译自「Reactive State Management in Compose --- MVI Architecture」,原文链接proandroiddev.com/reactive-st...,由Davies Adedayo AbdulGafar发布于2025年4月22日。

译注:原文作者虽然是基于Jetpack Compose来写的,但重点讨论的是应用的MVI架构方式,涉及的都是纯Kotlin语言层面的,以及Compose层面的,并不涉及平台特性,因此完全适用于跨平台的Compose Multiplatform。

MVI是什么鬼

MVI架构的基本概念

MVI(模型-视图-意图)架构为 Android 应用程序中可扩展、稳健且可测试的 UI 状态管理提供了一种结构良好的方法。它强调代码简洁和关注点分离(Separation of Concerns),将应用程序划分为三个主要组件------模型(Model)、视图(View)和意图(Intent)------它们共同构成一个循环:意图 -> 视图模型 -> 模型 -> 视图,从而定义单向数据流(**译注:**这里的意图Intent是架构中的一个逻辑概念,与Android系统中的Intent没有关系)。此架构模式提供的不同角色有助于更轻松地理解和维护 UI 状态。从本质上讲,MVI 不仅仅是一种架构模式,而是一个旨在流畅响应变化的响应式系统。这种响应性是其定义特征之一,也是其最大的优势。

  • 单向数据流:指数据以单向流动------从模型流向视图,并以意图的形式返回。这确保了架构的清晰度、可预测性和易维护性。
  • 关注点分离:指模型、视图和意图组件具有不同的角色。模型管理状态,视图处理 UI 渲染,意图捕获并传达用户操作。
  • 不可变性(Immutability):确保模型的状态一旦设置便保持不变。这保证了可预测性,消除了意外的副作用,并促进了稳定可靠的应用状态。
  • 响应式:当状态发生变化时,UI 会自动更新。

该架构分解为三个和谐的组件,它们以响应式流程协同工作:

  • 模型 (Model) 是单一事实来源,它是应用程序在任何特定时刻的状态快照。当此状态发生变化时,它会触发整个系统的级联响应式更新。UI 会在状态变化时自动更新,这凸显了这一核心的响应式原则。
  • 视图 (View) 根据当前模型状态以响应式方式渲染用户所见内容。它订阅状态变化并自动进行转换以反映这些变化,而无需任何命令式更新调用。这种响应式渲染正是 MVI 如此强大的原因------视图始终与状态同步。
  • 意图 (Intent) 完善了响应式电路,捕获用户交互并将其反馈回系统以创建新状态。这形成了一个持续的反馈循环:用户操作触发意图,意图产生新状态,新状态触发 UI 更新。

当我们说 MVI 具有响应式特性时,我们指的是整个系统都是围绕自动响应变化而构建的。数据发生变化时,UI 无需手动更新,而是会自动反映当前状态。这种响应式特性能够创建一个动态、响应迅速的应用程序,让用户感觉生动活泼。

MVI架构的典型实现方式

在原生 Android 开发中,MVI 的大部分实现都放在 ViewModel 类中。以下是实现 MVI 模式的一种简单方法:

  1. 我们打算建模的 UI 状态将实现为一个不可变的 Kotlin 数据类(data class),其字段保存着我们想要在视图中显示的状态。
  2. StateFlow 是将整个架构绑定在一起的响应式粘合剂。这个观察者对象包装了模型,并将变化通知给视图,以便它反映新的状态。这个响应式管道确保任何状态变化都会自动传播到 UI。
  3. 目前,我们的意图可以实现为 ViewModel 中的公共函数。这些函数应该没有返回值,以确保视图只接收来自观察者的状态更新。类似于对象作为编程语言中的一等公民,可以通过引用传递,同样,函数也依赖于方法引用。我们利用这一点将意图传递给使用它的 UI 节点。我们无需编写复杂的类来建模意图,因为那样需要额外实现意图处理程序。

以下是在 ViewModel 中实现 MVI 模式的模板。

Kotlin 复制代码
class ScreenViewModel() : ViewModel() {
  private val _uiState: MutableStateFlow<MyModel> = MutableStateFlow(MyModel(...)) // private observer object
 
  val uiState: StateFlow<MyModel> = _uiState // observer object exposed as an immutable instance
 
  fun doUpdateOnState(...) { ... } // public function serves as an intent
  fun doAnotherUpdate(...) { ... }
}

data class MyModel(...)

新状态在 ViewModel 中生成,然后由观察 uiState 的视图使用。请注意,Intent 是如何作为触发状态更改的回调传递到可组合项页面的。

Kotlin 复制代码
@Composable
 fun MyScreen(viewModel: ScreenViewModel) {
   val uiState: MyModel = viewModel.uiState.collectAsStateWithLifeCycle() // consumes the state produced in the viewModel
  
   MyScreenContent(
     uiState = uiState,
     doUpdate = viewModel::doUpdateOnState, // intent to do update
     doAnotherUpdate = viewModel::doAnotherUpdate // intent to do another update
   )
 }

这种反应模式通过 Kotlin 的 StateFlow 实现:

Kotlin 复制代码
private val _uiState: MutableStateFlow<MyModel> = MutableStateFlow(MyModel(...))
val uiState: StateFlow<MyModel> = _uiState

在 UI 方面,这种反应性通过收集操作来表达,该操作消耗从 viewModel 生成的新状态。

Kotlin 复制代码
val uiState = viewModel.uiState.collectAsStateWithLifeCycle()

这行代码建立了一个响应式连接,每当状态发生变化时都会自动刷新 UI。无需手动刷新调用或复杂的更新逻辑------系统本身就是响应式的。

案例研究

让我们采用更实用的方法,实现 MVI 模式来管理页面 状态。下图是一个页面,用户可以从给定的选项中选择所显示问题的答案。

图片来源 --- Compose 示例 Jetsurvey。点击此 处查看完整实现。

页面包含以下状态:

  1. 问题
  2. 选项列表
  3. 问题计数
  4. 选择指示器
  5. 启用/禁用按钮

此外,页面还提供以下用户操作的输入:

  1. 获取下一个问题
  2. 获取上一个问题
  3. 选择一个选项
  4. 关闭/结束操作。这些操作用于将用户的意图传达给应用程序。

保存页面状态的模型可以这样实现:

Kotlin 复制代码
data class UiState(
  val questionCount: Int,
  val totalQuestion: Int,
  val question: Question,
  val userSelection: Option?
) {
  val hasNext: Boolean = questionCount < totalQuestion
  val hasPrevious: Boolean = questionCount > 1
}

如前所述,为了简单起见,我们使用公共函数来描述意图,这些函数没有返回值。我们在下面列举了将在 ViewModel 中实现的函数。

Kotlin 复制代码
fun next() // 加载下一个问题
fun previous() // 加载上一个问题
fun onOptionSelected(selection: Option) // 激活选中选项的标识

在视图中调用的每个函数都会触发一个新的状态以供视图使用,这样我们的页面就是可预测和可测试的,因为每个用户交互都会产生一个新的不可变状态,可以在测试期间进行比较。

Kotlin 复制代码
@Composable
fun QuestionScreen(
    modifier: Modifier = Modifier,
    onDone: () -> Unit
) {
    BackHandler { /* Do nothing */ }
    val viewModel = QuestionViewModel(getQuestions())
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    QuestionScreenContent(
        uiState = uiState,
        onClickNext = viewModel::next,
        onClickPrevious = viewModel::previous,
        onOptionSelected = viewModel::onOptionSelected,
        onDone = onDone
    )
}

注意:并非所有带有状态的页面组件都能用 MVI 管理。有些组件由单独的状态持有类管理,有些则由可组合组件本身的内部状态管理------例如上图中的进度指示器。不真正处理业务逻辑的状态不应该使用 MVI 管理。

超越基本响应式

上述案例研究基于我们的页面需求,使用了一个模型的简单实现。编写 MVI 模式的模型实现有很多方法------根据页面需求进行实现------例如,一个包含加载和错误状态的页面。通常,最简单的方法是使用密封的类层次结构来实现------尽管如此,你也可以选择以不同的方式实现你自己的模型。

Kotlin 复制代码
// 考虑加载和错误状态的模型
internal sealed class UiState {
    object Loading : UiState()
    @Immutable
    class Content(val myModel: UiModel) : UiState()    
    @Immutable
    class Error(val error: ErrorUiModel): UiState()
}

视图使用这种新类型的方式略有不同,如下所示:

Kotlin 复制代码
 @Composable
 fun MyScreenContent(uiState: UiState) {
	when(uiState) {
		is Loading -> LoadingScreen() 
		is Error -> ErrorScreen(uiState.error)
		is Content -> ContentScreen(uiState.myModel)
	}
 }

fun LoadingScreen() { /* implementation block */ }
fun ErrorScreen(error: ErrorUiModel) { /* implementation block */ }
fun ContentScreen(content: UiModel) { /* implementation block */ }

需要注意的是,这种模型是互斥的------它保证三个状态不会同时发生,而是一次发生一个,这有助于防止 UI 状态渲染中常见的错误。

重写 Intent 实现

Intent 实现也可以通过添加 Reducer/Handler 来修改,Reducer/Handler 是 ViewModel 中的一个公共函数,它会调用私有实现(辅助函数)来执行操作,并使用 when 表达式分支到相应的操作。

Kotlin 复制代码
Class HomeScreenViewModel() : ViewModel() {
	/*
	* Some class properties
	*/
	
	// our reducer/handler
    fun onHomeAction(action: HomeAction) {
        when (action) {
            is HomeAction.CategorySelected -> onCategorySelected(action.category)
            is HomeAction.TopicFollowed -> onTopicFollowed(action.topic)
            is HomeAction.HomeCategorySelected -> onHomeCategorySelected(action.category)
            is HomeAction.ToggleTopicFollowed -> onToggleTopicFollowed(action.topic)
        }
    }
	
    private fun onCategorySelected(category: CategoryInfo) { ... }
    
    private fun onTopicFollowed(topic: TopicInfo) { ... }
    
    private fun onToggleTopicFollowed(topic: TopicInfo) { ... }
    
    private fun onHomeCategorySelected(category: HomeCategory) { ... }
}

为了使此设置正常工作,我们定义了一个密封的接口层次结构,对应于每个操作,其子类属性用于保存参数,然后这些参数将通过视图中的 reducer/handler 传递给这些操作。

Kotlin 复制代码
@Immutable
sealed interface HomeAction {
    data class CategorySelected(val category: CategoryInfo) : HomeAction
    data class HomeCategorySelected(val category: HomeCategory) : HomeAction
    data class TopicFollowed(val topic: TopicInfo) : HomeAction
    data class ToggleTopicFollowed(val topic: TopicInfo) : HomeAction
}

与我们第一次实现 Intent 时需要在视图中使用方法引用传递所有操作不同,这里我们只需要传递 Reducer/Handler。然后,决定需要调用哪个操作的责任就落在了调用者身上。

这是视图中的样子

Kotlin 复制代码
@Composable
fun HomeScreen(viewModel: HomeScreenViewModel, onNavigate: (String) -> Unit) {
	
	HomeContent(
        modifier = Modifier.padding(contentPadding),
        onHomeAction = viewModel::onHomeAction,
        onNavigate = onNavigate,
    )
    
}

HomeContent 可组合函数现在负责决定调用哪个操作,方法是实例化以下任意对象,然后使用实例化的对象调用 onHomeAction

Kotlin 复制代码
	HomeAction.CategorySelected(category = CategoryInfo())
	HomeAction.HomeCategorySelected(category = HomeCategory())
	HomeAction.TopicFollowed(topic = TopicInfo())
	HomeAction.ToggleTopicFollowed(topic = TopicInfo())

请参阅下面的实际操作!

Kotlin 复制代码
@Composable
private fun HomeContent(
    modifier: Modifier = Modifier,
    onHomeAction: (HomeAction) -> Unit, // HomeAction是一个密封类型层次
    onNavigate: (String) -> Unit,
) {
	val homeCategory = HomeAction.HomeCategorySelected(CategoryInfo())
	onHomeAction(homeCategory) // triggers a state change
}

即使增加了复杂度,核心的响应式原则依然保持不变。Reducer 只是提供了一种更有条理的方式来处理意图并生成新的状态,而从状态到 UI 的响应式流程保持不变。

这种实现方式使得迭代构建变得简单------无论我们需要对意图进行什么更改(无论是添加新的意图还是删除现有的意图),都不需要像我们最初的实现那样在很多地方进行重写;我们只需在处理程序中注册新的操作,然后在调用处理程序时根据具体情况实例化不同的对象即可。

总结一下这篇文章,MVI 最优雅的方面在于它如何创建一个完整的响应式链路:

  1. 模型发出状态
  2. 视图使用状态并渲染 UI
  3. 用户与视图的交互生成意图
  4. 意图被处理以创建新的模型
  5. 这个循环以响应式的方式持续进行

这种不间断的响应式循环确保你的应用程序始终与用户操作和后端数据保持同步。这不仅仅是响应变化------而是创建一个系统,让变化自然地通过预定义的响应式路径进行。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
阿巴斯甜21 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker21 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇2 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android