☰
Jetpack ViewModel原理深析:跨过Activity重建,状态如何死里逃生
2026/10/12 17:49:55 网站建设 项目流程

我前一阵子在做某个跨平台架构改造,遇到了一个老生常谈但特别容易翻车的问题:屏幕旋转后列表数据没了。几个人在群里讨论了半天,最后把问题定位到了 Activity 重建那一刻的状态丢失上。当时的代码里还留着几年前的写法,数据全部存在 Activity 成员变量里,转个屏,界面还在,内容清空,用户心态直接崩了。后来把这块用 Android Jetpack 里的 ViewModel 组件重构了一遍,数据稳定了,内存也没啥压力。这个系列的第二篇,我就集中聊聊 ViewModel 组件的原理,不光是 API 怎么调,关键是它背后是怎么做到“死里逃生”的。

如果你已经在项目里用上了 ViewModel,但说不清楚为什么转屏后对象还在;或者你正准备从 MVP 那套脏活累活里跳出来,想搞懂 LiveData、ViewModel 这组组件到底怎么配合;又或者只是好奇一个看似普通的类,凭什么能跨过 Activity 的销毁重建继续存活——这篇文章就是冲你来的。我会把 ViewModel 的生命周期设计、ViewModelStore 的存在方式、Provider 的获取链路、Factory 的自定义场景、以及 Fragment 之间共享的底层逻辑都拆开来讲,也会附上我在实际项目中踩过的坑和排查思路。

1. ViewModel 到底解决的是什么样的痛点

先别急着上代码,我们得先把痛点钉死。ViewModel 不是 Android 官方拍脑袋想出来的一个类,它是被活生生逼出来的。

1.1 屏幕旋转那一夜,我丢掉了所有数据

Android 的应用界面是由 Activity 或 Fragment 承载的,而这两个东西有一个非常特殊的行为:当设备配置发生变化,最常见的就是屏幕旋转,系统会把当前 Activity 销毁,然后重新创建一个新的实例。这个过程不是开发者主动调用finish(),而是系统在底层完成的回收重建。

听起来好像没什么,但问题是:如果你把业务数据直接存在 Activity 的字段里,比如一个ArrayList<User>,一个 EditText 里输入到一半的草稿,一个正在进行的网络请求回调,在转屏的那一瞬,旧 Activity 实例就被废弃了,你存在旧实例上的所有内存数据全部跟着一起“陪葬”。新 Activity 是被重新 new 出来的,字段都是初始值,界面重新走onCreate,你以为的“重新加载”对用户来说就是“刚才的东西呢?”

这还没完。转屏只是一个最典型的场景,系统还会因为其他原因销毁 Activity,比如切换到后台太久被系统回收、开启多窗口模式、语言切换、字体大小调整等。只要配置变了,这套“销毁重来”的流程就会重新上演。如果每次都用 “重新请求一遍接口” 的方式补救,体验差倒也罢了,流量和性能也是白花花的成本。

1.2 为什么说 onSaveInstanceState 不够用

很多老开发的第一反应是:用onSaveInstanceState存一下不就行了?这确实是个方案,但它有一个非常致命的天花板——Bundle 的容量和类型限制。

onSaveInstanceState的本质是把数据序列化到一个 Bundle 里,在 Activity 重建时再通过onCreate的savedInstanceState参数拿回来。但 Bundle 适合存的是基本类型、字符串、序列化对象这类轻量数据。如果你要把一个大的列表、一个 Bitmap、一个复杂的业务实体塞进去,会立刻碰到两个问题:

一是跨进程传输的大小限制。系统在极端情况下需要把 Bundle 数据交给系统进程托管(比如进程被系统杀死后的恢复场景),过大的数据会导致TransactionTooLargeException,这个异常是崩溃级别的,我们在线上就遇到过几次,日志里明确提到了Binder transaction failed。崩溃的原因说白了就是:数据太大了,Binder 的传输缓冲区装不下。

二是序列化和反序列化的开销。就算数据没有大到触发异常,每次转屏都把几十 KB 甚至几 MB 的数据序列化一遍、再反序列化一遍,短时间内的 CPU 开销和 GC 压力都不小。对一个以流畅为目标的 App 来说,这不是一个优雅的做法。

