4. SavedStateHandle 与状态持久化

大家都知道,Compose 是响应式的——状态一变,UI 自动更新。但有个老问题始终绕不开:屏幕旋转、进程重建甚至用户切后台被杀掉,状态怎么恢复?我早期做传统 View 系统时被这个问题坑过好多次,后来在 Compose 里第一次遇到类似的场景,下意识就想去找 saveInstanceState。嗯,Android 官方其实已经给我们提供了更优雅的方案——SavedStateHandle。

核心一句话:SavedStateHandle 是 ViewModel 层用来跨进程/跨配置保存状态的「保险箱」,做 Mock 回放的时候尤其好用。

4.1 为什么需要 SavedStateHandle?

说白了,ViewModel 本身只有在对应的 Activity/Fragment 被销毁后才跟着销毁。但如果是进程被系统杀死,或者屏幕旋转这类配置变更,ViewModel 虽然还在(因为默认开启了 onRetainNonConfigurationInstance),可它里面引用的对象内存会被清空——尤其是那些没有持久化的数据。我有个项目里用户填了一整个表单,旋转屏幕后所有输入全没了,产品经理当场就炸了。

官方的解决方案就是:SavedStateHandle 配合 SavedStateRegistry,自动把你的数据存进 Bundle,恢复时再取出来。对开发者来说,你只需要声明好哪些数据要保存,剩下的事情框架帮你搞定。

4.2 SavedStateHandle 怎么用?

使用超简单。在你的 ViewModel 的构造函数里接收一个 SavedStateHandle 参数,然后像操作 Map 一样存取值。Type-safe 版本还可以用 getStateFlow() 或 getLiveData(),这样 Compose 可以直接观察它。

class FormViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    val userName: StateFlow<String> = savedStateHandle
        .getStateFlow("userName", "")

    val age: MutableStateFlow<Int> = savedStateHandle
        .getStateFlow("age", 0)

    fun updateName(name: String) {
        savedStateHandle["userName"] = name
    }

    fun updateAge(age: Int) {
        savedStateHandle["age"] = age
    }
}

注意我用的是 getStateFlow,这样在 Composable 里直接 collectAsState() 就能拿到最新的值。你甚至可以把它当成一个持久化的 MutableState 来用——但底层它会自动序列化到 Bundle,进程杀掉也不怕。

我习惯把那些「用户还没提交但需要跨状态保留」的数据用 SavedStateHandle 来存。比如搜索关键词、草稿文本、选中的 tab 索引。省心。

4.3 跟 Compose 的 rememberSaveable 有啥区别?

这个问题我猜很多人会问。我简单对比一下:

特征rememberSaveableSavedStateHandle
作用范围单个 Composable / 屏幕局部整个 ViewModel(可跨多个 Composable)
生命周期跟随 Composable 所在的 SavedStateRegistry跟随 ViewModel(更持久,可跨 Activity 重建)
数据共享只能在同一个 Composable 树内可以被多个 Fragment / Screen 共享
序列化能力只能存 Bundle 支持的类型同上,但可通过自定义 Parcelable 扩展
Mock 回放场景需手动保存到外部天然支持,可直接注入测试数据

我自己定的原则是:如果状态只在单个屏幕内用,且不需要跨 Fragment 共享 → 用 rememberSaveable;如果状态是 ViewModel 的业务数据,或者要用来做 Mock 回放 → 用 SavedStateHandle。

4.4 SavedStateHandle 在 Mock 回放中的妙用

回到我们的课程主题。Mock 回放的核心是把录制时的状态快照存下来,然后在新的一次运行中「喂」给 UI。如果没有持久化,你录好的用例一旋转屏幕就没了,那还玩啥?

具体做法是这样:

  1. 录制时,把 ViewModel 里 savedStateHandle 的当前所有键值对提取出来(可以通过反射或定义接口),存到本地文件。
  2. 回放时,创建 ViewModel 之前,先把文件中的快照传入一个 SavedStateHandle 实例,再注入到 ViewModel 中。
  3. Compose UI 直接通过 getStateFlow 拿到回放数据,无需任何额外处理。

我曾经在项目里用这种方法做到了「录制后旋转屏幕 → 回放依然正确显示相同界面」。核心代码大概这样:

// 回放时构建 SavedStateHandle
val mockSnapshot = loadSnapshotFromFile("case_001.json")
val mockHandle = SavedStateHandle(mockSnapshot.toMap())
val vm = ViewModelProvider(
    viewModelStore,
    ViewModelProvider.AndroidViewModelFactory(application)
).get(MyViewModel::class.java) // 注意:需要自定义 Factory 注入 mockHandle
避坑指南:我曾经把 savedStateHandle 的 key 写成了硬编码字符串,结果不同模块的 key 冲突了,导致回放时数据错乱。后来我强制在 ViewModel 里定义一个常量类,统一管理所有 key。切记:key 要有命名空间前缀或全局唯一。

4.5 生命周期与持久化原理

理解原理,用起来才安心。SavedStateHandle 的数据实际上走了这么一条路径:

SavedStateHandle 状态保存与恢复流程 Activity / Fragment 重建 SavedStateRegistry Bundle(磁盘/进程) SavedStateHandle ViewModel Compose UI (StateFlow) 保存状态 恢复状态 传 Handle 配置变更时: Activity 重建 → SavedStateRegistry 会从 Bundle 恢复数据

看图就清楚了:当 Activity 重建时,SavedStateRegistry 会从 Bundle 中反序列化出之前保存的数据,然后注入到 ViewModel 的 SavedStateHandle 中。ViewModel 内部再通过 StateFlow 推送给 Compose UI。整个过程对开发者基本透明——你只要负责声明哪些 key 要存就行。

4.6 项目实战中的小技巧

  • 不要把所有数据都往 SavedStateHandle 里塞,它会序列化到 Bundle,Bundle 大小是有限制的(官方建议 < 500KB)。大图片、长列表,老老实实用 Room 或 DataStore。
  • Mock 回放时,用 SavedStateHandle 的构造函数直接注入预设数据,这样你连 ViewModel 的初始化代码都不用改。
  • 可以结合 Hilt 的 @HiltViewModel,自动注入 SavedStateHandle。省掉手动创建 Factory 的麻烦。
  • 注意 SavedStateHandle 不是跨进程的,进程杀掉后它依赖系统的 onSaveInstanceState 机制。如果你想保存更长时间(比如第二天打开 App),必须把它同步到持久化存储。
总结一下:SavedStateHandle = ViewModel 级别的自动持久化容器。它在 Mock 回放中的价值是——让你能准确模拟录制时的状态快照,而且不需要修改 ViewModel 的业务代码。接下来如果你要设计自己的回放框架,这个工具值得重点利用。