SavedStateViewModelFactory 的恢复逻辑,Bundle 大小限制

SavedStateViewModelFactory 的恢复逻辑,Bundle 大小限制

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 被调用,遍历所有注册到 SavedStateRegistrySavedStateProvider,把它们的 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 处理,复杂对象的序列化开销很容易膨胀。


SavedStateRegistryperformSave 时的聚合逻辑在 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 的写入,导致进程重建后 StateFlowvaluesavedStateHandle 里的实际数据可能不一致。这个 bug 在 2.4.0 修复。


2.5.0 版本重构了 SavedStateRegistry 的内部结构,把 performSaveperformRestore 的调用时机和 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 时试了一下,发现 SavedStateHandlesaveState() 实现改成了延迟序列化,声称可以减少主线程阻塞。但实际测试下来,对于复杂对象,序列化开销只是被转移到了第一次读取时,整体时间没差多少。而且 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 里,不是当前可见的)。但 SavedStateRegistryperformRestore 时,会把所有注册过的 provider key 都恢复出来,包括 Fragment B 的 ViewModel 状态。这个状态被存在一个 pending map 里,等待对应的组件来消费。


如果 Fragment A 的 CartViewModel 和 Fragment B 的某个 ViewModel 恰好用了同一个 key(比如都是默认的类名全限定名),就会发生冲突。SavedStateRegistryconsumeRestoredStateForKey 是只能消费一次的,第一个来的拿走,后面的拿到 null。


这个 key 冲突在官方文档里完全没有提及。默认情况下,SavedStateViewModelFactoryAbstractSavedStateViewModelFactory 的 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 里只存文件路径。


对官方设计的看法


SavedStateViewModelFactorySavedStateHandle 的设计意图是好的:让 ViewModel 在进程重建后保持状态,同时避免 onRetainNonConfigurationInstance 那种容易内存泄漏的 API。但实际用下来,我觉得它在易用性和正确性之间的平衡没做好。


太容易误用了。savedStateHandle["key"] = value 这个 API 看起来和普通 Map 一样简单,但背后有 Bundle 大小限制、序列化开销、key 命名空间冲突等一系列坑。官方文档只说了 "use SavedStateHandle to save UI state",没强调什么该存什么不该存。很多开发者(包括我自己一开始)自然地把它当成 ViewModel 的持久化存储,什么数据都往里塞。


SavedStateHandleStateFlow 支持也有问题。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 大小限制的具体数值,我整理了一组实测结果,供参考:


  • Android 13 Emulator (API 33): 约 1.0MB 时数据丢失,无异常
  • Pixel 4, Android 12 (API 31): 约 1.0MB
  • Samsung S21, Android 13: 约 1.0MB
  • Xiaomi 10, MIUI 12, Android 10: 约 0.8MB
  • Huawei P30, EMUI 10, Android 10: 约 0.9MB
  • OPPO Find X2, ColorOS 7, Android 10: 约 0.7MB

  • 测试方法是构造一个包含单个大 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。而且 ParceldataSize() 在 write 之后、setDataPosition(0) 之前读才准确,这个细节坑了我一次。


    写到这,基本把 SavedStateViewModelFactory 的恢复逻辑和 Bundle 大小限制相关的技术点都覆盖到了。从源码机制到实际踩坑,从版本差异到防御方案,这个组件看起来简单,但深入下去有很多边界情况。如果你也在用 SavedStateHandle,建议检查一下项目里有没有存大数据的地方,加一层大小监控,避免在用户的设备上静默丢状态。

    Lottie 动画的文件体积优化,JSON 精简技巧 2026-07-15
    OverlayManagerService 的悬浮窗权限,不同厂商差异 2026-07-15

    评论区