ViewModel 解决的就是这个“非序列化的、需要存活的、和 UI 相关联的对象数据”问题。它不绑定 Activity 的销毁事件,不在转屏时随 Activity 一起走,而是由系统把它单独保存下来,等新的 Activity 创建后,你再通过ViewModelProvider把同一个对象取回来。对象还是那个对象,引用没变,数据自然还在。

2. ViewModel 生命周期设计的底层逻辑

ViewModel 能活过 Activity 重建,靠的不是魔法,而是一套明确的生命周期约定。

2.1 生命周期容器:ViewModelStore 是核心中的核心

先看一个关键概念:ViewModelStore。它的名字已经说明白了,这是一个用来“存储 ViewModel 实例”的容器类。它的内部实现核心就是一个HashMap<String, ViewModel>,key 是 ViewModel 的类名(默认情况下是DEFAULT_KEY+ 类名),value 就是对应的 ViewModel 实例。

我们的 Activity 在第一次创建时,会有一个关联的 ViewModelStore 实例。每当你通过ViewModelProvider去获取一个 ViewModel 时,流程是这样的:

  1. Provider 先找到当前 Activity 或 Fragment 关联的 ViewModelStore。
  2. 用类名作为 key 去HashMap里查。
  3. 如果查到了,直接返回已有的实例。
  4. 如果没查到,就通过 Factory 创建一个新的实例,放进 ViewModelStore,然后返回。

记住最后一步:“放进 ViewModelStore”。这个动作是后面所有机制的地基。因为 ViewModel 实例一旦进了 ViewModelStore,它的生杀大权就交给了 ViewModelStore 所属的生命周期容器,而不是某个具体的 Activity 实例。

所以,当屏幕旋转时,旧 Activity 被销毁,但系统会在销毁前把 ViewModelStore 这个容器“捞”出来,传给新 Activity 使用。新 Activity 创建后,再拿这个 ViewModelStore 去取 ViewModel,因为 HashMap 里有现成的实例,所以直接就取到了同一个对象。

这里有个细节很多人没注意:getViewModelStore()这个方法会检查当前 Activity 的NonConfigurationInstances对象里有没有遗留的 ViewModelStore。如果有,就直接复用;如果没有,就 new 一个。整个思路跟老牌游戏里“存档/读档”非常像:旧实例销毁前存档,新实例启动后读档。

2.2 onRetainNonConfigurationInstance 与数据中转站

接下来是第二个关键机制:onRetainNonConfigurationInstance()。这个方法在 Activity 因为配置变更而即将被销毁时被系统调用,它的返回值会被系统保存下来,并在新的 Activity 出来后,通过getLastNonConfigurationInstance()方法取回。

ViewModelStore 就是靠这个通道进行“跨实例交接”的。至于 Fragment,情况类似但路径不同。Fragment 在重建时,旧 Fragment 实例上的 ViewModelStore 会被系统转移到新的 Fragment 实例上,所以 Fragment 里的 ViewModel 同样能在重建后活下来。

那 Activity 进程被杀的情况呢?这里就要谨慎了。如果是配置变更导致的销毁,ViewModelStore 能保住;但如果是进程被系统杀掉了,那 Activity 和 ViewModelStore 都保不住,因为内存都没了。这种场景下,真正的兜底方案是SavedStateHandle,我会在后面的排查实录里专门讲它。

我刚开始学这块的时候也有一个困惑:ViewModelStore 既然存的是 HashMap,那 Activity 销毁时 ViewModelStore 会不会也跟着销毁?答案是不会,但前提是“配置变更导致的销毁”。系统内部对配置变更这条路径做了特殊处理,把 ViewModelStore 的幸存通道单独摘出来了。如果是正常finish()退出,那 ViewModelStore 就会在 Activity 销毁后调用所有 ViewModel 的onCleared(),然后被回收。

3. ViewModelProvider 是怎么把对象“递”给你的

3.1 ViewModelProvider 的获取流程

