☰
Android ViewModel 生命周期感知与配置变更存活实战指南
2026/10/15 14:21:22 网站建设 项目流程

1. 从一次屏幕旋转引发的崩溃说起

很多人第一次接触 ViewModel 是在一个很具体的场景里:辛辛苦苦写了个页面,用户把手机横过来,界面直接回到初始状态,输入框清空、列表滚动位置丢失、网络请求重新发起。更让人头疼的是,如果此时有个异步回调还在跑,它持有的 Activity 引用已经失效,一个空指针就崩了。这个问题的根源在于 Android 的组件生命周期天然不稳定——配置变更、页面跳转、系统回收,任何一个环节都可能让界面层对象被销毁重建。

ViewModel 就是为解决这类问题而生的。它的核心定位是以生命周期感知的方式持有和管理界面相关的数据,让数据在配置变更时存活下来,同时把数据逻辑从 Activity 或 Fragment 中剥离出去。关键词里提到的“生命周期感知”“配置变更存活”“数据与界面分离”,本质上都是围绕这个定位展开的。这篇文章适合已经写过几个 Android 页面、被生命周期问题折磨过、想真正把 ViewModel 用对而不是用个皮毛的开发者。我会从它到底解决了什么问题讲起,拆解底层原理,再落到实际项目里怎么组织代码、怎么避坑,尽量把那些文档里一笔带过但实际开发中一定会遇到的东西说透。

需要先明确一点:ViewModel 不是“万能数据容器”,也不是“替代所有状态管理方案”的银弹。它有自己的适用边界,用错了地方反而会引入内存泄漏或者数据错乱。下面我们一层层来看。

2. ViewModel 到底在生命周期里扮演什么角色

2.1 配置变更时数据为什么能活下来

要理解 ViewModel 的存活机制,得先搞清楚 Activity 重建时到底发生了什么。当屏幕旋转时,系统会销毁当前 Activity 实例并创建一个新的,这个过程走的是正常的 onDestroy 和 onCreate,但不是进程被杀。ViewModelStore 就挂在这个重建链条之外——它由 Activity 的 NonConfigurationInstance 机制持有,在配置变更时被系统临时保存并传递给新的 Activity 实例。

用生活化的类比:Activity 像是一个前台接待员,换班时人换了,但交接的文件夹还在。ViewModelStore 就是这个文件夹,里面装着各个 ViewModel。新接待员上岗后,从文件夹里取出原来的资料继续用,而不是从头再问一遍客户信息。

这里有个关键细节:ViewModel 的 onCleared 回调只在真正销毁时触发,比如用户按返回键退出页面、或者 Activity 被系统正常 finish。配置变更导致的重建不会触发 onCleared。所以如果你在 ViewModel 里注册了监听器、开了线程,判断“什么时候该清理”就要以 onCleared 为准,而不是 onDestroy。

2.2 ViewModel 和 onSaveInstanceState 的分工

很多人会问:既然 onSaveInstanceState 也能保存数据,为什么还要 ViewModel?这两者的适用场景完全不同,搞混了会写出很别扭的代码。

对比维度ViewModelonSaveInstanceState
数据存放位置内存中Bundle 中,可序列化
存活范围配置变更配置变更 + 进程被系统回收后恢复
适合的数据列表、网络结果、复杂对象轻量级 UI 状态、ID、简单标记
数据大小限制基本无限制(受内存约束)有严格限制,过大易触发 TransactionTooLarge
是否可持有引用可持有 Repository 等只能放可序列化基础类型

实际项目里的常见做法是两者配合:ViewModel 持有主要数据,onSaveInstanceState 只存一个最小的“恢复锚点”,比如当前选中的 tab、列表的滚动位置 ID。进程被回收后重建时,用这个锚点重新拉取数据。我见过有人把整个列表塞进 Bundle,结果页面一复杂就崩,这个坑一定要避开。

2.3 一个 ViewModel 被多个 Fragment 共享时的作用域

Fragment 之间通信是另一个高频场景。传统做法是用接口回调或者 Activity 做中转,代码耦合严重。ViewModel 的作用域可以绑定到 Activity,这样同一个 Activity 下的多个 Fragment 拿到的是同一个 ViewModel 实例,天然实现了数据共享。

这里的关键是ViewModelProvider的构造参数。传入 Activity 作为 owner,得到的就是 Activity 作用域的 ViewModel;传入 Fragment 作为 owner,得到的就是 Fragment 作用域的。共享场景下必须统一用 Activity 作为 owner,否则每个 Fragment 会各自创建一份,数据根本不同步。这个点看起来简单,但实际开发中因为传错 owner 导致“数据不刷新”的问题非常常见,排查起来还挺费时间。

