MVI架构如何改变Android开发模式

原文:xuanhu.info/projects/it...

MVI架构如何改变我Android开发模式

引言:为什么放弃传统架构模式

作为一名Android开发者,我经历了从MVC到MVP,再到MVVM的架构演变,但始终感觉缺少了什么------直到我遇见了MVI(Model-View-Intent)架构。

Google在Android应用架构指南中正式推荐MVI架构,这不仅仅是一种技术趋势,更是对高质量Android应用构建方式的重新思考。传统架构模式如MVP和MVVM在简单场景下表现良好,但当应用复杂度增加时,状态管理变得混乱,数据流向难以追踪,测试维护成本急剧上升。

MVI通过单向数据流不可变状态管理彻底改变了我的开发方式。在本文中,我将分享MVI如何解决实际开发中的痛点,并通过一个完整的购物车案例展示其强大威力。无论你是正在考虑架构迁移,还是单纯好奇MVI的价值,这篇文章都将为你提供深入的见解和实践指南。

MVI架构核心概念解析

什么是MVI架构?

MVI(Model-View-Intent)是一种基于响应式编程和函数式编程思想的架构模式。它的核心思想是将应用视为一个状态机,其中所有的状态变化都是通过明确的意图(Intent)触发,并遵循严格的单向数据流。

与MVP和MVVM不同,MVI强调三个基本原则:

  1. 单向数据流:数据只能从一个方向流动,从模型到视图,不允许反向修改

  2. 不可变状态:状态对象一旦创建就不能被修改,任何变化都通过创建新状态实现

  3. 意图驱动:所有用户操作和系统事件都被封装为明确的意图对象

这种设计使得应用的行为变得可预测易于调试,因为你可以清晰地追踪每个状态变化的来源和结果。

MVI架构的核心组件

Model(模型):不可变的状态容器

在MVI中,Model不再代表数据获取逻辑,而是代表当前UI的完整状态。这是一个根本性的转变。传统的Model负责数据操作,而MVI中的Model是一个纯粹的状态容器。

kotlin 复制代码
// 购物车页面的状态定义

data class ShoppingCartState(

val items: List<CartItem> = emptyList(),

val totalPrice: BigDecimal = BigDecimal.ZERO,

val isLoading: Boolean = false,

val errorMessage: String? = null,

val selectedItemIds: Set<String> = emptySet(),

val checkoutInProgress: Boolean = false

)

这个状态类包含了页面需要的所有数据:商品列表、总价、加载状态、错误信息等。关键是这个类是不可变的------所有属性都是val类型,任何修改都会创建新对象。

View(视图):状态的纯函数渲染

View的职责变得极其简单:根据当前状态渲染UI,并将用户操作转换为意图。View不再包含任何业务逻辑,它只是一个状态的函数:UI = f(State)。

kotlin 复制代码
class ShoppingCartActivity : AppCompatActivity() {

private lateinit var viewModel: ShoppingCartViewModel

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

// 订阅状态变化

viewModel.state.observe(this) { state ->

render(state)

}

}

private fun render(state: ShoppingCartState) {

// 根据状态更新UI

if (state.isLoading) {

showLoadingIndicator()

} else {

hideLoadingIndicator()

}

state.errorMessage?.let { showError(it) }

updateCartItems(state.items)

updateTotalPrice(state.totalPrice)

}

}

这种设计使View变得"笨拙"但可靠,它只关心如何显示数据,不关心数据如何产生或变化。

Intent(意图):用户操作的明确表示

Intent在MVI中不是Android框架中的Intent,而是代表用户意图的抽象概念。每个用户操作都被封装为一个意图对象。

kotlin 复制代码
// 购物车的所有可能意图

sealed class ShoppingCartIntent {

object LoadCart : ShoppingCartIntent()

data class UpdateItemQuantity(val itemId: String, val newQuantity: Int) : ShoppingCartIntent()

data class SelectItem(val itemId: String) : ShoppingCartIntent()

data class DeselectItem(val itemId: String) : ShoppingCartIntent()

object Checkout : ShoppingCartIntent()

object RetryLoading : ShoppingCartIntent()

}

通过密封类定义所有可能的意图,编译器可以确保我们处理了所有情况,减少了运行时错误的可能性。

MVI的工作流程:单向数据流详解

MVI架构最迷人的地方在于其简洁而强大的工作流程:

  1. 用户交互:用户在界面上执行操作(如点击按钮)

  2. 意图创建:View捕获操作并创建对应的Intent对象

  3. 意图处理:ViewModel接收Intent,执行业务逻辑

  4. 状态更新:ViewModel根据处理结果生成新状态

  5. UI渲染:View接收到新状态并重新渲染界面