我们在代码里写new ViewModelProvider(this).get(XxxViewModel.class),看似只有一行,背后的分工其实很清楚。

第一步,构造 ViewModelProvider 时,它会接收四个关键参数:ViewModelStore、Factory、一个默认的 key,还有创建参数 Creator。如果你只传了一个this,那么它会在内部做这些事:

  • 如果this是ViewModelStoreOwner接口的实现类(Activity 和 Fragment 都实现了这个接口),就调用getViewModelStore()拿到 ViewModelStore。
  • 如果this实现了HasDefaultViewModelProviderFactory,就拿开发者自定义的默认 Factory;否则用SavedStateViewModelFactory作为默认工厂。
  • 在get(Class)方法内部,会先通过 key 去 ViewModelStore 里取。key 的默认生成规则是DEFAULT_KEY + ":" + canonicalName,但如果是自定义 Factory 生成的 ViewModel 且通过get(String, Class)传入自定义 key,就会用你传的 key 查找。

第二步,如果 ViewModelStore 里没有这个 key,就调用 Factory 的create(Class)或create(Class, CreationExtras)方法生成一个新实例,然后调用viewModelStore.put(key, viewModel)存进去。

第三部,返回实例。整个过程既有“缓存命中”的快速路径,也有“新建并缓存”的慢路径。这也是为什么同一个 Activity 里多次调用get(XxxViewModel.class)拿到的永远是同一个对象——因为它第一次创建后就被放进 HashMap 了,后续的get只是查表而已。

这里有一个让人错误的直觉:ViewModelProvider 不是每次获取时都 new 一个新对象。它的核心设计是“先查后建”,查到了就复用,查不到才创建。这也是它能保证 UI 和 ViewModel 状态一致的关键。

3.2 Factory 的作用与自定义场景

Factory 是 ViewModelProvider 里的“工厂角色”,它负责回答一个问题:当 ViewModelStore 里没有当前要找的 ViewModel 时,你该怎么创建它?

默认的SavedStateViewModelFactory已经能处理大多数情况:如果你的 ViewModel 构造函数里只有一个参数或者没有参数,它会尝试反射创建实例;如果构造函数里有SavedStateHandle参数,它会自动为你注入一个。换句话说,默认 Factory 是官方为了让你少写样板代码而设计的。

但在两类场景下,你必须重写 Factory:

场景一:你的 ViewModel 构造函数里有业务参数。比如需要传入一个Repository,或者传入某个用户 ID。这时候默认 Factory 帮不了你,因为它是靠反射调用构造函数,遇到非默认类型的参数就直接挂掉。你需要自己写一个 Factory,在里面拿到参数并创建 ViewModel。

场景二:你需要在 ViewModel 创建前做一些前置操作。比如打日志、统计、加载缓存等。Factory 的create方法会在创建实例时被调用,你可以在这个方法里做这些事,再把创建好的 ViewModel 返回。

自定义 Factory 的标准写法,以常见的AbstractSavedStateViewModelFactory或者ViewModelProvider.Factory接口为例,实现create方法即可。需要注意的是,新版 AndroidX 里更推荐用CreationExtras来传递参数,比如SavedStateHandle的初始化数据可以通过createSavedStateHandle获取,这样比手动塞 Bundle 更优雅。

我个人的建议是:不要把 Factory 写得太重。它本质上是提供创建依赖的工具,如果每个 ViewModel 都要写一个 Factory,代码量会变得很臃肿。优先用默认 Factory + 构造函数注入的模式,或者用官方推荐的SavedStateHandle方案;只有遇到复杂依赖时,才考虑为特定 ViewModel 单独定制 Factory。

4. ViewModel 存活范围与跨组件共享

4.1 Activity 与 Fragment 的共享逻辑

ViewModel 有一个非常实用的特性:Fragment 可以直接获取它所在 Activity 的 ViewModel。这个特性和 Fragment 的宿主切换有紧密关系。

每当我们从 Fragment 里调用new ViewModelProvider(requireActivity()).get(XxxViewModel.class)时,Provider 拿到的 ViewModelStore 是宿主 Activity 的,而不是 Fragment 自己的。也就是说,同一个 Activity 下的多个 Fragment,只要共用这个 Activity 的 ViewModelStore,就能拿到同一个 ViewModel 实例。

