Android 首页 ViewModel MVI 重构实战:从 combine 嵌套到单向数据流

0 阅读9分钟

前言

当前项目是一个海外信贷 App 的首页模块,而首页的 ViewModel 随业务迭代变得越来越难维护,因此决定重构它。

具体来说,主要面临以下问题:UiState 中弹窗状态用多个 Boolean 表示,可能出现非法组合;多个MutableStateFlow 通过 combine 拼接,字段越多嵌套越深;ViewModel方法全部暴露,调用方无法快速了解有哪些用户操作;弹窗显示逻辑散落在 Screen 中,if 嵌套难以维护。

本文将针对这些问题逐一重构,最终形成 MVI 单向数据流架构。话不多说,我们直接开始正题。

一、UiState 设计问题

1.1 现状

data class HomeUiState(
    ...
    val isShowCenterDialog: Boolean = false,
    val isShowReplaceDialog: Boolean = false,
    val isShowUpgradeDialog: Boolean = false,
    val isShowReportDialog: Boolean = false,
    val isShowCouponDialog: Boolean = false
    ...
    val title: String = "",
    val content: String = "",
    val errorCode: String = "",
)

问题分析

  1. 弹窗在业务上互斥(同时只会显示一个),但 Boolean 无法约束,可能出现多个同时为 true 的非法状态
  2. title、content、errorCode 属于同一个弹窗的信息,拆散存放降低了内聚性
  3. 新增弹窗需要加字段

1.2 重构方案

  1. 定义 DialogType 枚举,5 个 Boolean 收敛为 1 个 nullable enum(null 即不显示)
  2. 定义 DialogInfo 数据类,收拢 title、content、errorCode

我们直接看重构后的代码

// HomeViewModel.kt
data class HomeUiState(
    ...
    val dialogType: DialogType? = nullval dialogInfo: DialogInfo? = null
)

data class DialogInfo(
    val title: String = "",
    val content: String = "",
    val errorCode: String = "",
)

// Enum.kt
enum class DialogType {
    Center, Replace, Upgrade, Report, Coupon, Notice
}

小结:

  • 互斥状态用 enum 而非 Boolean
  • UiState 中相关属性抽取为独立类

二、combine嵌套及其连锁反应

2.1 现状

@HiltViewModel
class HomeViewModel @Inject constructor() : ViewModel() {
    ...
    private val load = MutableStateFlow(LoadingStatus.Loading)
    private val homeInfo = MutableStateFlow(HomeInfoResult())
    private val notification = MutableStateFlow(NotificationInfo())
    ...

    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState = _uiState.asStateFlow()

    private val _effect = MutableSharedFlow<HomeSingleEvent>()
    val effect by lazy { _effect.asSharedFlow() }

    init {
        val combineFirst = combine(
            load,
            homeInfo,
            notification,
            ...
        ) { load, ..., homeInfo, notificationInfo ->
            HomeUiState(
                loadStatus = load,
                homeInfo = homeInfo,
                notificationInfo = notificationInfo,
                ...
            )
        }

        val combineSecond = ...

        val combineThird = ...

        val combineForth = ...

        viewModelScope.launch {
            combine(combineFirst, combineSecond, combineThird, combineForth
            ) { combineFirst, combineSecond, combineThird, combineForth ->
                HomeUiState(
                    loadStatus = combineFirst.loadStatus,
                    homeInfo = combineFirst.homeInfo,
                    notificationInfo = combineFirst.notificationInfo,
                    ...
                )
            }.catch {  }
                .collect { homeUiState ->
                    _uiState.update {
                        it.copy(
                            loadStatus = homeUiState.loadStatus,
                            homeInfo = homeUiState.homeInfo,
                            notificationInfo = homeUiState.notificationInfo,
                            ...
                        )
                    }
                }
        }
    }

出现原因

  1. 为了用 x.value = y 的简洁语法,选择了 18 个独立 Flow → combine 5 参数限制 → 被迫嵌套 2 层 → 60 行纯组装代码
  2. 各 ViewModel 统一用声明 Flow → init 里 combine → 收集到 UiState的相同模式,代码风格保持一致

问题分析