这个流程形成一个严格的单向循环,数据永远朝着一个方向流动,不会出现双向绑定带来的复杂性。

graph LR A[用户操作] --> B[Intent创建] B --> C[ViewModel处理] C --> D[状态更新] D --> E[UI渲染] E --> A

这种单向数据流使得应用的行为变得可预测,在调试时可以轻松重现问题,因为每个状态变化都有明确的触发点。

MVI与传统架构的深度对比

MVP架构:经典的解耦尝试

MVP(Model-View-Presenter)是Android早期流行的架构模式,它通过Presenter将View和Model解耦。

MVP的工作方式

  • View负责UI显示和用户交互

  • Presenter包含业务逻辑,协调View和Model

  • Model负责数据操作

kotlin 复制代码
// MVP中的Presenter示例

class ShoppingCartPresenter(

private val view: ShoppingCartView,

private val repository: CartRepository

) {

fun loadCartItems() {

view.showLoading()

repository.getCartItems { items ->

view.hideLoading()

view.showItems(items)

}

}

}

MVP的问题

  • 双向耦合:View和Presenter相互引用,耦合度较高

  • 状态分散:状态可能分散在View和Presenter中,难以管理

  • 测试困难:需要模拟View接口,测试复杂度高

MVP在简单场景下有效,但当UI复杂时,Presenter容易变成"上帝类",包含过多逻辑难以维护。

MVVM架构:数据绑定的优势与陷阱

MVVM(Model-View-ViewModel)通过数据绑定机制进一步解耦,ViewModel不直接引用View,而是通过可观察的数据驱动UI更新。

MVVM的工作方式

  • View观察ViewModel中的数据变化

  • ViewModel暴露可观察数据(如LiveData、StateFlow)

  • 数据绑定自动更新UI

kotlin 复制代码
// MVVM中的ViewModel示例

class ShoppingCartViewModel : ViewModel() {

private val _items = MutableLiveData<List<CartItem>>()

val items: LiveData<List<CartItem>> = _items

private val _loading = MutableLiveData<Boolean>()

val loading: LiveData<Boolean> = _loading

fun loadCartItems() {

_loading.value = true

repository.getCartItems { items ->

_loading.value = false

_items.value = items

}

}

}

MVVM的优势

  • 更好的解耦:ViewModel不依赖Android UI组件

  • 生命周期感知:LiveData等组件自动处理生命周期

  • 数据绑定:减少模板代码

MVVM的陷阱

  • 数据不一致风险:多个LiveData可能状态不同步

  • 调试困难:数据绑定使数据流向不清晰

  • 状态分散:状态分散在多个LiveData中

MVI:集大成者的解决方案

MVI吸取了之前架构的经验教训,通过单向数据流和状态集中管理解决了上述问题。

kotlin 复制代码
// MVI中的ViewModel实现

class ShoppingCartViewModel : ViewModel() {

private val _state = MutableStateFlow(ShoppingCartState())

val state: StateFlow<ShoppingCartState> = _state

fun processIntent(intent: ShoppingCartIntent) {

when (intent) {

is ShoppingCartIntent.LoadCart -> loadCartItems()

is ShoppingCartIntent.UpdateItemQuantity -> updateQuantity(intent.itemId, intent.newQuantity)

// 处理其他意图...

}

}

private fun loadCartItems() {

_state.update { it.copy(isLoading = true) }

viewModelScope.launch {

try {

val items = cartRepository.loadCartItems()

_state.update { it.copy(

isLoading = false,

items = items,

totalPrice = calculateTotal(items)

) }

} catch (e: Exception) {

_state.update { it.copy(

isLoading = false,

errorMessage = "加载失败: ${e.message}"

) }

}

}

}

}

MVI的独特优势

  1. 状态一致性:所有UI状态集中在一个对象中,避免不一致

  2. 可预测性:单向数据流使状态变化路径清晰

  3. 易于调试:可以记录和重放状态序列

  4. 强类型安全:密封类确保处理所有意图类型

架构对比总结

::: tabs

@tab MVP

  • 数据流向:双向(View ↔ Presenter ↔ Model)

  • 状态管理:状态分散在View和Presenter中

  • 测试难度:中等(需要模拟View接口)

  • 适用场景:简单UI,逻辑不复杂的应用

@tab MVVM

  • 数据流向:双向绑定(View ↔ ViewModel ↔ Model)

  • 状态管理:状态分散在多个可观察对象中

  • 测试难度:较低(ViewModel易于单元测试)

  • 适用场景:中等复杂度应用,需要数据绑定优势

