状态放哪,取决于谁要改、谁要活得更久
状态提升不是「全部塞进 ViewModel」,而是把状态提到所有读写者最近的共同祖先。
聊天输入框草稿既要旋转后保留,也要由「发送」按钮清空,但无需跨设备重启。最合适的所有者通常是:
- 输入框内部的
remember - 页面的
rememberSaveable或 ViewModel 的SavedStateHandle - Repository 的远端数据库
- Application 单例
两个问题决定位置
第一,谁需要读取或修改它?把状态提到这些参与者最近的共同祖先。第二,它需要活过什么?只活过重组用 remember;还要活过 Activity 重建用 rememberSaveable;涉及页面业务与数据层,用 ViewModel;跨进程不能丢,用保存状态或持久层。
状态提升后的典型 Composable 接口是「值加事件」:
@Composable
fun MessageComposer(
text: String,
canSend: Boolean,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier
) { /* 只渲染状态、转发意图 */ }
这种函数既能由 ViewModel 驱动,也能在 Preview 和测试里直接传假状态。它不知道 Repository、NavController 或 CoroutineScope,因此重用边界清楚。
remember 的三个常见误区
误区一:remember 让值永久存在。它只跟当前 Composition 位置共存。误区二:rememberSaveable 能保存任何东西。它依赖可保存格式或自定义 Saver,适合短小 UI 状态,不适合大列表和 Bitmap。误区三:remember(key) 会观察 key 内部变化。它只在 key 的相等性变化时重新计算。
派生值若计算便宜,直接算比存两份状态安全。把 canSend 同时存为 MutableState,草稿变化时就可能忘记同步。让它从 draft.isNotBlank() && !isSending 推导,真相只有一份。
UI 状态要完整,但不要复制数据库
一个页面常用 sealed UiState 表达加载、内容、错误,或用一个 data class 表达可同时出现的维度。选择标准仍是合法组合:加载时能不能同时显示旧内容?错误是替代整页,还是只给一条消息加失败标记?
ViewModel 将多个数据源转换成 UI 需要的稳定快照,但不应把整个 Room 实体图复制进 MutableState 再手工同步。Repository 的 Flow 是业务事实,ViewModel 负责派生和界面语义。
Modifier 通常作为第一个可选参数并提供默认值;事件 lambda 不返回业务结果,而是报告意图。这样调用者控制布局,数据仍单向流动。把有状态输入框拆成无状态核心与页面所有者。旋转后草稿保留,点击发送后由所有者清空:
输入 hello → draft=hello 旋转页面 → draft=hello 点击发送 → draft=""
@Composable
fun ChatRoute(viewModel: ChatViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ChatScreen(
state = state,
onDraftChange = viewModel::onDraftChange,
onSend = viewModel::send
)
}
@Composable
fun ChatScreen(
state: ChatUiState,
onDraftChange: (String) -> Unit,
onSend: () -> Unit
) {
MessageComposer(
text = state.draft,
canSend = state.draft.isNotBlank() && !state.isSending,
onTextChange = onDraftChange,
onSend = onSend
)
}在 Android Studio 中给 ChatViewModel 用 SavedStateHandle.getStateFlow 保存 draft。写一个 Preview 直接传 ChatUiState,确认无 ViewModel 也能渲染整页。
搜索框内容可能只需页面内记住,消息草稿可能需要进程重建后恢复,待发消息必须先进入数据库。它们看起来都是 Text,却有三种可靠性要求。按数据类型选存储,会把本来不同的寿命揉成一个错误答案。
「所有状态都提升到 ViewModel 才是单向数据流。」按钮按压、展开动画、LazyListState 等纯 UI 状态放进去,会让 ViewModel 绑上 UI 实现并扩大更新范围。
业务状态由页面状态生产者拥有,纯 UI 状态留在 UI;只有多个组件真的共享或寿命要求更长时才向上提升。
答案是第 2 项。输入框和发送按钮共同读写草稿,因此状态要提到页面级;又要跨配置变更,所以使用 rememberSaveable 或 SavedStateHandle。是否进 ViewModel 取决于它是否参与业务规则与页面状态生产。
这一章的一句话
状态的位置由共同读写者决定,状态的容器由所需寿命决定。
下一章处理状态之外的世界:打开相机、收集 Flow、注册监听器都不是描述,而是副作用。少一个 key 会重复启动,少一个清理会一直监听。