卷 III · CH 10 · 深度 10/18

状态放哪,取决于谁要改、谁要活得更久

状态提升不是「全部塞进 ViewModel」,而是把状态提到所有读写者最近的共同祖先。

STATE HOISTINGREMEMBERVIEWMODEL
▷ 先答一下

聊天输入框草稿既要旋转后保留,也要由「发送」按钮清空,但无需跨设备重启。最合适的所有者通常是:

  1. 输入框内部的 remember
  2. 页面的 rememberSaveable 或 ViewModel 的 SavedStateHandle
  3. Repository 的远端数据库
  4. 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,因此重用边界清楚。

可以带走的判断:可测试性往往不是多写 mock,而是 UI 函数只接收值与意图。

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 中给 ChatViewModelSavedStateHandle.getStateFlow 保存 draft。写一个 Preview 直接传 ChatUiState,确认无 ViewModel 也能渲染整页。

▸ 在现实里

搜索框内容可能只需页面内记住,消息草稿可能需要进程重建后恢复,待发消息必须先进入数据库。它们看起来都是 Text,却有三种可靠性要求。按数据类型选存储,会把本来不同的寿命揉成一个错误答案。

✗ 这个直觉是错的

「所有状态都提升到 ViewModel 才是单向数据流。」按钮按压、展开动画、LazyListState 等纯 UI 状态放进去,会让 ViewModel 绑上 UI 实现并扩大更新范围。

业务状态由页面状态生产者拥有,纯 UI 状态留在 UI;只有多个组件真的共享或寿命要求更长时才向上提升。

◇ 面试收口

答案是第 2 项。输入框和发送按钮共同读写草稿,因此状态要提到页面级;又要跨配置变更,所以使用 rememberSaveableSavedStateHandle。是否进 ViewModel 取决于它是否参与业务规则与页面状态生产。

这一章的一句话

状态的位置由共同读写者决定,状态的容器由所需寿命决定。

下一章处理状态之外的世界:打开相机、收集 Flow、注册监听器都不是描述,而是副作用。少一个 key 会重复启动,少一个清理会一直监听。