@tab MVI

  • 数据流向:严格单向(View → Intent → ViewModel → State → View)

  • 状态管理:状态集中在一个不可变对象中

  • 测试难度:低(纯函数式状态转换易于测试)

  • 适用场景:复杂UI,状态管理重要的应用

:::

从对比可以看出,MVI在复杂场景下具有明显优势,特别是当应用有复杂状态交互和严格的可预测性要求时。

MVI的实际优势:从理论到实践

可预测的状态管理

在传统架构中,状态可能分散在多个地方:Activity的成员变量、Presenter的缓存、多个LiveData等。当出现bug时,很难确定状态是如何变成当前值的。

MVI通过状态集中管理解决了这个问题。整个页面的状态只有一个来源,任何变化都通过创建新状态对象实现。这意味着:

kotlin 复制代码
// 错误的方式:分散的状态变量

class TraditionalViewModel : ViewModel() {

val items = MutableLiveData<List<Item>>()

val loading = MutableLiveData<Boolean>()

val error = MutableLiveData<String?>()

// 问题:可能忘记更新某个状态,导致不一致

}

  


// MVI的方式:集中状态管理

data class UiState(

val items: List<Item> = emptyList(),

val loading: Boolean = false,

val error: String? = null

)

  


class MviViewModel : ViewModel() {

private val _state = MutableStateFlow(UiState())

val state: StateFlow<UiState> = _state

// 任何状态更新都是原子的

fun updateItems(newItems: List<Item>) {

_state.update { it.copy(items = newItems) }

}

}

这种集中管理确保了状态的一致性原子性。当UI渲染时,它看到的是某个时间点的完整状态快照,不会出现部分更新的情况。

强大的可调试性

MVI架构为调试提供了强大支持。由于所有状态变化都是通过Intent触发,并且状态是不可变的,我们可以轻松实现时间旅行调试

kotlin 复制代码
// 调试记录器

class DebugLogger {

private val stateHistory = mutableListOf<UiState>()

private val intentHistory = mutableListOf<Intent>()

fun logIntent(intent: Intent) {

intentHistory.add(intent)

println("Intent: $intent")

}

fun logStateChange(oldState: UiState, newState: UiState) {

stateHistory.add(newState)

println("State changed from $oldState to $newState")

}

// 重现特定状态

fun replayToState(targetState: UiState) {

// 清空当前状态

// 按顺序重新执行intent直到达到目标状态

}

}

在实际调试中,当遇到异常状态时,我们可以检查状态历史记录,找到导致问题的Intent,从而快速定位问题根源。

卓越的可测试性

MVI架构的纯函数特性使其非常适合测试。Reducer函数(状态转换逻辑)是纯函数,对于相同输入总是产生相同输出。

kotlin 复制代码
// 可测试的Reducer函数

class ShoppingCartReducer {

fun reduce(oldState: ShoppingCartState, intent: ShoppingCartIntent): ShoppingCartState {

return when (intent) {

is ShoppingCartIntent.LoadCart -> oldState.copy(isLoading = true)

is ShoppingCartIntent.ItemsLoaded -> oldState.copy(

isLoading = false,

items = intent.items,

totalPrice = calculateTotal(intent.items)

)

is ShoppingCartIntent.UpdateItemQuantity -> {

val newItems = oldState.items.map { item ->

if (item.id == intent.itemId) item.copy(quantity = intent.newQuantity)

else item

}

oldState.copy(items = newItems, totalPrice = calculateTotal(newItems))

}

// 其他intent处理...

}

}

}

  


// 单元测试

@Test

fun `加载购物车时应该显示加载状态`() {

val initialState = ShoppingCartState()

val intent = ShoppingCartIntent.LoadCart

val newState = reducer.reduce(initialState, intent)

assertTrue(newState.isLoading)

}

  


@Test

fun `更新商品数量应该重新计算总价`() {

val items = listOf(

CartItem("1", "商品A", BigDecimal("10.00"), 2),

CartItem("2", "商品B", BigDecimal("20.00"), 1)

)

val initialState = ShoppingCartState(items = items, totalPrice = BigDecimal("40.00"))

val intent = ShoppingCartIntent.UpdateItemQuantity("1", 3)

val newState = reducer.reduce(initialState, intent)

assertEquals(3, newState.items.find { it.id == "1" }?.quantity)

assertEquals(BigDecimal("50.00"), newState.totalPrice) // 10*3 + 20*1 = 50

}

这种测试非常简单直接,不需要复杂的模拟设置,因为Reducer没有副作用,只依赖输入参数。

与Jetpack Compose的完美契合

随着Jetpack Compose成为Android官方推荐的UI工具包,MVI的价值更加凸显。Compose的核心思想是声明式UI,即UI是状态的函数,这与MVI的理念完全一致。

kotlin 复制代码
@Composable