这么做的好处很明显:Fragment A 往 ViewModel 里写了一条数据,Fragment B 不用通过接口回调,也不用传参,直接读同一个 ViewModel 就能拿到最新状态。这对“列表页 → 详情页”、“主内容区 → 侧边栏”这类交互非常合适。

但要注意一个边界:Fragment 使用 Activity 的 ViewModelStore,意味着这个 ViewModel 的生命周期范围是 Activity 级别的。它不会因为某个 Fragment 销毁而onCleared,只会跟着宿主 Activity 走。如果你把一些本应属于 Fragment 级别的状态硬塞进 Activity 级别的 ViewModel,那么 Fragment 退出后这些数据依然存活,有可能会造成不必要的内存占用和逻辑混乱。

4.2 Fragment 之间如何拿到同一个 ViewModel

具体到代码层面,要让两个 Fragment 共享同一个 ViewModel,通常有两种姿势。

第一种,直接共享 Activity 作用域:两个 Fragment 都使用requireActivity()作为 owner 去拿同一个 class 的 ViewModel。这种情况下,它们拿到的是同一个实例,因为它们在同一个 Activity 的 ViewModelStore 里查找同一个 key。

第二种,父子 Fragment 共享:如果 Fragment 之间有父子关系,子 Fragment 可以通过requireParentFragment()作为 owner,去获取父 Fragment 的 ViewModel。这样可以实现一个 Fragment 容器内部的状态共享。

我个人更推荐第一种,因为它的语义最简单:凡是这个 Activity 页面范围内的组件,都可以共享一个 ViewModel。这种模式下,UI 状态集中存放在 ViewModel 里,Fragment 只负责渲染和事件转发,数据的流向单一且可控。

还有一种很容易踩坑的情况:你拿 Activity 作用域的 ViewModel,想在新打开的 Fragment 里复用旧数据。假设 Fragment A 带着一个id字段往 ViewModel 里写了一次数据,Fragment B 读的是同一个 ViewModel,如果 ViewModel 里没有清理机制,B 就会读到 A 留下的旧值。解决方案是:要么通过SavedStateHandle的 key 区分,要么在 Fragment 切换时手动重置部分状态,要么把 id 这类“每次进入都可能不同”的数据设计成参数而不是 ViewModel 里的共享状态。

4.3 作用域并不是越大越好

很多初学者看到 ViewModel 的生命周期长,就想把所有数据都塞到 Activity 级的 ViewModel 里,结果 Activity 没退出,一大坨数据就一直躺在内存里。不要这样用。

ViewModel 适合存放“和界面状态强相关、在配置变更时需要保留”的数据。但如果是全 App 级别的全局状态,应该考虑用专门的数据层方案,或者组件化后的共享仓库,而不是把 Activity 级 ViewModel 当全局存储用。否则,你的 Activity 级别 ViewModel 一旦引用了 Application 里的大对象,而不是轻量引用,就很容易在页面栈堆积时放大内存压力。

作用域的选择,本质上是“数据存活范围”的选择。一句话总结:你的数据需要活多久,就把它放到匹配的生命周期容器里。如果你要的是“整个 App 运行期间都存活”,直接放进 ViewModel 是不合适的,要么放到 Application 级的数据仓库,要么用单独的依赖注入容器管理。

5. onCleared 与资源清理的门道

5.1 onCleared 何时触发

ViewModel 里有一个方法很像 Activity 的onDestroy,叫做onCleared()。它是在 ViewModelStore 清除自己的时候被调用的。什么时候 ViewModelStore 会清除自己?一般有两个场景:

场景一:宿主 Activity 正常退出(finish()),系统在销毁流程里会遍历 ViewModelStore 的所有 value,逐个调用onCleared(),然后清空 HashMap。

场景二:宿主 Fragment 正常退出或被彻底移除。同样地,它的 ViewModelStore 会清空并触发onCleared()。