  1. combine 最多接收 5 个参数,超过就要嵌套,字段越多嵌套越深
  2. 新增一个字段至少要改 5 处:声明 Flow → 内层 combine → 外层 combine → 构造 UiState → collect 中的 copy
  3. 使用了空 catch ,发生异常无法感知
  4. 重构前 7 次 Flow 赋值之间,combine 可能触发多次,UI 可能拿到中间状态(比如 load 已经是 LoadFinish 但 showCenterDialog 还是 false)。重构后一个 .update {} 一次性更新所有字段,Compose 在下一次重组时只会看到最终状态,不会出现中间状态。如processKtorFailed 里,改一个错误弹窗要改 7 个 Flow
  load.value = LoadingStatus.LoadFinish
  isRefresh.value = false
  isLoadInfo.value = false
  errorCode.value = "Refresh"
  title.value = "Refresh"
  content.value = failure.message
  showCenterDialog.value = true

这 7 次赋值之间,combine 可能触发多次,UI 可能拿到中间状态(比如 load 已经是 LoadFinish 但 showCenterDialog 还是 false)。重构后一个 .update{} 是原子的,不会出现中间状态。

2.2 重构方案

  1. 移除所有单独的 MutableStateFlow 声明
  2. 移除 init 块中的 combine 和 collect
  3. 各业务方法中直接 _uiState.update { it.copy(...) }
_uiState.update {
    it.copy(
        loadStatus = LoadingStatus.LoadFinish,
        isRefresh = false,
        isLoadInfo = false,
        dialogInfo = DialogInfo(
            errorCode = "Refresh",
            title = "Refresh",
            content = failure.message,
        ),
        dialogType = DialogType.Center
    )
}

小结

单个 UiState 除了提升可维护性,还有性能方面的收益。重构前多个 StateFlow 各自独立发射,一次网络请求成功可能触发多次重组;重构后收敛为一次 update,Compose 只需重组一次。
但需要注意,单个 UiState 意味着任意字段变化都会触发读取 uiState 的 Composable 重组。解法是将 uiState 的具体字段传给子组件,而非传递整个 uiState 对象。

  // 传具体字段,避免整个 uiState 变化触发重组
  HomeTopBar(title = uiState.title)
  // 而不是
  HomeTopBar(uiState = uiState)

三、ViewModel 方法直接暴露,缺少统一入口

3.1 现状

// ViewModel 中定义方法
fun getHomeBasicInfo(){...}

// Screen中直接访问
LaunchedEffect(Unit) {
    viewModel.getHomeBasicInfo()
}

问题分析

  1. ViewModel 方法全部 public,Screen 可以随意调用,调用方无法快速了解有哪些用户操作
  2. 方法签名各不相同,Screen 需要关心参数细节
  3. 新增操作时 Screen 和 ViewModel 之间没有约定约束

3.2 重构方案

通过定义 Intent 和 processIntent,Screen只能通过调用 processIntent 去发送Intent,去间接调用 ViewModel中的其他方法,实现单向数据流:View -> Intent → ViewModel → State/Effect → View

// 定义 HomeIntent
sealed class HomeIntent {
    // 数据加载
    data object LoadHome : HomeIntent()
    data object RefreshHome : HomeIntent()
    data object RetryLoad : HomeIntent()

    // 业务请求
    data class GetLoanInfo(val productId: Long?) : HomeIntent()
    data class ViewBillDetail(val loanId: Long) : HomeIntent()
    data class GoToRepay(val loanId: Long) : HomeIntent()
    data class GetUserRenewalInfo(val loanId: Long) : HomeIntent()
    data object GetHomeCoupon : HomeIntent()

    // 用户交互
    data class UpdateSelectProduct(val index: Int, val isUserClick: Boolean = false) : HomeIntent()
    data object IncreaseAmount : HomeIntent()
    data object DecreaseAmount : HomeIntent()
    data object ClickApply : HomeIntent()

