SavedStateViewModelFactory 的恢复逻辑,Bundle 大小限制
「SavedStateViewModelFactory 的恢复逻辑,Bundle 大小限制」
去年给一个电商 App 做进程回收后的状态恢复时,我踩到了一个坑:用户把商品加入购物车后切到后台,过十几分钟再回来,App 被系统杀掉了,重新打开后购物车数据没了,但浏览记录还在。排查到最后,问题出在 SavedStateViewModelFactory 处理 Bundle 恢复的逻辑上,更准确地说,是 Bundle 大小触发了系统限制,导致部分数据被静默丢弃。
这个坑最恶心的地方在于它不会崩溃。Android 的 Binder 机制在传输大数据时,如果超限会直接抛 TransactionTooLargeException,但 SavedStateViewModelFactory 走的不是普通 Binder 路径,它通过 SavedStateRegistry 把数据塞进 onSaveInstanceState 的 Bundle 里。这个 Bundle 在进程存活时没问题,进程被杀后由系统保管,恢复时再读回来。系统对单个 Bundle 的大小限制大约是 1MB(不同厂商有差异,Pixel 上实测 1MB 左右,某些国产 ROM 可能更严格),超过这个限制,系统会直接丢弃整个 Bundle,你的 ViewModel 状态就全没了。不是部分丢失,是全部消失,而且没有任何异常抛出。
`SavedStateViewModelFactory` 的恢复链路
先理清楚这个 Factory 到底在干什么。AndroidX 的 lifecycle-viewmodel-savedstate 库从 2.2.0-alpha01 开始提供了 SavedStateViewModelFactory,它的核心作用是让 ViewModel 在进程重建后还能拿到之前保存的状态。传统的 ViewModelProvider.Factory 创建的 ViewModel 在进程杀死后会完全重建,SavedStateViewModelFactory 则通过 SavedStateHandle 把数据持久化到 onSaveInstanceState 的 Bundle 中。
具体调用链是这样的:你的 Activity 或 Fragment 实现了 SavedStateRegistryOwner,内部持有一个 SavedStateRegistryController。当系统触发 onSaveInstanceState 时,SavedStateRegistryController.performSave 被调用,遍历所有注册到 SavedStateRegistry 的 SavedStateProvider,把它们的 saveState() 结果收集到一个 Bundle 里。SavedStateViewModelFactory 创建的 ViewModel 会自动注册一个 provider,把 SavedStateHandle 里的数据序列化进去。
恢复时,系统在 onCreate 把 Bundle 传回来,SavedStateRegistryController.performRestore 解析这个 Bundle,按 key 分发给各个 provider。SavedStateViewModelFactory 在创建 ViewModel 时,会从 SavedStateRegistry 消费掉对应 key 的 Bundle,构造出 SavedStateHandle 注入到 ViewModel 的构造函数。
这个设计本身没问题,但问题出在 Bundle 的聚合方式上。SavedStateRegistry 把所有组件的数据塞到一个 Bundle 里,这个 Bundle 最终会和 Activity 自己 onSaveInstanceState 里的数据合并。也就是说,你的 ViewModel 状态、Fragment 的 setArguments、FragmentManager 的 back stack 状态、各种其他库(比如 Navigation 组件)保存的状态,全部共享同一个大小配额。
那个购物车消失的 Bug
回到那个电商 App 的 Bug。购物车页面是一个 CartViewModel,用 SavedStateViewModelFactory 创建,内部用 SavedStateHandle 保存了当前选中的 SKU 列表。用户选了五六个商品,每个 SKU 对象包含完整的商品信息:ID、名称、价格、图片 URL、规格参数、库存状态。我把整个 List<Sku> 直接塞进了 SavedStateHandle。
class CartViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val selectedSkus = savedStateHandle.getStateFlow("skus", emptyList<Sku>())
fun selectSku(sku: Sku) {
val current = selectedSkus.value.toMutableList()
current.add(sku)
savedStateHandle["skus"] = current
}
}Sku 是一个 data class,实现了 Parcelable。单个对象不大,但用户选了 6 个商品后,整个列表序列化后的 Bundle 大约 180KB。这看起来离 1MB 还很远,但问题是这个页面同时存在多个 Fragment,每个 Fragment 都有自己的 ViewModel,而且主 Activity 还有一个 MainViewModel 保存了当前导航状态。更致命的是,商品详情页返回时,会把整个 ProductDetail 对象也塞进 SavedStateHandle,这个对象包含了商品详情、评价列表、推荐商品,序列化后超过 400KB。
一次完整的页面浏览后,所有 ViewModel 的 SavedStateHandle 数据加起来,在 onSaveInstanceState 时聚合的 Bundle 大小达到了 1.2MB。在 Pixel 4 上测试,这个大小不会立即触发问题,系统有一定的缓冲空间。但在某些国产 ROM 上,比如我测试的一台 MIUI 12 的设备,限制更严格,大约 800KB 就会丢弃 Bundle。结果就是用户后台停留一段时间后返回,所有 ViewModel 状态清空,但 Activity 和 Fragment 的实例状态(比如 EditText 的文字)还在,因为那些走的是另外的路径。
最诡异的是表现不一致。有时候购物车数据丢了,但浏览记录还在;有时候反过来。后来看 SavedStateRegistry 的源码才理解:Bundle 被丢弃是整个丢弃的,但不同组件恢复的时机不同。如果某个组件在 performRestore 之前就尝试读取状态,它会拿到空 Bundle;如果另一个组件晚一点读取,可能能拿到部分数据——不,实际上是一旦超限,整个 Bundle 就没了,所有组件都拿不到。我观察到的"部分丢失"其实是应用层自己的缓存逻辑在起作用,不是 SavedStateRegistry 的行为。
源码层面的确认
为了确认这个行为,我拉下了 androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1 的源码,跟踪了 SavedStateHandle 的读写路径。
SavedStateHandle 内部有两个关键结构:
private val regular = mutableMapOf<String, Any?>()
private val savedStateProviders = mutableMapOf<String, SavedStateProvider>()当你调用 savedStateHandle["key"] = value 时,数据直接进 regular 这个普通 HashMap。但 SavedStateHandle 同时实现了 SavedStateProvider 接口,它的 saveState() 方法会把 regular 里的数据序列化成 Bundle:
override fun saveState(): Bundle {
val keySet = regular.keys + savedStateProviders.keys
val outBundle = Bundle(keySet.size)
// ... 把 regular 的数据按类型写入 Bundle
for ((key, provider) in savedStateProviders) {
outBundle.putBundle(key, provider.saveState())
}
return outBundle
}注意这里 regular 里的数据是直接写进 Bundle 的,不是嵌套一层。这意味着如果你的 value 是一个 Parcelable 列表,每个元素都会被递归序列化。Bundle 的底层实现是 ArrayMap,序列化时通过 Parcel.writeValue 处理,复杂对象的序列化开销很容易膨胀。
SavedStateRegistry 在 performSave 时的聚合逻辑在 SavedStateRegistryController 中:
fun performSave(outBundle: Bundle) {
val components = mutableListOf<Bundle>()
for ((key, provider) in registeredProviders) {
components.add(provider.saveState().also { it.putString("key", key) })
}
// ... 把所有 component 塞进 outBundle
}所有 provider 的 Bundle 被扁平化后塞进一个总的 Bundle。这个 outBundle 最终会和 Activity 的 super.onSaveInstanceState(outState) 合并。也就是说,你的 ViewModel 数据、Fragment 的 mSavedFragmentState、各种 OnRetainInstance 的回调结果,全部挤在一个容器里。
Android 框架层对 onSaveInstanceState Bundle 的大小限制没有明确的文档说明,但可以通过实验测定。我写了一个测试工具,构造不同大小的 byte array 塞进 Bundle,观察什么情况下数据丢失:
val testBundle = Bundle()
val data = ByteArray(size)
// 填充数据...
testBundle.putByteArray("test", data)
val parcel = Parcel.obtain()
parcel.writeValue(testBundle)
val bundleSize = parcel.dataSize()
parcel.recycle()
// 通过启动新 Activity 并 finish,触发 onSaveInstanceState
// 然后模拟进程杀死,再恢复,检查数据是否存在在 Android 13(API 33)的模拟器上,Bundle 序列化后超过约 1MB 时,恢复时 testBundle.getByteArray("test") 返回 null,没有异常。在 Android 10(API 29)的一台真机上,这个阈值大约是 900KB。更麻烦的是,这个限制是整个 Bundle 的大小,不是单个 key。你可能某个 key 只有 100KB,但十几个 key 加起来就超了。
`SavedStateHandle` 的 `setSavedStateProvider` 陷阱
SavedStateHandle 提供了一个高级 API:setSavedStateProvider(key, provider),允许你自定义某个 key 的序列化逻辑。这个设计本意是让你把大数据拆出去,比如存到文件系统,只在 Bundle 里存一个文件路径。但实际用起来有个坑:provider 的调用时机和 SavedStateHandle 自身的 saveState() 是同步的,都在 performSave 时发生。如果你的 provider 内部又去读其他 SavedStateHandle 的数据,会形成依赖循环,虽然框架层面有检测会抛异常,但文档里没提这个限制。
我在尝试优化购物车的序列化时,写了一个自定义 provider:
savedStateHandle.setSavedStateProvider("skus") {
val skus = savedStateHandle.get<List<Sku>>("skus") ?: emptyList()
// 只存 ID 列表,不存完整对象
Bundle().apply {
putStringArrayList("ids", ArrayList(skus.map { it.id }))
}
}这段代码看起来没问题,但运行时直接抛了 IllegalArgumentException: SavedStateProvider for 'skus' is already registered。原因是 SavedStateHandle 在构造时已经为自己注册了一个默认 provider,key 就是 "skus"。setSavedStateProvider 不能覆盖已有的 key,这个限制在 2.6.1 的源码里写死了:
fun setSavedStateProvider(key: String, provider: SavedStateProvider) {
if (key in regular) {
throw IllegalArgumentException(...)
}
savedStateProviders[key] = provider
}但 regular 里的数据是在你第一次 savedStateHandle["skus"] = ... 时放进去的,所以一旦你已经写过这个 key,就再也不能设置自定义 provider。正确的做法是在 ViewModel 初始化时就设置 provider,但 SavedStateHandle 的构造函数是框架调用的,你拿到实例时它可能已经恢复了一些数据。这个时序问题让我折腾了半天,最后只能改用另外一个 key 名字绕过去。
实际采用的解决方案
针对 Bundle 大小限制,我最后采用了分层存储的策略。核心原则是:SavedStateHandle 只存恢复 UI 状态必需的最小数据,业务数据走另外的持久化通道。
对于购物车场景,最终方案是:
class CartViewModel(
savedStateHandle: SavedStateHandle,
private val cartRepository: CartRepository
) : ViewModel() {
// SavedStateHandle 只存选中状态的 ID 集合,不存完整对象
private val selectedIds = savedStateHandle.getStateFlow("selected_ids", emptySet<String>())
// 完整数据从 Repository 实时读取
val cartItems: StateFlow<List<CartItem>> = cartRepository.items
.combine(selectedIds) { items, ids ->
items.map { it.copy(selected = it.sku.id in ids) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
fun toggleSelection(skuId: String) {
val current = selectedIds.value.toMutableSet()
if (current.contains(skuId)) current.remove(skuId) else current.add(skuId)
savedStateHandle["selected_ids"] = current
}
}CartRepository 底层用 Room 数据库,数据已经持久化。进程重建后,即使 SavedStateHandle 的数据也丢了,用户最多是选中状态重置为全未选,不会丢商品数据。而选中状态的 ID 集合很小,几十个 ID 也就几 KB,不可能触发 Bundle 限制。
对于商品详情页那个 400KB 的 ProductDetail,我直接放弃了在 ViewModel 里保存完整对象。改为在 onSaveInstanceState 时只存商品 ID,恢复时重新走网络请求。为了体验流畅,加了一个内存级的 LRU 缓存,进程存活时秒开,进程重建时显示骨架屏重新加载。这个方案牺牲了一点极端情况下的体验,但彻底规避了 Bundle 超限的风险。
`SavedStateViewModelFactory` 的版本差异
在排查过程中,我发现不同版本的 AndroidX 对 SavedStateHandle 的实现有细微差异,这些差异会影响你踩坑的方式。
2.3.0 版本之前,SavedStateHandle 不支持 StateFlow,只能用 LiveData。2.3.0 引入了 getStateFlow API,但实现上有 bug:StateFlow 的初始值不会触发 savedStateHandle 的写入,导致进程重建后 StateFlow 的 value 和 savedStateHandle 里的实际数据可能不一致。这个 bug 在 2.4.0 修复。
2.5.0 版本重构了 SavedStateRegistry 的内部结构,把 performSave 和 performRestore 的调用时机和 Activity 的生命周期回调更紧密地绑定。一个副作用是:如果你在 onStop 里手动调用 viewModelScope.launch { ... } 去修改 SavedStateHandle,而这时 Activity 已经进入了 onSaveInstanceState 的状态,你的修改可能不会被保存。2.5.0 之前,这种时序竞争问题更常见;2.5.0 之后,框架加了 isStateSaved 的检查,会抛 IllegalStateException 而不是静默丢失。
2.6.0 开始支持了 Kotlin 的 SavedStateHandle 委托属性,可以写 val query by savedStateHandle.saveable { mutableStateOf("") },这在 Compose 项目里很方便。但这个委托内部还是走同样的 Bundle 序列化路径,大小限制问题一点没改善。
我目前项目里锁定的版本是 2.6.1,配合 Compose 1.5.x 使用。升级到 2.7.0-alpha 时试了一下,发现 SavedStateHandle 的 saveState() 实现改成了延迟序列化,声称可以减少主线程阻塞。但实际测试下来,对于复杂对象,序列化开销只是被转移到了第一次读取时,整体时间没差多少。而且 alpha 版本里 getStateFlow 的初始值恢复有回归,就没继续跟进了。
一个未被文档记录的边界情况
最后说一个我花了整整一个下午才定位的问题,和 SavedStateViewModelFactory 的恢复逻辑直接相关。
场景是这样的:一个 Activity 里有两个 Fragment,A 和 B,共用同一个 CartViewModel(通过 activityViewModels() 获取)。A 负责展示商品列表,B 负责展示已选清单。用户操作一番后,A 和 B 都通过 SavedStateHandle 写了数据。然后用户按 home 键,App 进后台,进程被杀。
重新打开 App 时,恢复流程是:Activity onCreate -> performRestore -> 恢复 FragmentManager 的 back stack -> 创建 Fragment A -> Fragment A 的 onCreate 里通过 activityViewModels() 获取 CartViewModel。这时 SavedStateViewModelFactory 会检查 SavedStateRegistry 里有没有这个 ViewModel 的保存状态。如果有,构造 SavedStateHandle 注入;如果没有,创建一个空的。
问题出在这里:Fragment B 此时还没有被创建(因为它在 back stack 里,不是当前可见的)。但 SavedStateRegistry 在 performRestore 时,会把所有注册过的 provider key 都恢复出来,包括 Fragment B 的 ViewModel 状态。这个状态被存在一个 pending map 里,等待对应的组件来消费。
如果 Fragment A 的 CartViewModel 和 Fragment B 的某个 ViewModel 恰好用了同一个 key(比如都是默认的类名全限定名),就会发生冲突。SavedStateRegistry 的 consumeRestoredStateForKey 是只能消费一次的,第一个来的拿走,后面的拿到 null。
这个 key 冲突在官方文档里完全没有提及。默认情况下,SavedStateViewModelFactory 用 AbstractSavedStateViewModelFactory 的 key 生成规则:"androidx.lifecycle.ViewModelProvider.DefaultKey:" + canonicalName。如果你的两个 Fragment 各自通过 activityViewModels() 获取同一个类的 ViewModel,它们共享实例,没问题。但如果 Fragment B 里有一个嵌套的子 Fragment,也通过 activityViewModels() 获取同一个 CartViewModel,而子 Fragment 的 SavedStateRegistry 是独立的——不,等等,子 Fragment 的 SavedStateRegistryOwner 是它自己,不是 Activity。
实际触发冲突的场景更隐蔽:我们用了 Navigation 组件,一个 NavHostFragment 里有两个 destination,都用 activityViewModels() 获取 CartViewModel。Navigation 组件在保存状态时,会给每个 destination 的 ViewModel 状态加一个前缀,但 activityViewModels() 创建的是 Activity 级别的 ViewModel,它的 key 没有前缀。结果 Navigation 恢复 destination 状态时,某个 destination 的 SavedStateProvider key 和 CartViewModel 的 key 撞了,因为我们在某个地方手动注册了一个 provider。
定位这个问题的方法是打日志跟踪 SavedStateRegistry 的所有 key。AndroidX 没有提供这个 API,但可以用反射偷出来:
val registry = (activity as SavedStateRegistryOwner).savedStateRegistry
val field = registry.javaClass.getDeclaredField("mRestoredState")
field.isAccessible = true
val restoredState = field.get(registry) as? Bundle
restoredState?.keySet()?.forEach { Log.d("SavedState", "key: $it") }看到 key 列表时才发现,Navigation 组件偷偷注册了 androidx:lifecycle:lifecycle-viewmodel:view-model-state 这个 key,而我们的代码里也有一个同名的 provider(从某个旧版本 copy 过来的兼容代码)。两个 provider 抢同一个 restored state,结果随机有一个拿不到数据。
修复很简单,改掉自定义 provider 的 key 名字。但这个问题让我对 SavedStateRegistry 的 key 命名空间有了新认识:它是个全局扁平结构,没有任何隔离机制。Activity 的、Fragment 的、各个库注册的 provider,全部挤在一起,全靠 key 字符串不撞车。Navigation 组件用了 androidx: 前缀,你自定义的 provider 最好也加个应用专属前缀,否则和第三方库冲突时很难排查。
监控和防御
经历过这次事件后,我在项目里加了一个 Bundle 大小监控,在 debug 构建时自动检测 onSaveInstanceState 的 Bundle 是否接近限制:
class BundleSizeMonitor : Application.ActivityLifecycleCallbacks {
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
val parcel = Parcel.obtain()
parcel.writeValue(outState)
val size = parcel.dataSize()
parcel.recycle()
if (size > 500_000) { // 500KB 预警
Log.w("BundleSize", "${activity.javaClass.simpleName} Bundle size: $size bytes")
// 开发环境下可以抛异常强制暴露问题
if (BuildConfig.DEBUG) throw IllegalStateException("Bundle too large: $size")
}
}
}这个监控在 CI 的自动化测试里跑,能提前发现 Bundle 膨胀的问题。但生产环境不能这么干,Parcel.dataSize() 有性能开销,而且写 Parcel 再回收也不是零成本。
更轻量的防御是在 SavedStateHandle 的封装层加限制。我们项目里封装了一个 SafeSavedStateHandle,对写入的数据大小做估算:
class SafeSavedStateHandle(private val delegate: SavedStateHandle) {
private val sizeEstimator = BundleSizeEstimator()
operator fun <T> set(key: String, value: T?) {
val estimatedSize = sizeEstimator.estimate(value)
if (estimatedSize > 50_000) { // 单个 value 超过 50KB 拒绝
throw IllegalArgumentException("Value too large for SavedStateHandle: $estimatedSize")
}
delegate[key] = value
}
}BundleSizeEstimator 的实现很粗糙,对不同类型给经验值:Int 算 4 字节,String 按 UTF-8 长度乘 2,Parcelable 用反射估算字段大小。不准确,但能拦住明显的错误用法,比如有人想把 Bitmap 塞进 SavedStateHandle。
Bitmap 进 SavedStateHandle 听起来很蠢,但真有人干过。某个版本里,一个图片编辑页面的 FilterViewModel 把处理后的 Bitmap 直接 savedStateHandle["preview"] = bitmap,因为 "进程重建后要恢复预览图"。Bitmap 是 Parcelable,能塞进去,运行时也正常,但后台杀掉后恢复,Bundle 直接超限,整个页面状态全丢。SafeSavedStateHandle 的 50KB 限制就是为了拦住这种用法,逼开发者把 Bitmap 存到磁盘缓存,Bundle 里只存文件路径。
对官方设计的看法
SavedStateViewModelFactory 和 SavedStateHandle 的设计意图是好的:让 ViewModel 在进程重建后保持状态,同时避免 onRetainNonConfigurationInstance 那种容易内存泄漏的 API。但实际用下来,我觉得它在易用性和正确性之间的平衡没做好。
太容易误用了。savedStateHandle["key"] = value 这个 API 看起来和普通 Map 一样简单,但背后有 Bundle 大小限制、序列化开销、key 命名空间冲突等一系列坑。官方文档只说了 "use SavedStateHandle to save UI state",没强调什么该存什么不该存。很多开发者(包括我自己一开始)自然地把它当成 ViewModel 的持久化存储,什么数据都往里塞。
SavedStateHandle 的 StateFlow 支持也有问题。getStateFlow 返回的 StateFlow 在进程重建后能恢复值,但这个恢复是同步的,在 ViewModel 构造函数里就完成了。如果你的 StateFlow 的初始值依赖于其他 StateFlow,组合逻辑在恢复时可能执行多次,因为 savedStateHandle 的数据是分批恢复的。这个时序问题在复杂 ViewModel 里很难避免。
我个人觉得,Google 应该更明确地区分 "UI 状态" 和 "业务数据" 的存储位置。SavedStateHandle 只适合存那种丢失后可以接受、且体积很小的纯 UI 状态,比如当前选中的 tab、输入框的光标位置、滚动偏移量。任何和业务相关的数据,即使体积不大,也应该走 Room/Proto DataStore/文件等正式持久化通道。但官方示例代码里经常把搜索关键词、用户 ID 这种业务数据也放 SavedStateHandle 里,给开发者错误的暗示。
另外,SavedStateRegistry 的 key 冲突问题需要更好的调试工具。现在出了问题只能反射偷数据,或者靠猜。Android Studio 的 Layout Inspector 能看 Compose 的重组次数,为什么不能加个 Saved State Inspector,可视化展示当前 SavedStateRegistry 里的所有 key 和大小?这个工具对定位 Bundle 膨胀问题会非常有价值。
最后一点实测数据
关于 Bundle 大小限制的具体数值,我整理了一组实测结果,供参考:
测试方法是构造一个包含单个大 byte[] 的 Bundle,通过 Activity.recreate() 触发保存恢复,检查数组是否还在。注意 Activity.recreate() 不走真正的进程杀死路径,但 onSaveInstanceState/onRestoreInstanceState 的 Bundle 流转是相同的。要模拟真实进程杀死,需要用 am kill 或 adb shell 里的 kill -9。
不同厂商的差异比我想象的大,OPPO 那台设备 700KB 就丢数据,这意味着即使你很谨慎地控制每个 ViewModel 只存几十 KB,四五个 ViewModel 加起来就可能触雷。所以那个 "只存最小 UI 状态" 的原则不是过度防御,是刚需。
还有一个发现:Bundle 的大小计算不是看 Bundle.size(),那个返回的是 key 的数量。要看序列化后的大小,必须用 Parcel.writeValue 或者 Bundle.writeToParcel。而且 Parcel 的 dataSize() 在 write 之后、setDataPosition(0) 之前读才准确,这个细节坑了我一次。
写到这,基本把 SavedStateViewModelFactory 的恢复逻辑和 Bundle 大小限制相关的技术点都覆盖到了。从源码机制到实际踩坑,从版本差异到防御方案,这个组件看起来简单,但深入下去有很多边界情况。如果你也在用 SavedStateHandle,建议检查一下项目里有没有存大数据的地方,加一层大小监控,避免在用户的设备上静默丢状态。