但一定要注意,配置变更导致的 Activity 重建不会触发onCleared()。因为旧 Activity 销毁时,ViewModelStore 被系统捞走并转交给了新 Activity,ViewModel 根本没死,自然也不会触发清理回调。这也是 ViewModel 和普通 View 生命周期最大的不同:ViewModel 的销毁时机不是“界面销毁”,而是“宿主完全终结”。

我在项目里会在onCleared()里做这些事:取消协程任务、关闭文件流、释放大对象引用。尤其是协程,如果 ViewModel 里启动了viewModelScope.launch任务,当onCleared()被调用时,作用域会被自动取消。不手动挂起的话,后台任务就会在宿主销毁后继续跑,容易引发状态更新到已销毁 UI 的问题。

5.2 与 LiveData、Flow 的关系

ViewModel 本身不负责数据流动,它更像是一个“容器”。数据怎么从 ViewModel 出来,有两种主流方式:LiveData 或者 Kotlin Flow。

LiveData 是 AndroidX 里的一个可观察数据持有类,它有一个特性:只有处于STARTED或RESUMED状态的生命周期所有者才会收到数据更新。所以在 ViewModel 里暴露 LiveData 给 UI 层,UI 层在销毁时自动解绑,不会发生内存泄漏。

但 LiveData 有个明显的问题:它的转换操作符少,组合能力弱。如果你要做复杂的流式处理,比如 map、flatMapLatest、debounce,LiveData 会变得很难看。这种情况下,我建议 ViewModel 向外暴露 Flow(比如StateFlow),UI 层通过repeatOnLifecycle来收集,既安全又灵活。

Flow + ViewModel 的方案里,viewModelScope是一个自带生命周期的协程作用域,它会在onCleared()时自动取消,所以你在 ViewModel 里发起的网络请求、数据库读写,都不会成为在宿主销毁后的“野任务”。这一点比直接在 Activity 里用lifecycleScope更稳妥,因为你不需要担心 ViewModel 和 UI 的生命周期不匹配。

还有一个常见的坑:有些同学喜欢在 ViewModel 里持有 Activity 的 Context。这样做非常危险,因为 ViewModel 的生命周期比 Activity 长,如果它内部持有了 Activity 引用,Activity 被销毁后依然无法被 GC 回收,就会造成内存泄漏。正确的做法是,ViewModel 里如果需要上下文信息,应该用AndroidViewModel(它持有的是 Application,Application 的生命周期是全局的),或者通过SavedStateHandle去拿Bundle里的数据,而不是直接传 Activity。

6. 常见问题与排查技巧实录

说了这么多原理,最后聊聊我在实际开发中遇到过的几个典型问题,以及排查思路。

6.1 从 ViewModel 拿不到数据,问题出在 owner 不一致

有一次同事跑来说,Fragment A 里往 ViewModel 写的数据,Fragment B 里死活读不到。我第一反应就是请他把ViewModelProvider(requireActivity())改成ViewModelProvider(this)试试。果然,问题就出在这里。

ViewModelProvider(this)里的this指的是 Fragment 自身,系统会去找 Fragment 自己的 ViewModelStore。两个 Fragment 各有各的 ViewModelStore,所以即使 ViewModel 类名一样,它们拿到的也是两个不同的实例。要想共享,必须统一 owner,也就是都使用同一个 Activity 作为ViewModelStoreOwner。

排查这类问题很简单:打印 ViewModel 实例的 hashCode 或者toString(),如果两个 Fragment 拿到的 hashCode 不一样,说明 owner 不一致或者 key 不一致,直接检查取用逻辑即可。

6.2 OOM 与内存泄漏,经常出在持有了不该持有的引用

ViewModel 里的内存泄漏,绝大多数是因为持有了 Activity / View / Context 引用。我见过一个典型案例:ViewModel 里为了更新一个自定义 View,把 View 的引用传进去了。结果页面关闭后 ViewModel 还活着,View 也被 ViewModel 强引用着,Activity 无法被回收,内存一次比一次涨。

