ViewModel 的 SavedStateHandle,进程杀死后数据还在吗
ViewModel 的 SavedStateHandle,进程杀死后数据还在吗
Android 开发者对 ViewModel 的熟悉程度大概仅次于 Activity 和 Fragment,但 SavedStateHandle 这个 API 从 Jetpack 2.2.0 引入至今,关于它到底能保住多少数据的讨论一直没停过。官方文档说它能"在系统终止进程后恢复数据",这句话听起来很美好,实际用起来却有不少细节值得挖。
从 onCleared 不会调用的场景说起
很多人第一次接触 SavedStateHandle 是因为一个诡异的现象:ViewModel 里的 onCleared 在某些情况下根本不执行。旋转屏幕时它会调用,按 Home 键再回来不会调用,但把应用从最近任务划掉——有时候调用了,有时候又没调用。
这个"有时候"就是系统杀进程和正常销毁的分界线。Android 的进程生命周期管理对开发者来说基本是个黑盒,低内存时 ActivityManager 会挑优先级低的进程下手,你的应用可能在后台播放着音乐、下载着文件,下一秒进程就没了。这种情况下 onCleared 不会走,普通 ViewModel 里存的数据直接灰飞烟灭。
SavedStateHandle 的设计初衷就是解决这个场景。它把数据序列化后塞进 Activity 的 onSaveInstanceState Bundle 里,跟着系统的那套保存恢复机制走。但这里有个关键限制:Bundle 的大小。
Bundle 的 1MB 隐形天花板
这个限制不是 SavedStateHandle 加的,是底层 Binder 事务缓冲区的大小决定的。单个 Binder 事务通常不能超过 1MB,而 Activity 的 saved state 最终要通过 Binder 传递到系统服务。如果你的 SavedStateHandle 里塞了个大对象,比如一张 Bitmap 或者一个包含几千条数据的 List,应用崩溃时抛的异常是 TransactionTooLargeException,但堆栈往往指向别处,定位起来很烦人。
我去年在一个项目里踩过这个坑。当时用 SavedStateHandle 保存了一个搜索页面的筛选状态,对象结构大概长这样:
data class FilterState(
val keywords: String,
val categoryIds: List<Int>,
val priceRange: Pair<Float, Float>,
val selectedTags: List<String>,
val sortOrder: SortOrder
)测试时一切正常,直到某个用户反馈说在低端机上搜索后切到别的应用,再回来就崩溃。复现步骤是:先选十几个标签,价格区间来回拖动几次,然后按 Home 键,打开相机拍几张照片,再回来。崩溃日志是 android.os.TransactionTooLargeException: data parcel size 1056932 bytes,刚好踩线。
查了一下,那个用户的 selectedTags 里有 200 多个字符串,每个标签还带了中文描述。SavedStateHandle 的 set() 方法对数据类型没有限制,编译期不会报错,运行时才会暴露问题。后来改成了只保存筛选条件的 ID 和索引,具体数据从数据库重新加载,Bundle 大小降到几 KB。
Jetpack 的文档其实提过这个限制,但措辞很委婉:"should be kept small because it is serialized and deserialized during configuration changes and while the process is being killed and restored"。没有给具体数字,很多开发者直到崩溃才意识到问题的严重性。
进程杀死 vs 配置变更,两套完全不同的恢复路径
SavedStateHandle 的恢复机制有两条路径,行为并不完全一致。
配置变更(旋转屏幕、暗黑模式切换等)走的是 ViewModel 的保留实例机制。Activity 重建时,同一个 ViewModel 实例还在,SavedStateHandle 里的数据直接从内存读取,不需要反序列化。这时候你 get() 出来的对象和之前是同一个引用,修改它会影响其他观察者。
系统杀进程后的恢复就完全不同了。Activity 重新创建,ViewModel 是全新的实例,SavedStateHandle 从 Bundle 反序列化数据。这时候 get() 出来的是新对象,和之前那个没有引用关系。
这个差异导致了一个隐蔽的 bug。假设你在 ViewModel 里这样写:
private val _uiState = MutableStateFlow(savedStateHandle.get<<UiState>("key") ?: UiState())
val uiState = _uiState.asStateFlow()
fun updateState(newState: UiState) {
_uiState.value = newState
savedStateHandle["key"] = newState
}配置变更时,_uiState 和 savedStateHandle 里的 UiState 是同一个对象。你在 UI 层收集 uiState,修改后调用 updateState,因为对象引用相同,Flow 的 distinctUntilChanged 可能会跳过这次更新——取决于你的实现。
进程杀死后恢复,两个 UiState 是独立的,行为又正常了。这种不一致让调试变得很痛苦,我在一个项目里花了小半天才定位到问题。后来改成了强制复制:
savedStateHandle["key"] = newState.copy()确保每次保存都是新对象,虽然有点冗余,但行为稳定。
SavedStateHandle 的 set 和 get,不是简单的 Map 操作
看一下 SavedStateHandle 的源码实现,它内部包装了一个 MutableLiveData 式的状态管理机制,但 API 暴露得像 Map。set(key, value) 实际上会触发 LiveData 的更新,而 getLiveData(key) 返回的 LiveData 会在值变化时自动同步到 SavedStateHandle。
这个设计有个副作用:getLiveData 和 get 对同一个 key 的观察不是完全互通的。看这段代码:
val liveData = savedStateHandle.getLiveData<String>("name")
savedStateHandle["name"] = "Alice"
// 这里 liveData 会收到更新但如果反过来:
val current = savedStateHandle.get<String>("name")
liveData.value = "Bob"
// current 还是之前的值,get() 不会自动刷新get() 返回的是快照,getLiveData 返回的是可观察的流。更隐蔽的是,如果你先用 getLiveData 注册了一个 key,之后用 set() 修改值,LiveData 的观察者会收到通知;但如果你从未调用过 getLiveData,直接 set() 就只是修改内部 Map,没有 LiveData 的副作用。
Compose 的 saveable 和 rememberSaveable 底层依赖 SavedStateHandle,这个机制差异会影响到 State 的恢复行为。我迁移一个旧项目到 Compose 时,发现 rememberSaveable 保存的自定义对象有时候恢复出来是 null,查了很久才发现是对象的 Saver 实现里用了 savedStateHandle.get() 而不是 getLiveData,在特定的恢复时序下读到了旧值。
进程杀死测试,别再用开发者选项里的"不保留活动"了
验证 SavedStateHandle 的行为,最麻烦的是模拟系统杀进程。很多开发者习惯打开开发者选项里的"不保留活动"(Don't keep activities),但这和真实的系统杀进程有本质区别。
"不保留活动"是用户离开 Activity 时立即销毁它,但进程还在。这时候 onSaveInstanceState 会调用,ViewModel 的 onCleared 也会调用——因为进程没死,ViewModel 的保留机制被绕过,直接走正常销毁流程。SavedStateHandle 的数据确实保存了,但恢复路径是"配置变更式"的,不是真正的进程重建。
真实的系统杀进程,是 ActivityManager 把整个进程干掉,内存里的所有对象都不复存在。恢复时 Application 重新创建,所有单例重置,静态变量清零,这才是 SavedStateHandle 真正要应对的场景。
测试真实杀进程的方法有几种。最可靠的是用 adb:
adb shell am kill com.your.package这个命令模拟的是系统因内存压力杀进程,Activity 的 task 记录还在。然后你从最近任务切回来,就会走完整的恢复流程。注意这个命令需要 root 或者 debuggable 应用,release 包测不了。
另一种方法是 Android Studio 的 "Terminate Application" 按钮,但这和系统杀进程有细微差别,有时候不会触发 saved state 的恢复。我个人更信任 adb 的方式。
还有一个冷门的测试手段:在应用后台时,用 adb shell dumpsys activity activities 找到你的 Activity 记录,记下它的 ActivityRecord hash,然后 adb shell am force-stop 之后重新启动。这个流程太繁琐,一般只在怀疑框架层 bug 时才用。
API 26 的坑:SavedState Provider 的注册时序
SavedStateHandle 的底层实现依赖 SavedStateRegistry,这个类在 Activity 的 onCreate 中初始化。但 ViewModel 的创建时机和 SavedStateRegistry 的恢复时机有个微妙的竞态。
在 API 26(Android 8.0)上,如果你的 Application 注册了 ActivityLifecycleCallbacks,在 onActivityCreated 里立即访问 ViewModelProvider,可能会拿到一个 SavedStateHandle 为 null 的 ViewModel。因为 SavedStateRegistry.performRestore 还没执行,ViewModel 的 AbstractSavedStateViewModelFactory 拿不到恢复用的 Bundle。
这个 bug 在 Jetpack 2.3.0 之前存在,后来加了延迟初始化的 workaround。但如果你还在维护一个老项目,用的 ViewModel 版本比较旧,可能会遇到。表现是进程杀死后恢复,ViewModel 能创建但 SavedStateHandle 是空的,所有保存的数据丢失。
诊断方法是断点打在 SavedStateRegistryController.performRestore,看它和 ViewModelProvider.get 的调用顺序。正常应该是 performRestore 先执行,如果反了就是命中这个问题。升级 Jetpack 版本是最直接的修复,如果升不了,可以手动在 Activity 的 onCreate 里 super.onCreate 之后延迟几帧再初始化 ViewModel,很丑但能用。
自定义 Saver 的序列化陷阱
SavedStateHandle 支持任意可序列化的对象,但"可序列化"在 Android 里有好几层含义。Serializable 接口是最宽松的,但性能差且容易触发 ClassNotFoundException;Parcelable 性能好但实现繁琐;Kotlin 的 @Parcelize 注解省了不少代码,但和 SavedStateHandle 的配合有个细节。
@Parcelize 生成的 Parcelable 实现,在进程杀死后的反序列化时,需要类加载器能找到对应的 Class。如果你的模型类放在 feature module 里,主进程恢复时那个 module 还没加载(比如动态交付的场景),Parcel.readParcelable 会抛异常,SavedStateHandle 返回 null,数据静默丢失。
我遇到的具体场景是:一个动态功能模块里的 Fragment,用了 SavedStateHandle 保存 @Parcelize 的数据类。用户安装完基础应用,触发动态模块下载,进入功能页面,保存状态,按 Home 键。系统杀进程后,用户从最近任务恢复,但动态模块还没下载完,主进程先启动了。Activity 恢复时尝试反序列化 Parcelable,类找不到,SavedStateHandle 给默认值,UI 状态全丢。
修复方案是避免在跨模块边界的数据里用 Parcelable,改用原始类型组合或者 JSON 字符串。SavedStateHandle 的 set() 对 String 没有大小限制(除了 Bundle 总限制),序列化可控,类加载问题也绕开了。代价是性能差一点,但稳定性优先。
和 Hilt 集成的隐藏契约
Hilt 的 ViewModel 注入和 SavedStateHandle 的集成从 1.0.0 就支持,用法是在 ViewModel 构造函数里加 @AssistedInject:
@HiltViewModel
class MyViewModel @AssistedInject constructor(
@Assisted private val savedStateHandle: SavedStateHandle,
private val repository: MyRepository
) : ViewModel() {
// ...
}这个 @Assisted 标记看起来是 Hilt 的语法糖,但底层依赖 Dagger 的 assisted injection,生成的代码在编译期就要确定 SavedStateHandle 的提供方式。Hilt 1.0.0-alpha03 之前有个 bug:如果 ViewModel 的 SavedStateHandle 参数不是第一个,或者和别的 @Assisted 参数混用,生成的 ViewModelFactory 会报错 IllegalArgumentException: no argument match。
更隐蔽的是,Hilt 的 SavedStateHandle 和手动 ViewModelProvider 创建的 SavedStateHandle 在恢复行为上有细微差别。Hilt 的 HiltViewModelFactory 在 onCreate 时就把 SavedStateRegistry 的 owner 绑定了,而手动创建时你可以控制 ViewModelStoreOwner 和 SavedStateRegistryOwner 是否为同一个对象。在嵌套 Fragment 或者自定义 ViewModelStoreOwner 的场景下,Hilt 的绑定可能和你预期的不一致,导致恢复时拿到的 SavedStateHandle 是另一个 owner 的。
我调试过一个问题:一个 DialogFragment 里用了 Hilt 注入的 ViewModel,它的 SavedStateHandle 在进程杀死后恢复时,数据总是对不上。最后发现是 DialogFragment 的 ViewModelStore 继承自父 Fragment,但 SavedStateRegistry 是独立的,Hilt 的默认绑定把两者混为一谈。改成手动 ViewModelProvider 指定 SavedStateRegistryOwner 后解决。这个案例说明自动注入的便利性背后,对底层机制的理解不能丢。
性能数据:序列化开销到底多大
SavedStateHandle 的数据最终要进 Bundle,Bundle 要序列化到 Parcel,Parcel 要写入系统的持久化存储(虽然通常是内存里的缓存,但格式和持久化一致)。这个开销有多大?
我做过一个粗略的测试,设备是 Pixel 4a,Android 13。保存一个包含 1000 个简单数据对象的 List,每个对象有 5 个 String 字段和 2 个 Int 字段。
用 Serializable 序列化,保存耗时约 45ms,恢复约 80ms,生成的 Parcel 数据 380KB 左右。用 Parcelable 优化后,保存 12ms,恢复 15ms,数据 120KB。改成 JSON 字符串用 String 存进 SavedStateHandle,保存 8ms(含序列化),恢复 10ms(含反序列化),数据 95KB,但解析后的对象和原对象字段顺序有差异(HashMap 的遍历顺序)。
这些数字在高端机上看起来还能接受,但在低端机、后台杀进程的高负载场景下,系统给每个应用的保存时间窗口有限。Android 10 引入了 AppExitInfo,可以看到应用被系统杀死时的状态,其中 REASON_LOW_MEMORY 的杀死往往伴随严格的 CPU 时间限制。如果你的 saved state 太大,系统可能直接跳过保存,或者保存不完整,恢复时 Bundle 里 key 存在但 value 为 null。
一个实用的优化是:SavedStateHandle 只保存"导航状态"和"用户输入的未提交数据",完整的业务数据从持久化存储重新加载。比如一个表单页面,SavedStateHandle 存用户已经填了但还没提交的字段,已提交的数据在数据库里,恢复时重新查询。这样 SavedStateHandle 的数据量可控,恢复逻辑也更清晰。
一个未文档化的行为:LiveData 的粘性通知
前面提到 getLiveData 返回的 LiveData 会在值变化时同步,但没说它的初始行为。SavedStateHandle.getLiveData("key") 创建的 LiveData,如果当前 key 有值,会立即把这个值作为 LiveData 的初始值。这看起来合理,但结合 LiveData 的"粘性"特性,会有意外行为。
假设你在 Fragment 的 onCreateView 里观察 LiveData:
override fun onCreateView(...) {
viewModel.nameLiveData.observe(viewLifecycleOwner) { name ->
updateUI(name)
}
}进程杀死后恢复,Fragment 重建,onCreateView 再次执行,LiveData 会立即把保存的值发给观察者。但如果这个值触发的 updateUI 里有些副作用(比如发一次 analytics 事件),你就会在每次恢复时多上报一次。
更麻烦的是旋转屏幕和进程杀死的组合。旋转时 ViewModel 保留,LiveData 的观察者被清理后重新注册,不会收到旧值(因为版本号对齐);但进程杀死后恢复,LiveData 是新实例,版本号从 0 开始,保存的值作为初始值推送,行为不一致。
我的处理方式是在 ViewModel 里用 StateFlow 包装 SavedStateHandle,手动控制重放:
private val _name = MutableStateFlow(savedStateHandle.get<String>("name") ?: "")
val name = _name.asStateFlow()
init {
viewModelScope.launch {
_name.collectLatest { savedStateHandle["name"] = it }
}
}这样进程恢复时 StateFlow 的初始值来自 SavedStateHandle,但新订阅者不会收到旧值(除非显式用 stateIn 的 SharingStarted.Eagerly),行为统一。代价是多一层包装,代码多一点。
最后说一个冷知识:SavedStateHandle 的 key 不能是 null,但 value 可以是
这个限制来自底层的 HashMap,set(null, value) 会抛 NullPointerException。但 set(key, null) 是允许的,恢复时 get(key) 返回 null。这看起来无害,但和 Kotlin 的 null safety 结合会有陷阱。
如果你用 savedStateHandle.get<String?>("key"),返回类型是 nullable,编译器不会强制判空。但进程恢复后,如果系统保存时这个 key 的值是 null,和"key 不存在"两种情况,get() 都返回 null,区分不了。
SavedStateHandle 没有 contains 方法,要判断 key 是否存在,只能用 keys() 集合检查。这个 API 设计得不太方便,很多开发者直接用 null 作为"未设置"的语义,在恢复场景下会误判。我建议用一个哨兵值或者单独的标志位来区分"未初始化"和"已设置为空",虽然啰嗦但正确。
SavedStateHandle 是个实用的 API,但"进程杀死后数据还在"这句话的完整理解是:在 Bundle 大小限制内、类加载器能找到类、序列化不抛异常、系统给了足够保存时间、恢复时序没被打乱的前提下,部分数据有可能恢复。每个条件都是真实踩过的坑,不是文档里会强调的。把它当成最后的保底手段,而不是可靠的持久化存储,大概是实践中比较务实的态度。