fun ShoppingCartScreen(viewModel: ShoppingCartViewModel = hiltViewModel()) {

val state by viewModel.state.collectAsState()

ShoppingCartContent(

state = state,

onItemQuantityChanged = { itemId, quantity ->

viewModel.processIntent(ShoppingCartIntent.UpdateItemQuantity(itemId, quantity))

},

onCheckoutClicked = {

viewModel.processIntent(ShoppingCartIntent.Checkout)

}

)

}

  


@Composable

fun ShoppingCartContent(

state: ShoppingCartState,

onItemQuantityChanged: (String, Int) -> Unit,

onCheckoutClicked: () -> Unit

) {

if (state.isLoading) {

LoadingIndicator()

return

}

Column {

LazyColumn {

items(state.items) { item ->

CartItemRow(

item = item,

onQuantityChanged = { newQuantity ->

onItemQuantityChanged(item.id, newQuantity)

}

)

}

}

TotalPrice(price = state.totalPrice)

Button(

onClick = onCheckoutClicked,

enabled = !state.checkoutInProgress && state.items.isNotEmpty()

) {

Text("结算")

}

}

}

Compose与MVI的结合创造了极其强大的开发体验:UI自动响应状态变化,代码简洁明了,测试维护容易。

实战:构建MVI购物车应用

现在让我们通过一个完整的购物车案例,展示MVI架构在实际项目中的应用。这个案例包含商品列表、数量修改、价格计算、结算等完整流程。

项目结构和依赖配置

首先配置项目依赖,确保使用最新稳定版本:

kotlin 复制代码
// build.gradle.kts (Module级)

dependencies {

implementation("androidx.core:core-ktx:1.12.0")

implementation("androidx.appcompat:appcompat:1.6.1")

implementation("androidx.activity:activity-ktx:1.8.0")

implementation("androidx.fragment:fragment-ktx:1.6.1")

implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")

implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")

// Compose相关

implementation(platform("androidx.compose:compose-bom:2023.10.01"))

implementation("androidx.compose.ui:ui")

implementation("androidx.compose.ui:ui-graphics")

implementation("androidx.compose.ui:ui-tooling-preview")

implementation("androidx.compose.material3:material3")

implementation("androidx.activity:activity-compose:1.8.0")

implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")

// 协程

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

// 网络请求

implementation("com.squareup.retrofit2:retrofit:2.9.0")

implementation("com.squareup.retrofit2:converter-gson:2.9.0")

implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")

}

状态定义:完整的UI状态建模

购物车页面的状态需要包含所有可能的UI状态:

kotlin 复制代码
// 定义购物车商品数据类

data class CartItem(

val id: String,

val name: String,

val price: BigDecimal,

val quantity: Int,

val imageUrl: String? = null,

val maxQuantity: Int = 99

)

  


// 定义购物车状态

data class ShoppingCartState(

val items: List<CartItem> = emptyList(),

val totalPrice: BigDecimal = BigDecimal.ZERO,

val isLoading: Boolean = false,

val errorMessage: String? = null,

val selectedItemIds: Set<String> = emptySet(),

val checkoutInProgress: Boolean = false,

val checkoutSuccess: Boolean = false,

val lastUpdated: Long = System.currentTimeMillis()

) {

// 派生属性:选中商品的总价

val selectedItemsTotalPrice: BigDecimal

get() = items

.filter { it.id in selectedItemIds }

.sumOf { it.price * BigDecimal(it.quantity) }

// 派生属性:是否有选中的商品

val hasSelectedItems: Boolean

get() = selectedItemIds.isNotEmpty()

// 纯函数:创建更新了商品数量的新状态

fun withItemQuantityUpdated(itemId: String, newQuantity: Int): ShoppingCartState {

val updatedItems = items.map { item ->

if (item.id == itemId) item.copy(quantity = newQuantity.coerceIn(1, item.maxQuantity))

else item

}

return copy(

items = updatedItems,

totalPrice = calculateTotalPrice(updatedItems)

)

}

// 纯函数:计算总价

private fun calculateTotalPrice(items: List<CartItem>): BigDecimal {

return items.sumOf { it.price * BigDecimal(it.quantity) }

}

}

这个状态类设计了几个重要特性:

  1. 不可变性:所有属性都是val,修改通过复制实现

  2. 派生属性:通过getter计算衍生数据,避免存储冗余

  3. 纯函数方法:状态转换方法不修改原状态,返回新状态

意图定义:完整的用户操作枚举

使用密封类定义所有可能的用户操作:

kotlin 复制代码
// 购物车相关的所有意图