解决思路有两个:一是用LiveData/Flow做单向数据流,UI 层观察数据自己更新,ViewModel 不持有 View;二是如果真的需要某种原生入口,可以考虑用事件总线或者生命周期感知组件,但要非常克制。

如果需要 Application 级别的上下文,可以直接继承AndroidViewModel,它的构造函数里会传入 Application 引用。注意 Application 是全局单例,生命周期比 ViewModel 长,所以不会反过来把 ViewModel 拖死。

6.3 进程被杀与 SavedStateHandle

很多开发会误以为 ViewModel 万能:就算进程被系统杀了,只要 ViewModel 在,数据就在。实际上不是这样。

当进程级别被杀时(比如用户从最近任务列表划掉 App,或者系统在极度内存压力下杀进程),整个 App 的内存都会被系统回收,ViewModelStore 也不复存在。此时 Activity 重建时能拿到的只有系统可能保存下来的savedInstanceStateBundle,也就是SavedStateHandle里存的那份数据。

所以我的建议是:对于必须恢复的关键状态(比如当前编辑框的内容、当前页面的滚动位置标记),不要只放在 ViewModel 的普通字段里,要通过SavedStateHandle的 key 写入。SavedStateHandle 底层会把数据委托给 Bundle,系统在适当的时候把它保存下来,进程被杀后也能恢复。

使用方式也很简单:在 ViewModel 的构造函数里接收一个SavedStateHandle,然后用getLiveData("key")或者getStateFlow读取,用set("key", value)写入。这样即使进程被杀了,下次进入时这些值仍然在。

6.4 使用 viewModelScope 时的取消时机

如果你在 ViewModel 里用了viewModelScope.launch启动任务,注意它的取消时机是onCleared()。如果你的 ViewModel 是在配置变更时被转移而不是销毁,那onCleared()不会被调用,任务还会继续执行。这本身是符合预期的,但如果你在任务里写死了某些 UI 逻辑(比如获取到数据后直接更新某个 View),在 Activity 重建期间,旧 View 已经销毁,更新逻辑会碰上一个脱锚的引用。

解决方案:不要在 ViewModel 里操作 View,ViewModel 只负责把状态暴露给 UI 层,UI 层自己决定怎么响应该状态。这既符合单向数据流,也能避免因生命周期不匹配导致的空指针。

我处理过一个案例:某个页面加载进度是放在 ViewModel 的MutableLiveData<Boolean>里,在 Activity 里 collect 这个状态来显隐加载框。转屏之后 Activity 重新创建,因为 ViewModel 没死、状态还在,新 Activity 一注册观察者就能立刻收到 isLoading 的状态,界面进度条直接从“加载中”恢复。这种设计的好处就是:UI 状态和 UI 实体解耦,界面重建后,状态不丢,体验自然连贯。

做这一堆复盘,我个人最深的体会是:ViewModel 这套机制的核心价值不是“少写几行代码”,而是把“界面的临时状态”和“界面的载体”做了彻底分离。它让界面在无常的销毁重建中依然能稳定恢复状态,也让开发者在设计数据流时有了一个清晰的锚点。哪怕你不是用 Jetpack 全家桶,理解这套生命周期设计思路,对以后处理任何平台上的“视图重建”问题都会有帮助。

最后再提一个压箱底的小技巧:如果你在自定义 Factory 里创建 ViewModel,记得在ViewModelProvider.get前先确认 ViewModelStore 没有该 key 的旧实例。别每次进页面都 new,那和直接 new 一个数据类没有区别,生命周期管理也全废了。正确的姿势永远是让 Provider 先去查表,查不到再创建。

踩过几次坑之后,我现在写页面数据流的固定套路已经变成了:Activity 持有 Activity 级 ViewModel,Fragment 按需引用;基础数据类型全部走 SavedStateHandle;复杂业务数据进 ViewModelStore;任何耗时任务必须挂在 viewModelScope 下。一套流程下来,几乎没再遇到过转屏丢数据这种低级事故。ViewModel 的原理说复杂也复杂,说简单也简单,无非是搞明白它被存到了哪里、什么时候被创建、什么时候被清理,把这几个关键节点搞清楚了,你在任何项目里都能做到心里有数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询