Appearance
ViewModel 架构、SavedStateHandle 与进程被杀恢复
回到总览:状态管理、路由、架构
相关模块:状态持久化、内存恢复与分层恢复策略 · Jetpack Compose 状态模型、StateFlow 与重组优化
一句话定义
ViewModel 是 Jetpack 架构中负责管理 UI 相关数据并处理屏幕旋转等配置变更(Configuration Changes)的数据容器;结合 SavedStateHandle 机制,可以在 App 发生系统内存不足导致的进程被杀(Process Death) 时实现无感的状态恢复。
代码索引
对应 Lab:viewmodel-savedstate-demo · ViewModelSavedStateDemoActivity.kt
为什么需要
- 为什么屏幕旋转时 Activity 会销毁重建,但 ViewModel 里的数据依然存在?
- 一句话答:ViewModel 存储在宿主 Activity 的
ViewModelStore中;在旋转重建时,系统通过NonConfigurationInstances机制保留了该ViewModelStore实例,新创建的 Activity 绑定同一个 Store 即可拿到旧 ViewModel。
- 一句话答:ViewModel 存储在宿主 Activity 的
- 为什么有了
ViewModel还需要SavedStateHandle?- 一句话答:ViewModel 只能抵御“配置变更”(如旋转屏幕/切换语言);当 App 退后台由于系统内存不足被杀死(Process Death)时,ViewModel 依然会被清空,只有
SavedStateHandle(基于onSaveInstanceState)能够跨进程杀死存活数据。
- 一句话答:ViewModel 只能抵御“配置变更”(如旋转屏幕/切换语言);当 App 退后台由于系统内存不足被杀死(Process Death)时,ViewModel 依然会被清空,只有
- Compose 场景中 ViewModel 作用有何演进?
- 一句话答:在 Jetpack Compose 中,
viewModel()函数将 ViewModel 绑定至 Navigation 路由节点或 Activity,作为页面级单一事实源(Single Source of Truth),隔离 Composables 纯渲染与 ViewModel 业务逻辑。
- 一句话答:在 Jetpack Compose 中,
底层机制
1. ViewModel 生命周期跨配置变更存活原理
对应 Lab:viewmodel-savedstate-demo
图注补充:Act1: 原始 Activity (被销毁);Store: 首次创建 ViewModel 并存在 Store 中;Act1: onDestroy() (isChangingConfigurations = true);Store: 保留 ViewModelStore (不执行 store.clear());Store: 从 ViewModelProvider 重新获取;Store: onDestroy() (isFinishing = true)。
2. 进程被杀恢复 (SavedStateHandle) 的写入与还原
图注补充:App 退后台 / 收到 onSaveInstanceState;SavedStateRegistry 序列化为 Bundle;系统内存不足杀掉 App 进程;从 SavedStateRegistry 恢复 Bundle;重新注入到新的 ViewModel.SavedStateHandle。
代码示例与 StateFlow 响应式结合
kotlin
class UserSearchViewModel(
private val savedStateHandle: SavedStateHandle,
private val repository: UserRepository
) : ViewModel() {
// 1. 将 query 状态绑定到 SavedStateHandle,支持进程被杀自动恢复
var query: String
get() = savedStateHandle.get<String>("KEY_QUERY") ?: ""
set(value) { savedStateHandle["KEY_QUERY"] = value }
// 2. 将 SavedStateHandle 转为 StateFlow
val searchResult: StateFlow<List<User>> = savedStateHandle.getStateFlow("KEY_QUERY", "")
.flatMapLatest { searchKey ->
if (searchKey.isBlank()) flowOf(emptyList())
else repository.searchUsersFlow(searchKey)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
}Android / Flutter / Web / Backend 对照
| 状态维度 | Android (ViewModel + SavedState) | Flutter (Riverpod / BLoC) | Web (Pinia / Vuex) |
|---|---|---|---|
| 页面级状态 | ViewModel (生命周期感知) | StateNotifier / Cubit | Store / Local Component State |
| 进程杀死恢复 | SavedStateHandle / Bundle | hydrated_bloc / PageStorage | sessionStorage / IndexedDB |
| 内存隔离 | ViewModel 不得持有 Context/View 强引用 | Notifier 不持有 BuildContext | Store 不直接操作 DOM |
常见场景
1. 配合 Navigation 共享 ViewModel
在嵌套路由(Nested Navigation Graph)中,可以通过将 ViewModelProvider 作用域限定在 NavGraph 节点上,实现父子 Fragment/Composable 之间的数据无缝共享。
常见误配、事故后果与排障
1. 事故:ViewModel 持有 Activity 或 View 的强引用导致内存泄漏
- 误配原因:在 ViewModel 内部声明了
var activity: Activity?或var view: View?。 - 后果:当屏幕旋转时,旧 Activity 被销毁,但由于 ViewModel 生命周期比 Activity 长并强持有旧 Activity 引用,导致整个旧 Activity 树发生严重的内存泄漏!
- 排障与修法:** ViewModel 严禁持有任何 Context 或 View 强引用**;若必须使用 Application Context,需继承自
AndroidViewModel。
2. 事故:SavedStateHandle 中存入超大 Bitmap 导致崩溃
- 误配原因:将用户头像的完整
Bitmap直接存入savedStateHandle["avatar"] = bitmap。 - 后果:退后台触发
onSaveInstanceState时,底层通过 Binder 传输序列化数据,触发TransactionTooLargeException崩溃! - 排障与修法:
SavedStateHandle仅用于存放轻量级主键(如userId: String或id: Long),大数据必须持久化到 SQLite 或 Disk。
与相近概念对比
| 保存机制 | 存活范围 | 能否抵御屏幕旋转 | 能否抵御进程被杀 | 推荐存储内容 |
|---|---|---|---|---|
| ViewModel 内存 | 进程存活期 | 能 | 否 | 复杂 UI 状态、网络列表数据 |
| SavedStateHandle | 进程死亡前 Bundle 存储 | 能 | 能 | 页码、查询 Key、Form 选中 ID |
| Disk 持久化 (DataStore) | 长期保存 | 能 | 能 | 用户 Token、设置项、草稿文本 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| viewmodel-savedstate-demo | ViewModel 跨旋转存活 + SavedStateHandle 进程恢复(Don't keep activities 验证) | ViewModelSavedStateDemoActivity.kt · CounterViewModel.kt |
复习检查题
在 Android 中,为什么 ViewModel 严禁持有
Activity或View的强引用?答:因为 ViewModel 的生命周期比 Activity 更加长久。当发生屏幕旋转等配置变更时,旧 Activity 实例会被销毁并重新创建,但 ViewModel 会保留在内存中。如果 ViewModel 持有了旧 Activity 或 View 的强引用,就会阻止 GC 回收旧 Activity 占用的整棵视图树,从而引发严重的内存泄漏。
ViewModel已经能抵御屏幕旋转了,为什么还需要SavedStateHandle?答:因为 ViewModel 仅仅是将数据缓存在内存的
ViewModelStore中。当应用退后台且系统内存不足时,系统会直接杀死 App 进程;此时内存中的 ViewModel 依然会被一并清空。而SavedStateHandle机制基于系统的onSaveInstanceState,会将状态通过 Binder 暂存在系统 OS 进程中,使得进程被杀重新打开时依然能无感还原页面状态。
速记
- ViewModel 避坑:生命周期跨旋转,绝不持 Context/View 强引用。
- SavedStateHandle:进程被杀能复原,轻量 ID 塞 Bundle,大图必须放 Disk。
- 共享作用域:配合 Navigation Graph 实现父子页面级 ViewModel 无缝共享。
高频面试题还原
以下为大厂面试中真实出现的问题场景还原,与上方「复习检查题」互补。
面试还原:「我们发现 App 被后台杀死后,用户重新打开列表页数据消失了,你怎么排查和修复?」
答:数据消失说明 ViewModel 内存缓存被进程回收清空。排查链路:① 确认是系统杀进程(非用户主动关闭);② 查看是否启用了
SavedStateHandle保存关键状态(如查询关键词、页码);③ 检查onSaveInstanceState写入的数据大小是否超限(Bundle 上限约 1MB)。修复:用SavedStateHandle保存轻量检索键,列表数据通过键从 Room/网络重新加载,而非把整个列表塞 Bundle。面试还原:「ViewModel 的
viewModelScope取消时机是什么?如果在协程里执行数据库写入,会不会因为 ViewModel 销毁而中断写操作?」答:
viewModelScope在ViewModel.onCleared()时取消,对应 Fragment/Activity 真正销毁(非旋转)。协程取消是协作式的,正在执行的挂起函数会在下一个挂起点抛出CancellationException。数据库写操作若是单次挂起调用(如 Room suspend DAO),有可能在cancel()后被中断;关键写操作应改用NonCancellable上下文:withContext(NonCancellable) { db.insert(item) }。面试还原:「同一个 Fragment 内不同的子模块需要共享一个 ViewModel,如何设计?」
答:使用 Activity 作用域 ViewModel:
viewModel<SharedViewModel>(ownerProducer = { requireActivity() });或使用 Navigation Graph 作用域:navGraphViewModel(R.id.my_graph)。推荐后者,范围更精确——只有同一导航子图内的 Fragment 共享,不污染整个 Activity 的生命周期,且用户导航离开该图后自动销毁。面试还原:「我们的 ViewModel 里有一个 StateFlow,但 Fragment 切到后台后 collect 还在跑,造成了不必要的计算,怎么解决?」
答:在 Fragment 中使用
repeatOnLifecycle(Lifecycle.State.STARTED)代替普通lifecycleScope.launch:kotlinviewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { /* 只在 STARTED 时运行,STOPPED 时自动暂停 */ } } }这样 Fragment 进入后台(STOPPED)时 collect 自动挂起,恢复前台时重新激活,避免后台无效计算和内存持有。
面试还原:「为什么 ViewModel 内不能直接用
LiveData.observe(viewLifecycleOwner),而推荐用repeatOnLifecycle?」答:
LiveData.observe绑定ViewLifecycleOwner,在DESTROYED时自动取消订阅,但不区分STARTED和RESUMED,后台时仍可能分发数据触发 UI 更新(旧值补发)。repeatOnLifecycle(STARTED)精确控制 collect 时机,且与 StateFlow/SharedFlow 等 Kotlin 协程生态无缝衔接,是 Google 推荐的现代写法。