sealed class ShoppingCartIntent {

// 初始化相关

object LoadCart : ShoppingCartIntent()

object RetryLoading : ShoppingCartIntent()

// 商品操作相关

data class UpdateItemQuantity(val itemId: String, val newQuantity: Int) : ShoppingCartIntent()

data class SelectItem(val itemId: String) : ShoppingCartIntent()

data class DeselectItem(val itemId: String) : ShoppingCartIntent()

object SelectAllItems : ShoppingCartIntent()

object DeselectAllItems : ShoppingCartIntent()

data class RemoveItem(val itemId: String) : ShoppingCartIntent()

// 结算相关

object Checkout : ShoppingCartIntent()

object ConfirmCheckout : ShoppingCartIntent()

object CancelCheckout : ShoppingCartIntent()

// 错误处理

object DismissError : ShoppingCartIntent()

}

  


// 网络请求结果(用于Reducer处理)

sealed class CartResult {

data class ItemsLoaded(val items: List<CartItem>) : CartResult()

data class CheckoutSuccess(val orderId: String) : CartResult()

data class Error(val message: String) : CartResult()

object Loading : CartResult()

}

密封类确保了类型安全,编译器会检查是否处理了所有情况。

ViewModel实现:业务逻辑的核心

ViewModel负责处理Intent、执行业务逻辑、管理状态:

kotlin 复制代码
class ShoppingCartViewModel(

private val cartRepository: CartRepository

) : ViewModel() {

// 使用StateFlow管理状态

private val _state = MutableStateFlow(ShoppingCartState())

val state: StateFlow<ShoppingCartState> = _state

// 处理副作用的Channel(导航、Toast等)

private val _effects = Channel<CartEffect>()

val effects: Flow<CartEffect> = _effects.receiveAsFlow()

// Intent处理入口

fun processIntent(intent: ShoppingCartIntent) {

viewModelScope.launch {

when (intent) {

is ShoppingCartIntent.LoadCart -> loadCartItems()

is ShoppingCartIntent.RetryLoading -> loadCartItems()

is ShoppingCartIntent.UpdateItemQuantity -> updateItemQuantity(intent.itemId, intent.newQuantity)

is ShoppingCartIntent.SelectItem -> selectItem(intent.itemId)

is ShoppingCartIntent.DeselectItem -> deselectItem(intent.itemId)

is ShoppingCartIntent.SelectAllItems -> selectAllItems()

is ShoppingCartIntent.DeselectAllItems -> deselectAllItems()

is ShoppingCartIntent.RemoveItem -> removeItem(intent.itemId)

is ShoppingCartIntent.Checkout -> initiateCheckout()

is ShoppingCartIntent.ConfirmCheckout -> confirmCheckout()

is ShoppingCartIntent.CancelCheckout -> cancelCheckout()

is ShoppingCartIntent.DismissError -> dismissError()

}

}

}

private suspend fun loadCartItems() {

_state.update { it.copy(isLoading = true, errorMessage = null) }

try {

val items = cartRepository.getCartItems()

_state.update { it.copy(

isLoading = false,

items = items,

totalPrice = calculateTotalPrice(items)

) }

} catch (e: Exception) {

_state.update { it.copy(

isLoading = false,

errorMessage = "加载失败: ${e.message}"

) }

}

}

private fun updateItemQuantity(itemId: String, newQuantity: Int) {

val newState = _state.value.withItemQuantityUpdated(itemId, newQuantity)

_state.update { newState }

// 异步保存到服务器

viewModelScope.launch {

try {

cartRepository.updateItemQuantity(itemId, newQuantity)

} catch (e: Exception) {

_effects.send(CartEffect.ShowToast("数量更新失败"))

// 回滚状态?或者显示错误但保持本地修改

}

}

}

private fun selectItem(itemId: String) {

val newSelectedIds = _state.value.selectedItemIds + itemId

_state.update { it.copy(selectedItemIds = newSelectedIds) }

}

private fun initiateCheckout() {

if (_state.value.hasSelectedItems) {

_state.update { it.copy(checkoutInProgress = true) }

_effects.send(CartEffect.ShowCheckoutDialog)

} else {

_effects.send(CartEffect.ShowToast("请选择要结算的商品"))

}

}

private suspend fun confirmCheckout() {

val selectedItems = _state.value.items

.filter { it.id in _state.value.selectedItemIds }

try {

val orderId = cartRepository.checkout(selectedItems)

_effects.send(CartEffect.NavigateToOrderConfirmation(orderId))

_state.update { it.copy(

checkoutInProgress = false,

checkoutSuccess = true

) }

} catch (e: Exception) {

_state.update { it.copy(checkoutInProgress = false) }

_effects.send(CartEffect.ShowToast("结算失败: ${e.message}"))

}

}

// 其他Intent处理方法...

}

  