3. 把 ViewModel 接进项目:从依赖到第一个可用页面

3.1 依赖引入与版本选择的实际考量

现在新建项目基本都会带上 ViewModel 相关的依赖,但如果你是在老项目里手动加,需要注意几个点。核心依赖是 lifecycle-viewmodel 和 lifecycle-livedata(或者用 Flow 的话是 lifecycle-runtime-ktx)。用 Kotlin 的话,推荐直接用 ktx 版本,扩展函数用起来更顺手。

版本选择上有个经验:lifecycle 相关库的版本要和你的 compileSdk、Kotlin 版本大致匹配。我遇到过升级 lifecycle 到较新版本后,因为 Kotlin 协程版本不兼容导致编译报错的情况。稳妥的做法是查一下当前项目用的 Android Gradle Plugin 版本,对照官方文档推荐的 lifecycle 版本区间,不要盲目追最新。

如果你用 Hilt 做依赖注入,还需要加 hilt-lifecycle-viewmodel 相关依赖,并且 ViewModel 要用 @HiltViewModel 注解,构造函数注入依赖。这块配置稍微复杂,但一旦配好,ViewModel 里拿 Repository 会非常干净。

3.2 定义一个职责清晰的 ViewModel

先看一个反面例子,很多人刚开始会这么写:

class UserViewModel : ViewModel() { var userName = "" var userAge = 0 var isLoading = false var errorMsg = "" // ... 一堆散落的字段 }

这种写法的问题在于:状态分散、没有单一数据源、界面层要自己拼装这些字段,一旦字段多了就乱。更合理的做法是用一个数据类描述界面状态:

data class UserUiState( val userName: String = "", val userAge: Int = 0, val isLoading: Boolean = false, val errorMsg: String? = null ) class UserViewModel(private val repo: UserRepository) : ViewModel() { private val _uiState = MutableStateFlow(UserUiState()) val uiState: StateFlow<UserUiState> = _uiState.asStateFlow() fun loadUser(id: String) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } runCatching { repo.fetchUser(id) } .onSuccess { user -> _uiState.update { it.copy(userName = user.name, userAge = user.age, isLoading = false) } } .onFailure { e -> _uiState.update { it.copy(errorMsg = e.message, isLoading = false) } } } } }

这样界面层只需要订阅一个 uiState,状态变化一目了然。单一数据源是 ViewModel 设计里最重要的原则之一,它让“界面当前处于什么状态”这个问题只有一个答案。

3.3 在 Activity 和 Fragment 中正确获取实例

获取 ViewModel 实例的写法随着版本演进有过几次变化。现在推荐用by viewModels()委托(Activity)或by activityViewModels()/by viewModels()(Fragment)。这几个扩展函数背后做的事情是:根据 owner 找到对应的 ViewModelStore,没有就创建,有就复用。

一个容易忽略的点:Fragment 里用by viewModels()拿到的是 Fragment 作用域的实例,用by activityViewModels()才是 Activity 作用域的。共享数据时用错这两个,就会出现“我在 A Fragment 改了数据,B Fragment 没反应”的情况。我建议在团队里约定好命名,比如共享的 ViewModel 变量名统一加 Shared 前缀,减少误用。

4. viewModelScope 与协程:异步任务该怎么收尾

4.1 viewModelScope 自动取消的边界在哪里

viewModelScope 是 ViewModel 提供的一个协程作用域,它绑定 ViewModel 的生命周期,在 onCleared 时自动取消所有子协程。这个特性极大简化了异步任务管理——你不用担心页面销毁后网络回调还在跑。

但要注意它的边界:viewModelScope 取消的是协程,不是协程里启动的、脱离了该作用域的任务。比如你在 viewModelScope 里启动了一个协程,协程内部又用 GlobalScope 或者自己 new 了一个 CoroutineScope 去跑任务,那这个任务不会被自动取消。我见过有人在 ViewModel 里手动创建线程池执行任务,结果页面退出后任务还在跑,回调时访问已销毁的界面,直接崩溃。正确做法是让所有异步任务都挂在 viewModelScope 下,需要并行就用 async,需要串行就用挂起函数顺序调用。

4.2 网络请求与数据库操作的典型编排