    // 弹窗控制
    data class ShowDialog(val dialogType: DialogType) : HomeIntent()
    data object DismissDialog : HomeIntent()
}

// 定义 processIntent
fun processIntent(intent: HomeIntent) {
    when(intent) {
        is HomeIntent.LoadHome -> getHomeBasicInfo(isDownPullRefresh = false)
        is HomeIntent.RefreshHome -> getHomeBasicInfo(isDownPullRefresh = true)
        is HomeIntent.RetryLoad -> getHomeBasicInfo(isDownPullRefresh = false, isShowLoading = true)
        is HomeIntent.GetLoanInfo -> getLoanInfo(intent.productId)
        is HomeIntent.ViewBillDetail -> getBillDetailInfo(intent.loanId, isGoRepayScreen = false)
        is HomeIntent.GoToRepay -> getBillDetailInfo(intent.loanId, isGoRepayScreen = true)
        is HomeIntent.GetUserRenewalInfo -> getUserRenewalInfo(intent.loanId)
        is HomeIntent.GetHomeCoupon -> getHomeCoupon()
        is HomeIntent.UpdateSelectProduct -> updateSelectProduct(intent.index, intent.isUserClick)
        is HomeIntent.ShowDialog -> showDialog(intent.dialogType)
        is HomeIntent.DismissDialog -> dismissDialog()
        is HomeIntent.IncreaseAmount -> increaseAmount()
        is HomeIntent.DecreaseAmount -> decreaseAmount()
        is HomeIntent.ClickApply -> clickApply()
    }
}

// 修改其他 ViewModel 中的方法为私有
private fun getHomeBasicInfo() {...}
private fun getLoanInfo(productId: Long?) {}
...

四、弹窗优先级与级联逻辑

4.1 现状

@Composable
fun HomeScreen() {
    var isShowNoticeDialog by remember { mutableStateOf(true) }
    var isShowCouponDialog by remember { mutableStateOf(true) }
    ...
    
    val isUpgrade = uiState.homeInfoResult?.isUpdateApp == true
    if (isUpgrade && uiState.isShowUpgradeDialog) {
        // 显示强更弹窗
    }

    if ((isUpgrade && !uiState.isShowUpgradeDialog || !isUpgrade) &&
        uiState.notificationResult?.let { shouldShowNotice(it) } == true && isShowNoticeDialog) {
        // 显示公告弹窗
        ...
    }

    if ((isUpgrade && !uiState.isShowUpgradeDialog || !isUpgrade)
        && (uiState.notificationResult?.let { shouldShowNotice(it) } == false && isShowNoticeDialog || !isShowNoticeDialog)
        && uiState.showCouponDialog && isShowCouponDialog) {
        // 显示优惠券弹窗
        ...
    }
}

问题分析 弹窗显示逻辑是 强更 -> 公告 -> 优惠券,状态从 ViewModel(多个 Boolean)和 Screen 两个方向结合用来判断逻辑,难以预测哪个条件组合会出问题,且可读性和可维护性都比较差。

4.2 重构方案

将弹窗的优先级判断和级联关闭逻辑从 Screen 移到 ViewModel,Screen 只负责根据 dialogType 显示对应弹窗。

  1. ViewModel 控制弹窗优先级

网络请求成功后调用 determineDialog(),按优先级决定显示哪个弹窗:

  private fun determineDialog() {
      val state = _uiState.value
      val dialog = when {
          state.homeInfoResult?.isUpdateApp == true -> DialogType.Upgrade
          shouldShowNotice(state.notificationResult) -> DialogType.Notice
          state.hasCoupon -> DialogType.Coupon
          else -> state.dialogType
      }
      _uiState.update { it.copy(dialogType = dialog) }
  }
  1. ViewModel 控制弹窗级联关闭

关闭当前弹窗后,自动按优先级显示下一个:

  private fun dismissDialog() {
      val state = _uiState.value
      val current = state.dialogType
      val nextDialog = when (current) {
          DialogType.Upgrade -> {
              when {
                  shouldShowNotice(state.notificationResult) -> DialogType.Notice
                  state.hasCoupon -> DialogType.Coupon
                  else -> null
              }
          }
          DialogType.Notice -> {
              if (state.hasCoupon) DialogType.Coupon else null
          }
          else -> null
      }
      _uiState.update { it.copy(dialogType = nextDialog) }
  }
  1. Screen 只负责显示
  when (uiState.dialogType) {
      DialogType.Upgrade -> UpgradeDialog(
          cancel = { viewModel.processIntent(HomeIntent.DismissDialog) },
          confirm = { /* 跳转应用商店 */ }
      )
      DialogType.Notice -> ScreenSingleButtonDialog(...) {
          viewModel.processIntent(HomeIntent.DismissDialog)
      }
      DialogType.Coupon -> HomeCouponDialog(
          onDismissRequest = { viewModel.processIntent(HomeIntent.DismissDialog) },
          goCouponPage = { viewModel.processIntent(HomeIntent.GetHomeCoupon) }
      )
      else -> {}
  }

小结:

  • Screen 中弹窗显示从 if 嵌套判断简化为 when 匹配,职责单一
  • 优先级和级联逻辑收敛到 ViewModel,可测试、可维护
  • 新增弹窗只需:枚举加一项 + determineDialog/dismissDialog 中加一行 + Screen 加一个分支
  • 当前弹窗数量较少,直接 when 判断即可。如果后续弹窗增多,可以将判断条件抽象为优先级列表,新增弹窗只需加一行,dismissDialog 的级联逻辑也自动生效,无需为每个弹窗写分支。

五、MVI 模式

5.1 单向数据流概述

经过前四节的重构,首页的 MVI 架构已经形成清晰的单向数据流:

View → Intent → ViewModel → State/Effect → View

  • View 通过 processIntent 发送 Intent,唯一出口
  • ViewModel 根据 Intent 处理业务,更新 State(UiState)或发射 Effect(一次性事件)
  • View 订阅 State 驱动 UI,订阅 Effect 处理导航/Toast

数据流方向单一,每次状态变化都能沿着这条单向数据流找到原因。

5.2 Effect:一次性事件的处理

effect(SharedFlow):一次性事件,消费后消失,不会因 Screen 重建而重放

// HomeViewModel
sealed class HomeSingleEvent {
    data object GetLoanInfoSuccess : HomeSingleEvent()
    ...
}

private val _effect = MutableSharedFlow<HomeSingleEvent>()
val effect by lazy { _effect.asSharedFlow() }

fun processIntent(intent: HomeIntent) {
    when(intent) {
        ...
        is HomeIntent.GetLoanInfo -> getLoanInfo(intent.productId)
    }
}


    private fun getLoanInfo(productId: Long?) {
        if (productId == null) {
            // 错误提示
            return
        }
        _uiState.update {it.copy(isLoadInfo = true) }
        viewModelScope.launch {
            val result = XxxService.getLoanInfo(
                LoanInfoReq(
                    loanAmount = _uiState.value.amount.toDouble(),
                    productId = productId
                )
            )
            _uiState.update {it.copy(isLoadInfo = false) }
            when (result) {
                is KtorResult.Success -> {
                    _effect.emit(HomeSingleEvent.GetLoanInfoSuccess)
                }

                is KtorResult.Failure -> {
                    if (result.code.isEmpty()) {
                        _uiState.update {
                            it.copy(isRefresh = false) }
                        _effect.emit(HomeSingleEvent.ToastEvent(result.message))
                    } else {
                        processKtorFailed(result, false)
                    }
                }
            }
        }
    }
// HomeScreen 中监听事件
viewModel.effect.LaunchInLaunchedEffect {
    when (it) {
        is HomeSingleEvent.GetLoanInfoSuccess -> {
            // 跳转对应页面
        }
			
        ...
    }
}
...

// 点击按钮发送 HomeIntent.GetLoanInfo
viewModel.processIntent(HomeIntent.GetLoanInfo(productId = productId))

// FlowExt: 封装事件监听
@Composable
fun <T> Flow<T>.LaunchInLaunchedEffect(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend (T) -> Unit
) {
    LaunchedEffect(Unit) {
        this@LaunchInLaunchedEffect.flowOn(context).collect(block)
    }
}

整体流程

  1. 点击按钮发送Intent
  2. processIntent根据Intent调用不同方法
  3. 具体方法中根据处理结果发送不同的Effect
  4. 页面监听Effect,弹出提示或跳转页面等操作

image.png

六、单元测试

引言:MVI 的可测试性体现在——processIntent 是单一入口,UiState 是唯一输出,测试只需"给定初始状态 → 发送 Intent → 断言最终状态"。

6.1 测试环境配置

6.1.1 添加的依赖(coroutines-test、mockk、turbine)

// lib.versions.toml
[versions]
...
mockk = "1.14.9"
coroutinesTest = "1.10.2"
turbine = "1.2.1"

[libraries]
...
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
// build.gradle.kts(:app)
...
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)

6.1.2 Dispatchers.setMain(UnconfinedTestDispatcher())

class HomeViewModelTest {
    private val testDispatcher = UnconfinedTestDispatcher()
    private lateinit var viewModel: HomeViewModel

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        viewModel = HomeViewModel()
    }
    ...
}

6.1.3 setupState 辅助方法(反射设置初始状态)

/** 通过反射直接设置 UiState,用于构造测试初始状态 */
private fun setupState(transform: (HomeUiState) -> HomeUiState) {
    val field = HomeViewModel::class.java.getDeclaredField("_uiState")
    field.isAccessible = true
    @Suppress("UNCHECKED_CAST")
    val flow = field.get(viewModel) as MutableStateFlow<HomeUiState>
    flow.update(transform)
}

