Jetpack Compose 状态保存:从重组、旋转到进程重建
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 保存看似省事,又可能在异常终止时丢失进度。更稳妥的策略是按数据价值分级:
- 最高分、完成记录等不可重建结果,在业务动作成功时立即提交。
- 正在进行的局面,在有效操作后写入,可用协程串行化或短暂防抖合并连续操作。
- 动画进度、按压状态等纯视觉细节不写盘。
- 写入失败要成为可观察状态,不能只在日志里吞掉异常。
用恢复测试检验边界
只测试“点击按钮后文字变了”远远不够。至少覆盖以下场景:
- 重组后,临时界面状态是否按预期保留;
- 旋转或尺寸变化后,输入和当前局面是否恢复;
- 开发者选项启用“不保留活动”后,返回页面是否一致;
- 系统回收进程后,能否通过小型恢复参数重新加载数据;
- 强制停止并重新打开后,用户设置和已完成记录是否仍在;
- schema 升级后,旧版本真实数据能否迁移。
一条可执行的迁移路线
已有项目不必一次重写。可以从最容易验证的边界开始:
- 把屏幕渲染所需数据合并成不可变
UiState。 - 让 Composable 只接收状态和事件回调。
- 把屏幕业务状态移入
ViewModel。 - 只把恢复所需的 ID、筛选条件等小值放入 saved state。
- 将长期设置从
SharedPreferences迁移到 DataStore。 - 将可查询的历史与进度交给 Room,并为迁移加入测试。
判断是否完成的标准不是使用了多少 Jetpack 组件,而是每份状态只有一个权威所有者。remember、ViewModel、saved state、DataStore 和 Room 分别跨越不同的生命周期边界。把边界画清楚,Compose 应用才能在重组、旋转、进程回收和离线重启之后仍然表现得像同一个应用。