// 副作用定义

sealed class CartEffect {

data class ShowToast(val message: String) : CartEffect()

object ShowCheckoutDialog : CartEffect()

data class NavigateToOrderConfirmation(val orderId: String) : CartEffect()

}

这个ViewModel展示了几个关键设计:

  1. 单一入口:所有操作都通过processIntent方法处理

  2. 状态不可变:通过copy创建新状态,而不是修改原状态

  3. 副作用分离:导航、Toast等一次性操作通过Effect通道发送

  4. 错误处理:每个操作都有完整的错误处理

UI实现:Compose声明式界面

使用Jetpack Compose实现响应式UI:

kotlin 复制代码
@Composable

fun ShoppingCartScreen(

viewModel: ShoppingCartViewModel = hiltViewModel()

) {

val state by viewModel.state.collectAsState()

val context = LocalContext.current

// 收集副作用

LaunchedEffect(Unit) {

viewModel.effects.collect { effect ->

when (effect) {

is CartEffect.ShowToast -> {

Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()

}

is CartEffect.NavigateToOrderConfirmation -> {

// 处理导航逻辑

context.startActivity(

OrderConfirmationActivity.createIntent(context, effect.orderId)

)

}

CartEffect.ShowCheckoutDialog -> {

// 显示结算对话框

showCheckoutDialog(context, viewModel)

}

}

}

}

ShoppingCartScaffold(

state = state,

onIntent = viewModel::processIntent

)

}

  


@Composable

fun ShoppingCartScaffold(

state: ShoppingCartState,

onIntent: (ShoppingCartIntent) -> Unit

) {

Scaffold(

topBar = {

TopAppBar(

title = { Text("购物车") },

actions = {

if (state.hasSelectedItems) {

Text("已选${state.selectedItemIds.size}件")

}

}

)

},

bottomBar = {

ShoppingCartBottomBar(state, onIntent)

}

) { padding ->

when {

state.isLoading -> LoadingContent(padding)

state.errorMessage != null -> ErrorContent(

message = state.errorMessage,

onRetry = { onIntent(ShoppingCartIntent.RetryLoading) },

padding = padding

)

state.items.isEmpty() -> EmptyCartContent(padding)

else -> ShoppingCartContent(

state = state,

onIntent = onIntent,

padding = padding

)

}

}

}

  


@Composable

fun ShoppingCartContent(

state: ShoppingCartState,

onIntent: (ShoppingCartIntent) -> Unit,

padding: PaddingValues

) {

Column(modifier = Modifier.padding(padding)) {

// 全选操作栏

Row(

modifier = Modifier

.fillMaxWidth()

.padding(16.dp),

verticalAlignment = Alignment.CenterVertically

) {

val allSelected = state.selectedItemIds.size == state.items.size

Checkbox(

checked = allSelected,

onCheckedChange = { checked ->

if (checked) {

onIntent(ShoppingCartIntent.SelectAllItems)

} else {

onIntent(ShoppingCartIntent.DeselectAllItems)

}

}

)

Text("全选")

Spacer(modifier = Modifier.weight(1f))

Text(

text = "合计: ¥${state.selectedItemsTotalPrice}",

style = MaterialTheme.typography.titleMedium

)

}

LazyColumn {

items(state.items, key = { it.id }) { item ->

CartItemRow(

item = item,

isSelected = item.id in state.selectedItemIds,

onQuantityChanged = { newQuantity ->

onIntent(ShoppingCartIntent.UpdateItemQuantity(item.id, newQuantity))

},

onSelectedChanged = { selected ->

if (selected) {

onIntent(ShoppingCartIntent.SelectItem(item.id))

} else {

onIntent(ShoppingCartIntent.DeselectItem(item.id))

}

},

onRemove = {

onIntent(ShoppingCartIntent.RemoveItem(item.id))

}

)

Divider()

}

}

}

}

  


@Composable

fun CartItemRow(

item: CartItem,

isSelected: Boolean,

onQuantityChanged: (Int) -> Unit,

onSelectedChanged: (Boolean) -> Unit,

onRemove: () -> Unit

) {

Row(

modifier = Modifier

.fillMaxWidth()

.padding(16.dp),

verticalAlignment = Alignment.CenterVertically

) {

Checkbox(

checked = isSelected,

onCheckedChange = onSelectedChanged

)

AsyncImage(

model = item.imageUrl,

contentDescription = item.name,

modifier = Modifier.size(60.dp)

)

Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {

Text(item.name, style = MaterialTheme.typography.bodyLarge)

Text("¥${item.price}", style = MaterialTheme.typography.bodyMedium)

}

QuantitySelector(

currentQuantity = item.quantity,

maxQuantity = item.maxQuantity,

onQuantityChanged = onQuantityChanged

)

IconButton(onClick = onRemove) {

Icon(Icons.Default.Delete, contentDescription = "删除")

}

}

}

  


