Android ViewModel 生命周期感知与配置变更存活实战指南

发布时间:2026/10/15 14:21:30
Android ViewModel 生命周期感知与配置变更存活实战指南 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: StateFlowUserUiState _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) } returnlaunch } 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 配合 receiveAsFlowprivate val _events ChannelUiEvent(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 什么时候不该用 ViewModelViewModel 不是所有场景都适用。比如一个纯展示的静态页面没有任何状态需要跨配置变更保留硬套 ViewModel 反而增加复杂度。再比如一些工具类、纯函数式的数据处理也不需要 ViewModel。还有一种情况如果页面状态非常简单用 rememberSaveableCompose或者 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 管了数据层的事要么界面层管了状态的事。把边界理清楚很多问题自然就消失了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询