Jetpack 这个系列第一篇出来后,不少做应用重构的朋友在后台问我:ViewModel 已经用得挺熟了,为什么还要去啃它的源码?我的回答是,如果你只是停留在by viewModels()的用法层面,你是写不出既抗造又好维护的页面的。这次把 ViewModel 从创建到销毁整条链路讲透,你就明白它为什么叫“组件”而不是“工具类”。做一个稍微复杂的电商详情页你会发现,旋转屏幕后填写了一半的收货地址没了,滑动到第三步的图片预览又跳回第一步,网络请求还会莫名其妙重复发好几遍。ViewModel 就是官方针对这些场景给出的一套组件级方案,但在项目里真正决定它能不能发挥价值的,往往是你对它在配置变更、进程重建、生命周期回调这些关键节点上到底怎么运作的理解。这篇文章我会沿着一条主线走:它为了解决什么问题而存在、内部三件套是怎么协作的、源码链路里旋转屏幕时到底发生了什么、以及你能直接抄进项目里的姿势和坑。
1. 项目整体认知:ViewModel到底解决什么问题
1.1 没有ViewModel的日子:手动保存状态的痛苦经历
先说一个我早期的真实体验。当时做一个信息录入类的页面,不复杂,就是几个输入框加一个图片选择列表。测试时旋转了下屏幕,结果用户刚填好的表单内容全没了,选中的图片列表也回到了初始状态。那时候第一反应是赶紧把内容写到onSaveInstanceState里,这个函数确实能把轻量数据放进 Bundle,但等你把每个输入框、每个选中项、每个列表状态都手动塞进去,代码已经变得非常恶心,而且 Bundle 本身还有大小和类型限制,存图片路径、缓存列表、分页信息都相当勉强。
更麻烦的是请求问题。列表页旋转后 Activity 销毁重建,onCreate又跑了一遍,原有的网络请求也被触发一次。如果你没有做结果缓存,用户看到的不是“刷新”,而是“加载中”的闪烁,严重的甚至会因为回调里的 View 已经失效而直接崩掉。当时也想过用单例来做临时数据中转,但单例一旦 Activity 退出后没及时清理,内存泄漏就成了定时炸弹。
ViewModel 的价值恰恰就在这里:它把页面里那些“只跟当前界面相关、不该随旋转丢失、又不值得持久化到数据库”的状态,从 Activity 中抽离到一个独立对象里,由官方机制保证它在配置变更期间存活。这相当于给你的页面准备了一个“出趟门回来东西还在原位”的抽屉,而不是每次出门前匆匆忙忙把所有家当往兜里塞。
1.2 配置变更与进程被杀的区别:ViewModel能救哪个
很多人有个认知误区,觉得 ViewModel 是“持久化数据”的。这个必须纠正:ViewModel 本质上是内存对象,它解决的是配置变更(configuration change)带来的重建问题,不是进程被杀后的数据恢复问题。
配置变更指旋转屏幕、切换系统语言、深色模式切换这类事件。这些场景下 Activity 会被销毁重建,但整个应用进程还活着,堆内存还在,系统也允许你在旧 Activity 销毁时把一些对象“托管”出去,重建后再交回来。ViewModel 就是利用这个机制活下来的。
进程被杀是另一回事。用户把应用切到后台,系统内存紧张时直接回收掉整个进程,清理掉所有堆内存,Activity、ViewModel 全部烟消云散。当用户回到应用时,系统会尝试恢复 Activity,但你的 ViewModel 对象不会自动回来。要恢复这类场景,你需要SavedStateHandle或onSaveInstanceState配合,把轻量状态写进 Bundle,由系统在进程重建时回传。所以正确的分工是:ViewModel 负责解决旋转等配置变更时的状态保持,SavedStateHandle 负责解决进程被杀后的临时状态恢复,数据库和文件负责真正持久化。搞混这三层,后面排查问题时会白费很多功夫。
2. 原理拆解:ViewModel是怎么“活”过配置变更的
2.1 核心三件套:ViewModelStoreOwner、ViewModelStore、ViewModelProvider
ViewModel 能在配置变更后存活,靠的是三个核心接口和类之间的协作:ViewModelStoreOwner、ViewModelStore、ViewModelProvider。
ViewModelStoreOwner是一个接口,定义了getViewModelStore()方法。Activity 和 Fragment 都实现了它,意味着它们是 ViewModel 的“宿主容器”。没有这个宿主,ViewModel 就没有存身之所。
ViewModelStore是真正存放 ViewModel 的容器,内部结构就是一个HashMap<String, ViewModel>,按 key 存、按 key 取。它有几个关键方法:get(key)、put(key, viewModel)、clear()。整个 store 的生命周期由一个宿主负责维护。
ViewModelProvider是使用入口,负责帮你从 store 里拿到目标 ViewModel。它的逻辑很直白:先从 store 里通过 key 找,找到且类型匹配就直接返回;找不到就用内部的 Factory 创建新实例,再放进 store。
Factory 则是创建 ViewModel 的工厂接口,只有一个方法create(Class<T> modelClass): T。默认实现DefaultViewModelProviderFactory会尝试通过无参构造newInstance()创建实例。如果你需要给 ViewModel 传参,就要自己提供 Factory。
这里值得注意的设计意图是:Provider 依赖的是“这个 Activity 对应哪个 ViewModelStore”,而不是“Activity 的实例是谁”。所以哪怕 Activity 换了个新实例,只要它从系统手里拿回了同一个 ViewModelStore,就能从里面取出同一个 ViewModel。这就像一个储物柜:锁和钥匙换了,但柜子还是原来那个柜子,里面的东西自然还在。
2.2 源码链路走查:旋转屏幕时究竟发生了什么
把上面这些类串起来,走一遍旋转屏幕的完整链路。
第一步,屏幕旋转触发配置变更,系统决定销毁旧的 Activity,创建新的 Activity。旧 Activity 会在销毁流程中走到onRetainNonConfigurationInstance()这个方法。ComponentActivity已经重写了它,在处理时会构造一个内部对象NonConfigurationInstances,把当前 Activity 持有的ViewModelStore实例装进去,交给系统临时保留。这个对象不会随 Activity 销毁而消失,而是由系统放在“非配置实例”通道里等着新 Activity 来取。
第二步,新 Activity 创建时,会在onCreate前调用的getLastNonConfigurationInstance()方法中取回之前保存的NonConfigurationInstances,再从里面恢复出那个一模一样、还是原来内存地址的ViewModelStore实例,重新赋给新 Activity。
第三步,代码里调用ViewModelProvider.get(ViewModel::class.java)时,Provider 从新 Activity 拿到的 store 里查询目标 key,发现旧实例还在,于是直接返回。你看到的现象就是“Activity 变了,ViewModel 没变”。
看一段简化版的ViewModelProvider.get()内部逻辑,就能感受到这个流程的简单和优雅:
public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) { // 1. 先从 store 里查,命中直接返回 ViewModel viewModel = mViewModelStore.get(key); if (modelClass.isInstance(viewModel)) { return (T) viewModel; } // 2. 没找到就通过 Factory 创建,放回 store viewModel = mFactory.create(modelClass); mViewModelStore.put(key, viewModel); return (T) viewModel; }整个过程最核心的一句话是:旋转时被保留下来的不是 Activity 实例,而是 Activity 内部那棵 ViewModel 存储树。旧 Activity 销毁前把树托管给系统,新 Activity 创建后把同一棵树接回来。这个机制在 Fragment 的场景里同样成立,只是托管链路的载体换成 FragmentManager 的NonConfig机制。
2.3 onCleared()的精确触发时机与边界条件
ViewModel 的回调方法只有一个onCleared(),它是资源释放和任务取消的唯一时机。很多人都知道它会在页面退出时调用,但具体什么时候不调用,往往说不清。
关键就在 ActivityonDestroy()里的这段逻辑:
override fun onDestroy() { // 判断当前销毁是不是因为配置变更 if (!isChangingConfigurations) { viewModelStore.clear() } }当用户按返回键退出、或者调用finish()时,isChangingConfigurations为 false,Activity 销毁后才会调用viewModelStore.clear(),进而触发每个 ViewModel 的onCleared()。
当只是旋转屏幕或切换暗黑模式时,isChangingConfigurations为 true,即使 Activity 的onDestroy被执行了,store 也不会被 clear,ViewModel 也就不会收到onCleared()。这个判断从根上保证了 ViewModel 在配置变更期间不会“假死”或提前释放。
Fragment 场景还有一个容易踩的点:Fragment 被替换时,如果还在回退栈里,它的 ViewModelStore 不会 clear;只有 Fragment 真正被移除、且不再存在于回退栈时,才会触发清理。所以不要指望 Fragment 从回退栈弹出来时onCleared()一定马上执行。这属于正常机制,不是 Bug。
3. 生命周期感知的完整闭环:ViewModel如何与Lifecycle协同
3.1 ViewModel决不会给你View引用,这是一条铁律
ViewModel 名字里带“View”,但它跟 View 没有任何直接关联,这是一个非常容易误用的点。ViewModel 的存续期比 Activity 和 Fragment 长,如果在 ViewModel 里持有 Activity、View 或 LifecycleOwner 的引用,页面退出但 ViewModel 还没清理时,这些引用会阻止 GC 回收整个 UI 树,造成内存泄漏。LeakCanary 上报的很大一类问题就是这么来的。
从设计意图上看,ViewModel 是“面向 UI 状态”的,不是“面向 UI 控件”的。它只存储和加工数据:列表数据、加载状态、用户输入、临时缓存。UI 层应该观察 ViewModel 暴露出的数据,然后自己去渲染。LiveData、StateFlow、Flow 这些响应式组件就是为了这个“数据驱动 UI”的链路存在的。
有个直观的类比:ViewModel 是门店后厨,Activity 是前台服务员。后厨只管把菜做好放到出餐口,前台自己去取、去端给顾客。如果后厨派人跑到前厅站着等服务员点完菜再炒,整个门店就乱套了,生命周期稍长一点,这个人还会赖在店里不走。
3.2 对比一下:ViewModel、SavedInstanceState、持久化的分工
搞清楚 ViewModel 的位置,最好的办法是把它和另外两层存储机制放在一张表里对比。
| 维度 | ViewModel | SavedStateHandle / Bundle | 数据库 / 文件 |
|---|---|---|---|
| 本质载体 | 内存对象 | Bundle 序列化数据 | 持久化存储 |
| 旋转屏幕时 | 存活,继续使用 | 系统自动保留 | 不受影响 |
| 进程被回收时 | 丢失 | 可恢复轻量数据 | 不受影响 |
| 容量限制 | 内存足够即行 | 有大小限制,不适合大对象 | 无上限 |
| 典型用途 | 页面数据、列表缓存、UI 状态 | 表单临时草稿、筛选条件、滚动位置 | 用户信息、业务数据 |
这个表格写出来,你会发现页面状态的解决方案从来不是“二选一”,而是三层叠加。ViewModel 管内存态,SavedStateHandle 管进程重建恢复,数据库管持久业务数据。每一层的工作边界都别越界,代码的稳定性就会好很多。我曾经见过一个项目执着于把所有状态都塞进 ViewModel,结果进程被杀后用户返回页面一片空白,那就是没有第二层兜底。
4. 实操经验:ViewModel的正确使用姿势与进阶模式
4.1 构造参数与工厂模式:什么时候写ViewModelProvider.Factory
想用好 ViewModel,第一个绕不开的问题就是构造参数。默认情况下,ViewModelProvider 通过无参构造函数创建 ViewModel,所以下面的代码能直接跑通:
class SimpleViewModel : ViewModel() { val title = MutableStateFlow("") }可一旦你的 ViewModel 需要依赖 Repository、数据源或者某个业务参数,比如“详情页的 ArticleId”,你就会遇到经典的Cannot create an instance of class ViewModel报错。解决办法就是自定义 Factory。现在用 Kotlin 写,已经不需要手写一长串模板了:
class ArticleViewModel( private val articleId: Long, private val repo: ArticleRepository ) : ViewModel() { companion object { fun factory(articleId: Long): ViewModelProvider.Factory { return viewModelFactory { initializer { ArticleViewModel(articleId, ArticleRepository()) } } } } }Activity 里搭配by viewModels委托使用:
private val articleVM: ArticleViewModel by viewModels { ArticleViewModel.factory(articleId = intent.getLongExtra("id", 0L)) }注意viewModelFactory和initializer这两个 API 来自lifecycle-viewmodel-ktx,需要单独引入依赖。使用 Factory 的模式,比把参数塞到SavedStateHandle或者全局变量里都要清晰。构造器里直接声明依赖,测试时就能传入假的 Repository,这也是推荐依赖注入的起点。如果你还在用AndroidViewModel拿 Application,那属于历史遗留方案,能用但不够优雅,建议尽早迁到构造器注入。
4.2 跨Fragment共享数据的官方正确思路
Activity 内多个 Fragment 想共享数据,常见做法有 EventBus、共享单例,其实官方早就给出了更安全的方式:使用同一宿主 Activity 的 ViewModelStore。在 Fragment 里不要用by viewModels(),它会把 ViewModel 存到 Fragment 自己的 store;要用by activityViewModels():
class SharedViewModel : ViewModel() { private val _refreshState = MutableStateFlow<RefreshState>(RefreshState.Idle) val refreshState: StateFlow<RefreshState> = _refreshState.asStateFlow() fun onRefresh() { ... } }// FragmentA 和 FragmentB 都写这一行 private val sharedVM: SharedViewModel by activityViewModels()这里面的原理不神秘:Fragment 调用activityViewModels()时,使用的是宿主 Activity 的ViewModelStore,所以几个 Fragment 取到的都是同一个实例。相比 EventBus,它没有“发送了没人收”的问题,所有数据都天然跟着宿主 Activity 走,宿主退出时自动清理,不会出现全局单例残留。
在实际项目里要特别注意一点:不要直接把MutableStateFlow或MutableLiveData整个暴露给其他 Fragment。更好的做法是用私有可变版本加公有只读版本,再配合状态类做单向数据流。否则业务复杂以后,你会发现自己都不知道谁改了数据。
4.3 与协程、Flow、LiveData组合的推荐写法
ViewModel 和协程的搭配,官方已经默认集成好了。只要引入lifecycle-viewmodel-ktx,ViewModel 内部就会多一个viewModelScope(),它默认跑在Dispatchers.Main.immediate,生命周期跟随 ViewModel,onCleared()时自动取消所有协程。
这意味着你在 ViewModel 里发起的网络请求,用户退出页面时不会被遗留到后台继续空转,也不会因为回调里还拿着 View,导致崩溃或泄漏。看一个比较推荐的写法:
class HomeViewModel : ViewModel() { private val repo = HomeRepository() val uiState: StateFlow<HomeUiState> = repo.fetchHomeData() .map { HomeUiState.Success(it) } .catch { emit(HomeUiState.Error(it)) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = HomeUiState.Loading ) }关于 LiveData 和 Flow,我的建议是状态类数据优先用StateFlow,一次性事件则用SharedFlow或回调接口。StateFlow天然携带最新值,访问value就能拿到当前状态,还支持丰富的操作符,跟协程衔接顺畅。不过要提醒一句,在 UI 层用repeatOnLifecycle包住 collect,防止页面切到后台后依然持续接收数据流。这块不小心,很容易成为性能上“看不见的敌人”。
5. 常见问题与排查记录:我踩过的坑
下面整理一份我在实际项目中踩过、也帮别人排查过的问题清单,每一条都不是冷僻场景,而是很容易在日常开发中反复出现。
| 问题 | 现象 | 原因与处理 |
|---|---|---|
| ViewModel 持有 View 引用 | LeakCanary 报告页面销毁后仍有泄漏 | ViewModel 生命周期比 View 长,不能在 ViewModel 里保存 Activity / View / Fragment 引用,改用数据回调驱动 UI |
| 页面旋转后再次发起网络请求 | 明显多发了一倍请求,页面闪烁一次 | 请求写在了onCreate里且 ViewModel 数据是空的;应该在 ViewModel init 中发请求,并通过状态缓存判断是否已有数据 |
| Fragment 共享数据时取到不同实例 | 两个 Fragment 状态不同步 | 一个用了viewModels(),另一个用了activityViewModels();两者作用域不同,必须全部指向同一个宿主,建议全局统一 |
| 进程被回收后页面状态丢失 | 用户切后台一段时间再回来,页面数据空白 | 只有 ViewModel 没有配SavedStateHandle;临时状态需要走进程重建恢复通道 |
| 无限刷新或重复加载 | 数据流被多个观察者重复订阅 | stateIn策略没配好,或 UI 层没在repeatOnLifecycle中收集;统一数据入口并限制订阅生命周期 |
| 在 Fragment 中依赖 activityViewModels 但页面销毁不调 onCleared | 关闭页面后数据仍占用资源 | 若宿主 Activity 仍在存活,Fragment 显式移除后才触发清理;不要把页面级的资源放在共享 ViewModel 里无限累积 |
排查时的思路也有规律:先确认“ViewModel 实例是不是同一个”,再确认“是哪个 ViewModelStore 在管”,最后才去怀疑“是配置变更的问题还是进程被回收的问题”。三个问题定位清楚,这类错误基本上十有八九能找到根因。
另一个容易忽视的是SavedStateHandle的添加时机。很多人只会在遇到进程恢复问题时才想起来加,但临时状态保存这件事最好在 ViewModel 设计的初始阶段就放进方案里。比如一个订单筛选页,筛选条件、搜索关键词、滚动位置都建议通过SavedStateHandle保存。这样用户切后台再回来,体验是完整的,不用额外请求一次数据。
6. 扩展认识:从原理出发看懂ViewModel的开发演进
6.1 SavedStateHandle:让配置变更与进程回收都不怕
前文反复提到SavedStateHandle,这里展开细讲一下。它是 ViewModel 构造器中的一个特殊参数,由系统在创建 ViewModel 时自动提供。日常写法极其简洁:
class CountViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { val count = savedStateHandle.getStateFlow("count", 0) fun increase() { savedStateHandle["count"] = (savedStateHandle["count"] as? Int ?: 0) + 1 } }内部机制上,SavedStateHandle包装了一个 Bundle。当 Activity 因配置变更或进程回收重建时,系统会从 Bundle 中恢复数据,并把对应的SavedStateHandle传给新 ViewModel。它比onSaveInstanceState的手写版本更友好,还支持getStateFlow、getLiveData这类响应式 API。
要注意SavedStateHandle不适合存大对象和二进制数据。它最终走的是 Bundle 通道,超过一定体积会触发TransactionTooLargeException。列表数据还是放 ViewModel 内存里,SavedStateHandle 只处理轻量状态。
6.2 从Factory演进看DI框架的介入
理解 Factory 原理之后,你会更容易理解 Hilt 这类依赖注入框架在 ViewModel 上做了什么。Hilt 通过@HiltViewModel注解自动生成 ViewModel 的 Factory,并把SavedStateHandle和业务依赖一起注入到构造器里:
@HiltViewModel class DashboardViewModel @Inject constructor( private val repo: DashboardRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel()这样项目里就不再需要手写companion object { factory() },依赖关系一目了然,测试时也方便替换 mock。但如果你没搞懂默认 Factory 和无参构造的关系,遇到 Hilt 集成报错时依然会一头雾水。就见过的案例来说,很多人看到Cannot create an instance of class ViewModel就只知道上网搜,实际只要沿着 Factory 这条线,马上就能定位到“哪个依赖没注入”和“哪个构造器没有被正确理解”。原理在手,解决问题的速度会快很多。
我个人在实际项目里的体会是,ViewModel 真正厉害的地方不是某个 API,而是它逼着你把“界面”和“状态”拆开。早期写页面时,Activity 里塞满了数据请求、刷新逻辑、状态判断,后期加一个需求要动好几处。用 ViewModel 之后,我习惯先把页面会出现的所有状态写成一个不可变状态模型,再围绕它组织数据流和交互逻辑,最后才去写 UI。这个思考顺序一旦建立,重构页面就像重新排列组合积木,而不是推倒重砌墙。如果你打算把现有项目迁移到更清晰的架构上,建议从最常动的那个页面开始试,把状态抽进 ViewModel,再逐步推广,效果会比想象中明显。