Compose 应用里常见的状态问题,往往源于把寿命不同的数据装进同一容器。屏幕旋转后棋盘重置、返回列表时筛选消失、进程重建后恢复出半局旧游戏,都需要先回答:这份状态应该活多久?

以一个完全离线的益智游戏为例,界面上同时存在当前选中的卡片、正在进行的局面、累计最高分和历史对局。它们都叫“状态”,但生命周期完全不同。

状态 合适的保存位置 需要跨越的边界
按钮按下、展开面板 remember 重组
输入框、滚动位置、当前选项 rememberSaveable Activity 重建、系统回收进程
当前屏幕的业务状态 ViewModel 重组、配置变化
重建业务状态所需的小参数 SavedStateHandle 系统回收进程
主题、难度、音效开关 DataStore 应用重启
历史记录、题库、统计 Room 应用重启、查询与迁移

这张表不是“框架选型表”,而是状态所有权表。先确定谁拥有数据,再决定用什么 API。

remember 只负责当前组合

remember 会让值在重组之间保留,但 Composable 离开组合后,值就可能消失。它适合纯界面细节,例如帮助卡片是否展开:

@Composable
fun RulesCard() {
    var expanded by remember { mutableStateOf(false) }

    RulesContent(
        expanded = expanded,
        onToggle = { expanded = !expanded },
    )
}

不要把最高分、游戏进度或用户设置放进 remember。这些数据不属于某次组合,界面也不应该成为它们的唯一事实来源。

rememberSaveable 保存的是恢复线索

rememberSaveable 除了跨越重组,还会借助 saved instance state 应对 Activity 重建和系统发起的进程回收。它适合能装入 Bundle 的少量界面状态:

var selectedTab by rememberSaveable { mutableIntStateOf(0) }

“少量”是关键。不要把完整题库、大图、长列表或整个仓库对象序列化进去。保存一个题目 ID、页签编号或用户尚未提交的短输入即可;大型数据应通过这个 ID 从数据层重新取得。

如果状态参与业务逻辑并由 ViewModel 管理,则把恢复参数放进 SavedStateHandle 更自然:

class GameViewModel(
    private val repository: GameRepository,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
    private val puzzleId = savedStateHandle.getStateFlow("puzzle_id", "daily")

    val uiState: StateFlow<GameUiState> = puzzleId
        .flatMapLatest(repository::observeGame)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = GameUiState.Loading,
        )

    fun openPuzzle(id: String) {
        savedStateHandle["puzzle_id"] = id
    }
}

这里保存的不是完整游戏,而是重新找到游戏所需的 puzzle_id。这是非常实用的判断标准:saved state 负责“如何恢复”,数据库负责“恢复什么”。

ViewModel 是屏幕状态生产者,不是数据库

Android 官方架构建议让 ViewModel 暴露界面状态,并通过方法接收用户动作。Compose 只消费不可变状态并上报事件,数据沿一个方向流动:

data class PlayingState(
    val cards: List<Int>,
    val selected: Set<Int>,
    val score: Int,
    val isChecking: Boolean,
)

@Composable
fun GameRoute(viewModel: GameViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    GameScreen(
        state = state,
        onCardClick = viewModel::selectCard,
        onSubmit = viewModel::submit,
    )
}

这种结构让 GameScreen 不需要知道数据库、Context 或协程作用域。预览时传入固定状态,测试时直接调用 ViewModel 的动作,再断言新状态即可。

ViewModel 仍然只是一种屏幕级状态持有者。进程真正结束后,它也会消失。需要跨应用重启保存的数据,必须进入数据层。

DataStore 和 Room 不应互相替代

DataStore 适合少量配置。难度、主题、是否震动等键值设置可以放进 Preferences DataStore;需要强类型 schema 时可使用 Proto DataStore。官方目前也建议仍在使用 SharedPreferences 的项目考虑迁移到 DataStore。

Room 适合结构化、可查询的数据,例如历史对局、每日题目、成绩统计和收藏。它提供 SQL 查询的编译期验证,并有明确的 schema 迁移路径。当你开始需要“最近 30 天最高分”“按难度筛选历史”或表间关系时,继续往 DataStore 塞序列化 JSON 通常是在制造下一次迁移事故。

数据层可以为界面提供统一入口:

interface GameRepository {
    fun observeGame(id: String): Flow<GameUiState>
    suspend fun saveMove(gameId: String, move: Move)
    suspend fun finishGame(gameId: String, score: Int)
}

UI 不直接碰 DAO 或 DataStore。仓库决定从哪里读取、何时写盘,以及如何把持久化模型转换成界面需要的状态。即使应用永远不上网,这层边界仍然有价值:离线优先不是“没有网络代码”,而是本地数据拥有清晰的单一事实来源。

持久化时机比持久化工具更容易出错

每一步都同步写盘最安全,却可能产生不必要的 I/O;只在 onStop 保存看似省事,又可能在异常终止时丢失进度。更稳妥的策略是按数据价值分级:

  1. 最高分、完成记录等不可重建结果,在业务动作成功时立即提交。
  2. 正在进行的局面,在有效操作后写入,可用协程串行化或短暂防抖合并连续操作。
  3. 动画进度、按压状态等纯视觉细节不写盘。
  4. 写入失败要成为可观察状态,不能只在日志里吞掉异常。

用恢复测试检验边界

只测试“点击按钮后文字变了”远远不够。至少覆盖以下场景:

  • 重组后,临时界面状态是否按预期保留;
  • 旋转或尺寸变化后,输入和当前局面是否恢复;
  • 开发者选项启用“不保留活动”后,返回页面是否一致;
  • 系统回收进程后,能否通过小型恢复参数重新加载数据;
  • 强制停止并重新打开后,用户设置和已完成记录是否仍在;
  • schema 升级后,旧版本真实数据能否迁移。

一条可执行的迁移路线

已有项目不必一次重写。可以从最容易验证的边界开始:

  1. 把屏幕渲染所需数据合并成不可变 UiState
  2. 让 Composable 只接收状态和事件回调。
  3. 把屏幕业务状态移入 ViewModel
  4. 只把恢复所需的 ID、筛选条件等小值放入 saved state。
  5. 将长期设置从 SharedPreferences 迁移到 DataStore。
  6. 将可查询的历史与进度交给 Room,并为迁移加入测试。

判断是否完成的标准不是使用了多少 Jetpack 组件,而是每份状态只有一个权威所有者。rememberViewModel、saved state、DataStore 和 Room 分别跨越不同的生命周期边界。把边界画清楚,Compose 应用才能在重组、旋转、进程回收和离线重启之后仍然表现得像同一个应用。