@Composable

fun ShoppingCartBottomBar(

state: ShoppingCartState,

onIntent: (ShoppingCartIntent) -> Unit

) {

BottomAppBar {

Row(

modifier = Modifier.fillMaxWidth(),

horizontalArrangement = Arrangement.SpaceBetween,

verticalAlignment = Alignment.CenterVertically

) {

Text(

text = "总计: ¥${state.totalPrice}",

style = MaterialTheme.typography.titleLarge

)

Button(

onClick = { onIntent(ShoppingCartIntent.Checkout) },

enabled = state.hasSelectedItems && !state.checkoutInProgress

) {

if (state.checkoutInProgress) {

CircularProgressIndicator(modifier = Modifier.size(16.dp))

} else {

Text("结算(${state.selectedItemIds.size})")

}

}

}

}

}

这个UI实现展示了声明式编程的强大之处:

  1. 自动响应:UI自动响应状态变化,无需手动更新

  2. 组合性:通过小组件组合成复杂界面

  3. 状态驱动:UI完全由状态决定,没有内部状态逻辑

测试策略:确保代码质量

MVI架构的测试非常直观,我们可以分别测试各个组件:

kotlin 复制代码
// ViewModel测试

@Test

fun `初始状态应该是空购物车`() = runTest {

val viewModel = ShoppingCartViewModel(FakeCartRepository())

assertEquals(ShoppingCartState(), viewModel.state.value)

}

  


@Test

fun `加载购物车应该更新状态`() = runTest {

val repository = FakeCartRepository()

repository.setItems(listOf(

CartItem("1", "商品A", BigDecimal("10.00"), 1)

))

val viewModel = ShoppingCartViewModel(repository)

viewModel.processIntent(ShoppingCartIntent.LoadCart)

advanceUntilIdle()

assertFalse(viewModel.state.value.isLoading)

assertEquals(1, viewModel.state.value.items.size)

assertEquals(BigDecimal("10.00"), viewModel.state.value.totalPrice)

}

  


@Test

fun `更新商品数量应该重新计算总价`() = runTest {

val items = listOf(

CartItem("1", "商品A", BigDecimal("10.00"), 2)

)

val repository = FakeCartRepository()

repository.setItems(items)

val viewModel = ShoppingCartViewModel(repository)

viewModel.processIntent(ShoppingCartIntent.LoadCart)

advanceUntilIdle()

viewModel.processIntent(ShoppingCartIntent.UpdateItemQuantity("1", 3))

assertEquals(3, viewModel.state.value.items[0].quantity)

assertEquals(BigDecimal("30.00"), viewModel.state.value.totalPrice)

}

  


// Reducer纯函数测试

@Test

fun `Reducer应该正确处理加载中状态`() {

val reducer = ShoppingCartReducer()

val initialState = ShoppingCartState()

val intent = ShoppingCartIntent.LoadCart

val newState = reducer.reduce(initialState, intent)

assertTrue(newState.isLoading)

assertNull(newState.errorMessage)

}

  


// UI测试

@Test

fun `空购物车应该显示空状态界面`() {

val emptyState = ShoppingCartState(items = emptyList())

composeTestRule.setContent {

ShoppingCartContent(

state = emptyState,

onIntent = { },

padding = PaddingValues()

)

}

composeTestRule.onNodeWithText("购物车为空").assertExists()

}

MVI架构使测试变得简单而全面,每个组件都可以独立测试。

MVI架构的挑战与解决方案

学习曲线和团队适应

MVI的概念对于习惯了MVP或MVVM的开发者来说确实有一定学习曲线。响应式编程、不可变状态、单向数据流等概念需要时间消化。

解决方案

  1. 渐进式采用:先在项目的新模块中尝试MVI,积累经验

  2. 团队培训:组织内部技术分享,建立最佳实践文档

  3. 代码模板:创建MVI架构的代码模板,减少初始设置成本

  4. 结对编程:有经验的开发者带领其他成员共同开发

样板代码问题

MVI需要定义大量的数据类(State、Intent、Effect等),确实会增加一些样板代码。

解决方案

  1. 代码生成:使用KSP(Kotlin Symbol Processing)或注解处理器生成模板代码

  2. 通用基类:创建通用的BaseState、BaseIntent等基类

  3. DSL扩展:使用Kotlin DSL简化状态更新和Intent处理

kotlin 复制代码
// 使用DSL简化状态更新

