前言
当前项目是一个海外信贷 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 = "",
)
问题分析
- 弹窗在业务上互斥(同时只会显示一个),但 Boolean 无法约束,可能出现多个同时为 true 的非法状态
- title、content、errorCode 属于同一个弹窗的信息,拆散存放降低了内聚性
- 新增弹窗需要加字段
1.2 重构方案
- 定义 DialogType 枚举,5 个 Boolean 收敛为 1 个 nullable enum(null 即不显示)
- 定义 DialogInfo 数据类,收拢 title、content、errorCode
我们直接看重构后的代码
// HomeViewModel.kt
data class HomeUiState(
...
val dialogType: DialogType? = null,
val 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,
...
)
}
}
}
}
出现原因
- 为了用 x.value = y 的简洁语法,选择了 18 个独立 Flow → combine 5 参数限制 → 被迫嵌套 2 层 → 60 行纯组装代码
- 各 ViewModel 统一用声明 Flow → init 里 combine → 收集到 UiState的相同模式,代码风格保持一致
问题分析
- combine 最多接收 5 个参数,超过就要嵌套,字段越多嵌套越深
- 新增一个字段至少要改 5 处:声明 Flow → 内层 combine → 外层 combine → 构造 UiState → collect 中的 copy
- 使用了空 catch ,发生异常无法感知
- 重构前 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 重构方案
- 移除所有单独的 MutableStateFlow 声明
- 移除 init 块中的 combine 和 collect
- 各业务方法中直接 _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()
}
问题分析
- ViewModel 方法全部 public,Screen 可以随意调用,调用方无法快速了解有哪些用户操作
- 方法签名各不相同,Screen 需要关心参数细节
- 新增操作时 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 显示对应弹窗。
- 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) }
}
- 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) }
}
- 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)
}
}
整体流程
- 点击按钮发送Intent
- processIntent根据Intent调用不同方法
- 具体方法中根据处理结果发送不同的Effect
- 页面监听Effect,弹出提示或跳转页面等操作
六、单元测试
引言: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,即可断言状态变化