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 被调用,遍历所有注册到 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 大小限制的具体数值,我整理了一组实测结果,供参考:


  • 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。而且 Parcel 的 dataSize() 在 write 之后、setDataPosition(0) 之前读才准确,这个细节坑了我一次。


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

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

    评论区