实际业务里,一个页面加载往往涉及多个数据源:先查本地缓存,没有再去网络请求,请求回来再写回数据库。用 viewModelScope 编排这种流程非常自然:

fun loadData() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val local = repo.getFromLocal() if (local != null) { _uiState.update { it.copy(data = local, isLoading = false) } return@launch } val remote = repo.getFromRemote() repo.saveToLocal(remote) _uiState.update { it.copy(data = remote, isLoading = false) } } }

这段代码的可读性比回调嵌套好太多。但有个细节:如果用户在请求过程中退出了页面,viewModelScope 被取消,repo.getFromRemote()这个挂起函数会抛 CancellationException。如果你在 Repository 层用 runCatching 包了所有异常,可能会把取消异常也吞掉,导致协程无法正常取消。CancellationException 必须重新抛出,这是协程取消机制的基础,runCatching 里要单独判断一下。

4.3 并发请求的合并与竞态处理

搜索框联想、下拉刷新这类场景,用户操作很快,可能连续触发多次请求。如果不做处理,先发的请求后返回,就会覆盖掉新数据,出现“输入了 abc,结果显示了 ab 的结果”这种竞态问题。

处理方式有几种:一是用flatMapLatest把新的请求流切换掉旧的;二是在 ViewModel 里维护一个 job 引用,新请求来时先 cancel 旧的;三是用 debounce 操作符做防抖。我一般推荐第一种,用 Flow 的 flatMapLatest 最干净:

private val queryFlow = MutableStateFlow("") val searchResult = queryFlow .debounce(300) .flatMapLatest { query -> if (query.isBlank()) flowOf(emptyList()) else repo.search(query) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

这里的SharingStarted.WhileSubscribed(5000)是个很实用的配置,它表示界面订阅停止后 5 秒内如果重新订阅,不重启上游流。这样屏幕旋转时不会重新发起请求,体验更顺滑。

5. 那些文档不会告诉你但一定会踩的坑

5.1 在 ViewModel 里持有 Context 的后果

这是最经典也最致命的坑。ViewModel 的存活时间可能比 Activity 长,如果你在 ViewModel 里直接持有 Activity 或 Context 引用,配置变更后旧 Activity 无法被回收,直接内存泄漏。

正确的做法分几种情况:需要 Application 级别的 Context,用 AndroidViewModel 或者通过依赖注入传入 Application;需要访问资源,把资源 ID 或者已经取出的字符串传进来;需要启动组件,用 Application Context 或者把操作封装到 Repository 层。判断标准很简单:ViewModel 里不应该出现任何 Activity、Fragment、View 类型的字段。

5.2 事件只消费一次的正确姿势

用 StateFlow 或 LiveData 传递“弹 Toast”“跳转页面”这类一次性事件时,会遇到一个尴尬:界面重建后,订阅重新建立,又收到了上一次的事件,导致 Toast 弹两次、页面跳两次。

这个问题的本质是:状态和事件没有区分开。状态是可以重复读取的(比如当前用户名),事件是只能消费一次的(比如“登录成功,跳转首页”)。解决方案有几种:用 Channel 替代 StateFlow 传递事件;用 SingleLiveEvent(但官方不推荐);或者在状态里加一个“已消费”标记。

我目前更倾向于用 Channel 配合 receiveAsFlow:

private val _events = Channel<UiEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow() fun onLoginClick() { viewModelScope.launch { if (repo.login()) _events.send(UiEvent.NavigateToHome) } }

Channel 的每个元素只会被一个消费者接收一次,天然符合事件的语义。但要注意 Channel 在界面没有订阅者时,send 会挂起(BUFFERED 模式下有缓冲,缓冲满了才挂起),所以事件发送要放在协程里。

5.3 ViewModel 里做数据转换的度

ViewModel 该不该做数据转换?比如把网络返回的 DTO 转成界面需要的 UI 模型。我的经验是:轻量的、和界面展示强相关的转换放在 ViewModel,重型的、可复用的转换放在 Repository 或专门的 Mapper。

举个例子,把时间戳格式化成“3 分钟前”这种展示文案,放 ViewModel 合理,因为它和界面语言、展示逻辑强相关。但把网络 JSON 解析成领域模型,应该放在数据层,ViewModel 只拿领域模型。如果 ViewModel 里出现大量 JSON 解析、字段映射代码,说明分层没做好,数据层职责被侵占了。

5.4 测试 ViewModel 时最容易忽略的依赖

ViewModel 的一大优势是可测试性,但前提是你写对了。如果 ViewModel 里直接 new 了 Repository,测试时就没法替换成 mock。所以依赖必须从构造函数注入,这是可测试性的基础。

测试时用Dispatchers.setMain(TestDispatcher())替换主线程调度器,否则 viewModelScope 里的协程在测试环境下不会执行。这个坑我踩过好几次,测试代码跑完发现状态根本没变,排查半天才发现是调度器没替换。另外,测试 StateFlow 时要用runTest配合advanceUntilIdle,让挂起的协程执行完再断言。

6. 从 ViewModel 到整体架构:它该站在哪一层

6.1 ViewModel 与 Repository 的边界划分

一个常见的困惑是:什么逻辑该放 ViewModel,什么该放 Repository?我的划分原则是:Repository 负责“数据从哪来、怎么存”,ViewModel 负责“界面当前需要什么数据、怎么组合”。

具体来说,Repository 处理数据源切换(本地/网络)、缓存策略、数据格式转换。ViewModel 处理界面状态编排、多个数据源的组合、用户交互的响应。比如“先读缓存再请求网络”这个策略,如果它是全局通用的,放 Repository;如果只是某个页面特有的加载逻辑,放 ViewModel。

边界清晰的好处是:Repository 可以被多个 ViewModel 复用,ViewModel 可以专注于界面逻辑,测试时各测各的,互不干扰。

6.2 多模块项目里 ViewModel 的归属

在模块化项目里,ViewModel 通常放在 feature 模块,因为它和具体界面强绑定。但这里有个依赖方向的问题:feature 模块依赖 core 模块,ViewModel 依赖 Repository 接口,Repository 的实现放在 data 模块。这样 feature 模块不直接依赖 data 模块,编译隔离做得好。

如果多个 feature 需要共享某个 ViewModel(比如用户信息),可以把它下沉到 core 或者一个 shared 模块。但要注意,共享 ViewModel 意味着共享状态,如果两个页面对同一份数据的期望不一致,就会互相干扰。共享之前想清楚:这份数据是真的全局唯一,还是只是碰巧两个页面都要用。

6.3 什么时候不该用 ViewModel

ViewModel 不是所有场景都适用。比如一个纯展示的静态页面,没有任何状态需要跨配置变更保留,硬套 ViewModel 反而增加复杂度。再比如一些工具类、纯函数式的数据处理,也不需要 ViewModel。

还有一种情况:如果页面状态非常简单,用 rememberSaveable(Compose)或者 onSaveInstanceState 就能搞定,没必要引入 ViewModel。技术选型要看实际收益,不要为了用而用。我见过一些项目,每个页面都建一个 ViewModel,里面就一两个字段,这种过度设计反而让代码更难维护。

7. 我在实际项目里沉淀下来的几条经验

第一条,ViewModel 的命名要体现它的职责范围。比如 UserProfileViewModel、SearchViewModel,一看就知道管什么。如果出现 CommonViewModel、MainViewModel 这种大而全的命名,基本可以判断这个 ViewModel 承担了太多职责,后续会越来越臃肿。

第二条,状态更新尽量用不可变数据类的 copy,而不是直接修改字段。这样每次状态变化都是一个新对象,配合 StateFlow 的去重机制,界面只在真正变化时刷新。直接改字段的话,StateFlow 判断的是引用是否变化,可能该刷新的没刷新,不该刷新的刷了。

第三条,onCleared 里不要做耗时操作。onCleared 是在主线程调用的,如果里面做数据库写入、文件操作,会阻塞主线程。需要清理的资源,尽量在业务逻辑结束时主动释放,而不是攒到 onCleared 里统一处理。

第四条,调试 ViewModel 状态时,给 StateFlow 加个日志操作符。在开发阶段打印每次状态变化,能快速定位“界面为什么没更新”这类问题。上线前记得去掉或者用 BuildConfig 控制,避免日志泄露。

第五条,团队里统一 ViewModel 的模板。比如统一用 StateFlow 还是 LiveData、统一事件用 Channel 还是 SharedFlow、统一依赖注入方式。统一之后,任何人看别人的 ViewModel 都能快速理解,减少沟通成本。这个收益在多人协作项目里非常明显。

ViewMode 这个组件本身不复杂,难的是把它放在合适的架构位置上,和生命周期、协程、数据层配合好。我自己的体会是,每次觉得“这里好像不太对”的时候,往往就是职责边界模糊了——要么 ViewModel 管了数据层的事,要么界面层管了状态的事。把边界理清楚,很多问题自然就消失了。

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

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

立即咨询