一、先说结论:不矛盾
MVI 说的 "View 不持有状态",指的是不持有业务状态 ,而不是不持有任何状态。
Compose 里的 remember 变量分两种:
| 类型 | 例子 | 是否违反 MVI |
|---|---|---|
| 业务状态 | 列表数据、加载中、用户信息 | ✅ 违反,必须放 ViewModel |
| UI 状态 | 输入框文字、弹窗开关、动画进度 | ❌ 不违反,可以放 View 层 |
关键区别:这个状态是不是"业务数据"?
二、什么是业务状态 vs UI 状态
业务状态(必须放 ViewModel)
- 影响业务逻辑的状态
- 需要持久化 或跨页面共享
- 例子:
- 列表数据
- 用户登录状态
- 加载中/错误
- 播放状态
- 分页页码
UI 状态(可以放 View 层)
- 只影响当前 UI 的表现
- 不需要持久化,页面销毁就丢
- 例子:
- 输入框的文字
- 弹窗是否显示
- Tab 的当前索引
- 动画进度
- 是否展开/折叠
- 滚动位置
- 焦点状态
判断标准 :旋转屏幕后,这个状态需要恢复吗?
- 需要恢复 → 业务状态 → ViewModel
- 不需要恢复 → UI 状态 → View 层
三、Compose 里 remember 的两种典型用法
1. remember ------ 普通 UI 状态
kotlin
@Composable
fun SearchScreen() {
// ✅ 合理:输入框文字,属于 UI 状态
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it }
)
}
为什么不放 ViewModel?
- 输入框文字是"临时输入",不需要持久化
- 用户切走页面,输入框清空是合理的
- 如果放 ViewModel,反而会"意外保留"用户不想保留的内容
但注意 :如果输入框内容需要影响业务 (如搜索),那就需要把最终提交的搜索词传给 ViewModel:
kotlin
TextField(
value = query,
onValueChange = { query = it },
keyboardActions = KeyboardActions(
onSearch = {
// 把最终搜索词传给 ViewModel
viewModel.sendIntent(SearchIntent.Search(query))
}
)
)
query 是 UI 状态,搜索词 是业务状态。 两者分开。
2. rememberSaveable ------ 需要跨配置变更保留的 UI 状态
kotlin
@Composable
fun SearchScreen() {
// ✅ 合理:旋转屏幕后,输入框内容保留
var query by rememberSaveable { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
}
rememberSaveable 解决的是"UI 状态在配置变更时丢失"的问题,不是"业务状态"。
它和 ViewModel 的区别:
| 维度 | rememberSaveable |
ViewModel |
|---|---|---|
| 存储位置 | Bundle(内存+序列化) | 内存 |
| 生命周期 | Composable 销毁时丢 | ViewModel 销毁时丢 |
| 用途 | 简单 UI 状态 | 业务状态 |
| 跨页面 | ❌ | ✅ |
四、为什么 Compose 允许 View 层有状态?
因为 Compose 的设计哲学是"状态提升"(State Hoisting):
状态应该放在"需要它的最高层",而不是无脑全放 ViewModel。
状态提升的三个层次
| 状态 | 放在哪 | 原因 |
|---|---|---|
| 只被一个 Composable 用 | 那个 Composable 内部 | 封装性好 |
| 被多个兄弟 Composable 共享 | 最近的父 Composable | 避免参数传递过深 |
| 跨页面、跨生命周期 | ViewModel | 需要持久化 |
不是所有状态都该进 ViewModel,否则 ViewModel 会变成"状态垃圾桶"。
五、MVI 里 View 到底"不能做"什么?
MVI 说的 "View 不持有业务状态",具体是指:
| ❌ 不能做 | ✅ 可以做 |
|---|---|
| 自己存列表数据 | 存输入框文字 |
| 自己改加载状态 | 存弹窗开关 |
| 自己判断业务逻辑 | 存动画进度 |
| 直接改 ViewModel 的业务状态 | 存滚动位置 |
| 自己发网络请求 | 通过 sendIntent() 通知 ViewModel |
核心原则 :View 不能"直接修改业务状态",但可以"持有纯 UI 状态"。
六、用一个完整例子对比
场景:搜索页面
kotlin
@Composable
fun SearchScreen(viewModel: SearchViewModel = koinViewModel()) {
// ===== 业务状态:来自 ViewModel =====
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// ===== UI 状态:View 层自己持有 =====
// 1. 输入框文字(临时输入)
var query by rememberSaveable { mutableStateOf("") }
// 2. 弹窗开关(纯 UI)
var showFilterDialog by remember { mutableStateOf(false) }
// 3. 滚动位置(纯 UI)
val scrollState = rememberLazyListState()
// ===== 消费 Effect:一次性事件 =====
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is SearchUiEffect.ShowToast -> toast(effect.msg)
is SearchUiEffect.Navigate -> navigate(effect.route)
}
}
}
Column {
// 输入框:UI 状态驱动
TextField(
value = query,
onValueChange = { query = it },
keyboardActions = KeyboardActions(
onSearch = {
// 把业务动作交给 ViewModel
viewModel.sendIntent(SearchIntent.Search(query))
}
)
)
// 列表:业务状态驱动
when {
uiState.isLoading -> Loading()
uiState.error != null -> ErrorView(uiState.error)
else -> LazyColumn(state = scrollState) {
items(uiState.data) { item ->
ItemView(item, onClick = {
viewModel.sendIntent(SearchIntent.Click(item.id))
})
}
}
}
}
// 弹窗:UI 状态驱动
if (showFilterDialog) {
FilterDialog(onDismiss = { showFilterDialog = false })
}
}
职责划分:
| 状态 | 归属 | 理由 |
|---|---|---|
uiState.data |
ViewModel | 业务数据 |
uiState.isLoading |
ViewModel | 业务状态 |
query |
View(rememberSaveable) |
临时输入 |
showFilterDialog |
View(remember) |
纯 UI |
scrollState |
View(remember) |
纯 UI |
七、为什么有人说"Compose 里 remember 是反模式"?
这是误读。完整说法是:
"用
remember存业务状态"是反模式,不是"用remember"是反模式。
kotlin
// ❌ 反模式:用 remember 存业务数据
@Composable
fun Bad() {
var userList by remember { mutableStateOf<List<User>>(emptyList()) }
LaunchedEffect(Unit) {
userList = api.fetchUsers() // View 层发网络请求,存业务数据
}
}
// ✅ 正确:业务数据放 ViewModel
@Composable
fun Good(viewModel: UserViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// uiState.userList 来自 ViewModel
}
问题不在 remember 本身,而在用它存了什么。
八、MVI 的准确表述
MVI 的准确表述应该是:
View 不持有"业务状态",业务状态由 ViewModel 统一管理,通过 State 单向流动给 View。
而不是:
View 不能持有任何状态。
"UI 状态" 和 "业务状态" 是两回事。
九、总结
| 问题 | 答案 |
|---|---|
remember 变量违反 MVI 吗? |
存业务状态 违反,存 UI 状态不违反 |
| 什么状态可以放 View 层? | 输入框文字、弹窗开关、滚动位置、动画进度 |
| 什么状态必须放 ViewModel? | 列表数据、加载中、错误、用户信息 |
| 判断标准是什么? | 旋转屏幕后需要恢复吗?需要 → ViewModel,不需要 → View |
| 为什么 Compose 允许 View 有状态? | "状态提升"原则:状态放在需要它的最高层,而不是全塞 ViewModel |
| MVI 的真正约束? | View 不能直接改业务状态,但可以持有纯 UI 状态 |
一句话总结:
MVI 约束的是"业务状态",不是"所有状态"。remember 存 UI 状态完全合理,存业务状态才是反模式。