6.2 弹窗级联测试

// ==================== 弹窗状态管理 ====================

@Test
fun `ShowDialog sets dialogType to specified type`() {
    viewModel.processIntent(HomeIntent.ShowDialog(DialogType.Center))
    assertEquals(DialogType.Center, viewModel.uiState.value.dialogType)

    viewModel.processIntent(HomeIntent.ShowDialog(DialogType.Upgrade))
    assertEquals(DialogType.Upgrade, viewModel.uiState.value.dialogType)
}

// --- dismissDialog 级联测试 ---

@Test
fun `DismissDialog from Upgrade cascades to Coupon when no Notice`() {
    setupState {
        it.copy(
            dialogType = DialogType.Upgrade,
            notificationResult = null,
            hasCoupon = true
        )
    }

    viewModel.processIntent(HomeIntent.DismissDialog)

    assertEquals(DialogType.Coupon, viewModel.uiState.value.dialogType)
}

@Test
fun `DismissDialog from Upgrade cascades to null when no Notice and no Coupon`() {
    setupState {
        it.copy(
            dialogType = DialogType.Upgrade,
            notificationResult = null,
            hasCoupon = false
        )
    }

    viewModel.processIntent(HomeIntent.DismissDialog)

    assertNull(viewModel.uiState.value.dialogType)
}

...

6.3 金额逻辑测试

// ==================== 金额增减 ====================
@Test
fun `IncreaseAmount increases by product unit`() {
    val products = testProducts()
    setupState {
        it.copy(
            homeInfoResult = testHomeInfoResult(tucker = products),
            selectProduct = 1,
            amount = 5000
        )
    }

    viewModel.processIntent(HomeIntent.IncreaseAmount)

    assertEquals(6000, viewModel.uiState.value.amount) // 5000 + 1000 (product[1].sunglass)
}

@Test
fun `IncreaseAmount is capped at product max`() {
    val products = testProducts()
    setupState {
        it.copy(
            homeInfoResult = testHomeInfoResult(tucker = products),
            selectProduct = 0,
            amount = 4800
        )
    }

    viewModel.processIntent(HomeIntent.IncreaseAmount)

    assertEquals(5000, viewModel.uiState.value.amount) // min(4800+500, 5000)
}

七、前后对比

重构前重构后
UiState 字段数~15+(含 5 个弹窗 Boolean)11(弹窗收敛为 1 个 enum)
init 块多层 combine + collect
ViewModel public 方法11 个1 个 processIntent()
新增弹窗改动处5+ 处2 处(enum + showDialog)
弹窗非法状态不互斥enum 天然互斥
代码行数~660~618

重构后的代码的可读性和可维护性都提升较多

八、总结

8.1 UiState 中相关属性抽取为独立类

第1节的 DialogInfo 就是一个例子——title、content、errorCode 各自独立时,赋值容易遗漏其中一个;抽取为独立类后,要么整体为 null(不显示), 要么整体赋值(显示),不会出现有 title 没有 content 的中间态。

8.2 互斥状态用 enum 而非 Boolean

首页同一时间只会显示一个弹窗,因此它们是互斥的,应使用枚举。N 个 Boolean 有 2^N 种组合,其中大部分是非法的。用 nullable enum 只有 N+1 种(N 种有效 + null),新增一种状态只需枚举加一项,不需要加字段。

8.3 Intent 只传意图,不传已有数据

第3节中将 GetBillDetailInfo(loanId, isGoRepayScreen) 拆成了ViewBillDetail 和 GoToRepay 两个 Intent。原因:isGoRepayScreen不是用户的意图,而是 ViewModel 内部的导航路由判断。Intent 应该只描述用户做了什么,而非代码需要怎么路由

8.4 状态用 StateFlow,事件用 SharedFlow

首页中 ViewModel 对外暴露了两个 Flow:

  • uiState(StateFlow):持有最新 UI 状态,Screen 重建后仍能拿到当前值
  • effect(SharedFlow):一次性事件,消费后消失,不会因 Screen 重建而重放

如果用 StateFlow 管理事件,Screen 重建时(如旋转屏幕)会重新拿到上一个事件,导致重复导航。SharedFlow 的 replay=0 保证了事件只被消费一次。

可测试性:纯状态逻辑无需 Mock,给定初始状态和 Intent,即可断言状态变化

九、参考资料

  1. StateFlow 和 SharedFlow  |  Kotlin  |  Android Developers