inline fun <T> MutableStateFlow<T>.update(transform: (T) -> T) {

this.value = transform(this.value)

}

  


// 简化Intent处理

abstract class BaseMviViewModel<State, Intent> : ViewModel() {

abstract val state: StateFlow<State>

abstract fun processIntent(intent: Intent)

}

  


// 使用泛型减少重复代码

interface MviState

interface MviIntent

  


abstract class TypedMviViewModel<S : MviState, I : MviIntent> : BaseMviViewModel<S, I>()

状态对象膨胀问题

复杂页面的状态类可能包含大量属性,变得臃肿难以维护。

解决方案

  1. 状态嵌套:将相关状态分组到嵌套类中

  2. 状态分片:对于复杂页面,拆分为多个子状态

  3. 派生属性:使用getter计算衍生状态,避免存储

kotlin 复制代码
// 状态嵌套示例

data class ShoppingCartState(

val itemsState: ItemsState = ItemsState(),

val checkoutState: CheckoutState = CheckoutState(),

val uiState: UiState = UiState()

) {

data class ItemsState(

val items: List<CartItem> = emptyList(),

val selectedIds: Set<String> = emptySet()

)

data class CheckoutState(

val inProgress: Boolean = false,

val success: Boolean = false

)

data class UiState(

val isLoading: Boolean = false,

val errorMessage: String? = null

)

}

副作用管理挑战

MVI中副作用(导航、对话框、Toast等)的管理需要额外设计,增加了复杂度。

解决方案

  1. 明确副作用通道:使用Channel或SharedFlow管理副作用

  2. 副作用分类:将副作用按类型分类处理

  3. 中间件模式:使用中间件处理通用副作用(如加载状态、错误处理)

kotlin 复制代码
// 副作用中间件示例

class EffectMiddleware<Intent, State, Effect> {

private val handlers = mutableListable<(Intent, State) -> Effect?>()

fun addHandler(handler: (Intent, State) -> Effect?) {

handlers.add(handler)

}

fun process(intent: Intent, state: State): Effect? {

for (handler in handlers) {

val effect = handler(intent, state)

if (effect != null) return effect

}

return null

}

}

  


// 通用错误处理中间件

val errorHandler = { intent: Intent, state: State ->

when (intent) {

is Intent.DismissError -> null

is Intent.Retry -> null

else -> {

if (state.errorMessage != null) {

Effect.ShowError(state.errorMessage)

} else null

}

}

}

总结

MVI架构确实彻底改变了我的Android开发方式。通过单向数据流不可变状态意图驱动的设计,MVI解决了传统架构在复杂应用中的核心问题。

关键收获

  1. 可预测性:状态变化路径清晰,调试效率大幅提升

  2. 一致性:状态集中管理,避免了数据不同步问题

  3. 可测试性:纯函数式设计使单元测试变得简单可靠

  4. 可维护性:关注点分离,代码结构清晰易懂

何时选择MVI架构

基于我的经验,MVI特别适合以下场景:

  • 复杂UI状态:页面有多个交互元素和复杂状态逻辑

  • 团队协作:需要明确的架构规范保证代码一致性

  • 长期维护:项目需要长期迭代和维护

  • 高质量要求:对稳定性和可测试性有高要求

对于简单页面或原型项目,MVI可能显得过于重型,此时可以考虑简化版本或传统架构。

未来

随着Jetpack Compose的普及,MVI架构的重要性将进一步增强。Compose的声明式特性与MVI的理念天然契合,两者结合将创造更优秀的开发体验。

同时,社区也在不断改进MVI的实现方式,减少样板代码,提供更好的工具支持。未来可能会出现更智能的状态管理方案和更高效的开发工具。

原文:xuanhu.info/projects/it...

相关推荐
小孔龙2 小时前
K2 编译器 - Symbol(符号系统)
kotlin·编译器
梦终剧3 小时前
【Android之路】.sp和界面层次结构
android
2501_916008893 小时前
iOS 26 软件性能测试全流程,启动渲染资源压力对比与优化策略
android·macos·ios·小程序·uni-app·cocoa·iphone
9号达人3 小时前
Java18 新特性详解与实践
java·后端·面试
zh_xuan3 小时前
Android Handler源码阅读
android
学历真的很重要3 小时前
Claude Code 万字斜杠命令指南
后端·语言模型·面试·职场和发展·golang·ai编程
雪饼android之路3 小时前
【Android】 android suspend/resume总结(3)
android·linux
00后程序员张3 小时前
iOS 26 兼容测试实战,机型兼容、SwiftUI 兼容性改动
android·ios·小程序·uni-app·swiftui·cocoa·iphone
molong9314 小时前
Android 应用配置跳转微信小程序
android·微信小程序·小程序