第13周已经验证过 ViewModel 为什么能在旋转后活下来,那一周的结论是"实例存在 ViewModelStore 里,不跟着 Activity 重建"。但活下来只是底线。真正写业务页面时,问题会变成另外几个:构造参数怎么传进去?数据什么时候加载才不浪费?两个 Fragment 怎么拿到同一个实例?为什么不能在里面存 Activity 引用?
这一周把这些问题逐个落地。Demo 里有四个实验区:Factory 注入参数、viewModelScope 懒加载、两个 Fragment 共享 ViewModel、泄漏红线清单。
ViewModel 到底是什么
先给官方定义:ViewModel 是"业务逻辑或屏幕级状态容器"。拆开看是两个职责:把状态公开给界面,封装相关的业务逻辑。
它解决的具体问题是:Activity 和 Fragment 一旋转就销毁重建,存在它们身上的数据跟着没了。把数据挪到 ViewModel 里,界面重建后从 ViewModel 拿回来,用户无感知。
关键点在于作用域。官方原文:"ViewModel 会一直保留在内存中,直到其作用域 ViewModelStoreOwner 永久消失"。ViewModelStoreOwner 的直接子类只有三个:ComponentActivity、Fragment、NavBackStackEntry。你传给 ViewModelProvider 的那个 owner 决定了 ViewModel 活多久,这就是后面所有用法的根。
kotlin
class Week18CounterViewModel(
private val initialCount: Int
) : ViewModel() {
private val _count = MutableStateFlow(initialCount)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.value += 1
}
}
这是最小形态。_count 用 MutableStateFlow 包一层再暴露成只读的 StateFlow,外部只能读不能改,写入口收敛在 ViewModel 的方法里。少了这层封装,界面层谁都能直接改状态,出了问题根本找不到是谁改的。
获取 ViewModel 的三种形态
by viewModels():惰性委托
kotlin
private val counterViewModel: Week18CounterViewModel by viewModels {
Week18CounterViewModel.factory(initialCount = 100)
}
by viewModels() 是属性委托,首次访问时才真正向 ViewModelStore 请求实例,不是 onCreate 一进来就创建。复用的判断由系统做:ViewModelStore 里已经有这个类的实例就直接返回旧的,没有才走 factory 创建。旋转后新 Activity 执行到这行代码时拿到的是旧实例,这就是数据不丢的机制。
Factory:构造参数怎么传
ViewModel 不能自己 new,也不能在构造函数里随便接参数然后指望系统帮你填。系统控制创建时机,参数只能通过 Factory 传:
kotlin
companion object {
fun factory(initialCount: Int): ViewModelProvider.Factory = viewModelFactory {
initializer {
Week18CounterViewModel(initialCount)
}
}
}
viewModelFactory { initializer { ... } } 是 lifecycle-viewmodel 2.5 之后推荐的 DSL 写法。initializer 块只在首次创建时执行一次。这里有个反直觉的点:旋转后新 Activity 传入的 initialCount 即使变了也不会生效,因为 initializer 根本不会再跑,系统直接返回旧实例。所以 Factory 传的参数语义是"首次创建的初始值",不是"每次重建都刷新的配置"。如果需要配置变化后生效的东西,别走构造参数。
activityViewModels():Fragment 之间共享
Fragment 里写 by viewModels(),作用域是 Fragment 自己;写 by activityViewModels(),作用域是宿主 Activity。官方文档的原话:两个 fragment 使用同一范围,就会收到 ViewModel 的同一实例。
这是 Fragment 通信的官方推荐做法。以前的做法是 Fragment 定义接口回调给 Activity 再转发给另一个 Fragment,两边都认识 Activity,耦合很重。共享 ViewModel 之后,两个 Fragment 互相不知道对方存在。
Demo 里 FilterFragment 管筛选条件,ListFragment 管展示:
csharp
// FilterFragment 里
private val viewModel: Week18SharedViewModel by activityViewModels()
view.findViewById<CheckBox>(R.id.cb_food).setOnCheckedChangeListener { _, _ ->
viewModel.toggleCategory(category)
}
csharp
// ListFragment 里
private val viewModel: Week18SharedViewModel by activityViewModels()
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.filteredItems.collect { items ->
// 刷新列表
}
}
}
两个 Fragment 各自打印 viewModel.hashCode(),运行后能看到是同一个十六进制值。勾选筛选条件,列表端实时变化,中间没有任何回调代码。
一个容易写错的细节在 ListFragment 里:collect 要挂在 viewLifecycleOwner 上而不是 this(Fragment 本身)。Fragment 的视图比 Fragment 先销毁,比如 Fragment 还在返回栈里但视图已经销毁的场景,挂错了生命周期回调会在视图不存在时继续跑。
相关技术清单
- API / 类:
viewModels()、activityViewModels()、navGraphViewModels()、ViewModelProvider.Factory、viewModelFactory、initializer、CreationExtras - 系统机制:ViewModelStoreOwner 作用域、ViewModelStore 实例缓存
- Jetpack:fragment-ktx、lifecycle-viewmodel-ktx、Navigation(navGraphViewModels 所在)
- 常见坑:Fragment 里 observe 挂
this而不是viewLifecycleOwner;以为 Factory 参数旋转后会重新生效;共享 ViewModel 用来传一次性事件导致状态残留
viewModelScope 与懒加载
ViewModel 里跑异步任务用 viewModelScope。官方定义很干脆:"如果 ViewModel 已清除,则在此范围内启动的协程都会自动取消"。你不用手动管取消,onCleared 时系统帮你清。
懒加载的意思是:不是 ViewModel 一创建就拉数据,而是用户真正触发了才拉,而且只拉一次:
kotlin
private var hasLoaded = false
fun loadList() {
if (hasLoaded) return // 加载过就不再发起
hasLoaded = true
viewModelScope.launch {
_listState.value = UiState.Loading
delay(1200) // 模拟一次 IO 请求
_listState.value = UiState.Loaded(items, costMs)
}
}
hasLoaded 标记存在 ViewModel 里,这一点很重要:旋转屏幕后 ViewModel 还是同一个,标记还在,重建的界面再调 loadList() 会直接返回,不会重复请求。如果这个标记存在 Activity 里,旋转后标记归零,又会重新拉一次。Demo 里点"懒加载"按钮后再旋转,能看到列表直接恢复而不是重新转圈。
另一个相关写法是 stateIn 配 SharingStarted.WhileSubscribed(5000),官方协程文档的示例:界面消失后上游继续活 5 秒,正好覆盖旋转重建的空窗,避免旋转导致上游(比如网络请求流)被白白取消重连。
ini
val filteredItems: StateFlow<List<Item>> =
combine(MutableStateFlow(allItems), _activeCategories) { items, cats ->
items.filter { it.category in cats }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = allItems
)
相关内容
- API / 类:
viewModelScope、stateIn、SharingStarted.WhileSubscribed、combine、MutableStateFlow/StateFlow - 系统机制:onCleared 时协程自动取消
- 常见坑:懒加载标记放在 Activity 里导致旋转后重复请求;冷 Flow 直接 collect 不转 StateFlow,旋转后上游重启
泄漏红线:ViewModel 不能持有什么
官方最佳实践里有一条原文:"由于 ViewModel 的生命周期可能比 ViewModelStoreOwner 更长,因此 ViewModel 不应保留任何对与生命周期相关的 API(例如 Context 或 Resources)的引用,以免发生内存泄漏。"
展开成清单:
- View / Activity / Fragment 引用。旋转后旧 Activity 实例被 ViewModel 拽住,GC 收不掉,整个页面的视图树都在内存里烂着
- 非 application 的 Context。ViewModel 里确实要用 Context(比如读字符串资源)时,用 applicationContext,它的寿命和应用一样长,不存在泄漏
- 不取消的协程。自己 new CoroutineScope 而不在 onCleared 里 cancel,Activity 退了协程还在跑。直接用 viewModelScope 就没这个问题
- 手动注册的回调。比如向某个管理器注册了监听器,就要在 onCleared 里反注册:
kotlin
override fun onCleared() {
super.onCleared()
// viewModelScope 的协程系统已自动取消,这里只管 scope 之外的东西
someManager.unregister(listener)
}
官方还给了个更省事的机制:Lifecycle 2.5 起,ViewModel 构造函数可以接收 Closeable 对象,清除时自动关闭,不用手写 onCleared。
onCleared 什么时候触发?Activity 真正 finish(按返回键退出)时,或者 Fragment 从返回栈永久移除时。旋转不会触发,因为 ViewModelStore 在配置变化时被保留了。Demo 里退出页面后看 Logcat(tag: Week18VM)能确认这条日志打印出来。
选型对照
| 场景 | 方案 | 不选其他的原因 |
|---|---|---|
| 屏幕级状态 + 业务逻辑 | ViewModel | 普通类旋转就丢 |
| ViewModel 要构造参数 | viewModelFactory DSL | 不能自己 new,系统控制创建时机 |
| ViewModel 里跑异步 | viewModelScope | 自定义 scope 要手动 cancel |
| 两个 Fragment 同步状态 | activityViewModels 共享 | 接口回调耦合重;Fragment Result 只适合一次性结果 |
| 进程死亡后还要恢复 | SavedStateHandle(第13周) | ViewModel 本身活不过进程死亡 |
| 一次性事件(弹窗、提示) | Channel / Fragment Result | StateFlow 会回放,事件被重复消费 |
第18周真正带走的东西
ViewModel 的核心不是"数据不丢"这一个功能,是作用域这个概念:你传什么 owner,它就活多久。三种获取形态的差别全在 owner 上,泄漏红线的根因也在"它比 owner 的任何单次实例活得久"上。想通这一点,Factory 为什么只跑一次、activityViewModels 为什么能共享、为什么不能存 View 引用,都不需要背。
下周第19周,LiveData 全功能 + 数据驱动优化,正好接着本周 StateFlow 与 LiveData 的边界往下讲。
技术清零表
| 技术 | 它是什么 | Demo 落点 | 真实项目价值 | 常见坑 |
|---|---|---|---|---|
| ViewModel | 屏幕级状态容器 + 业务逻辑封装 | Week18CounterViewModel | 配置变化数据不丢 | 当普通类自己 new |
| ViewModelStoreOwner | 决定 ViewModel 活多久的作用域接口 | Activity / Fragment 都是它的实现 | 理解作用域才能选对获取方式 | 直接子类只有 ComponentActivity / Fragment / NavBackStackEntry |
| by viewModels() | 惰性属性委托,首次访问才创建 | Week18ViewModelActivity | 标准获取方式 | 以为 onCreate 时就创建了 |
| ViewModelProvider.Factory | 给 ViewModel 传构造参数的入口 | factory(initialCount = 100) | 参数注入的唯一正道 | 以为旋转后参数会重新生效 |
| viewModelFactory DSL | lifecycle 2.5+ 的 Factory 简写 | initializer 块 | 比手写 Factory 类简洁 | initializer 只在首次创建执行 |
| activityViewModels() | 作用域挂到宿主 Activity 的委托 | 两个 Fragment hashCode 相同 | Fragment 通信官方推荐 | 误用 viewModels() 导致各自拿到不同实例 |
| viewLifecycleOwner | Fragment 视图的生命周期 | ListFragment collect 处 | 视图销毁时自动停收集 | 挂成 this 导致视图销毁后回调还在跑 |
| viewModelScope | ViewModel 内置协程作用域,onCleared 自动取消 | loadList() | 异步任务不泄漏 | 自建 scope 忘记 cancel |
| 懒加载 | 触发才加载 + 标记防重 | hasLoaded 标记 | 省流量省请求 | 标记放 Activity 里旋转后失效 |
| stateIn / WhileSubscribed | 冷流转热流 + 上游存活窗口 | filteredItems | 覆盖旋转空窗 | 不写参数默认立即停止上游 |
| onCleared | ViewModel 永久销毁回调 | Logcat tag Week18VM | 清理 scope 外资源 | 以为旋转也会触发 |
| Closeable 构造函数注入 | Lifecycle 2.5+ 自动关闭资源 | 文章提及未做 Demo | 免手写 onCleared | 低版本库不支持 |
| SavedStateHandle | 进程死亡级状态保存(第13周已覆盖) | 选型表对照 | ViewModel 活不过进程死亡 | 以为 ViewModel 能扛进程死亡 |
| LeakCanary | 泄漏检测框架 | 未接入,登记专题 | 常态化泄漏监控 | 本周只点名不展开 |