第18周:ViewModel 基础功能 + 架构优化

第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 泄漏检测框架 未接入,登记专题 常态化泄漏监控 本周只点名不展开
相关推荐
2601_962885722 小时前
如何用 Python 判断一只股票“追涨”还是“抄底”更有效?
android·开发语言·python
素师良码2 小时前
第10篇:屏ESD Check内核逻辑驱动代码完整拆解
android
问心无愧05135 小时前
[GXYCTF2019]Ping Ping Ping
android·笔记
事圆则缓5 小时前
Kotlin 异步与并发:从一次搜索请求走到状态流
android·开发语言·kotlin
帅得不敢出门6 小时前
Android Framework定制卸载确认窗口
android
Android-Flutter7 小时前
Compose 什么叫重组?怎么样触发重组?
android·compose
硫酸锌018 小时前
安卓手机连接Windows电脑后通过DHCP服务为手机分配IP地址
android·windows·计算机网络
骑着蜗牛撵大象3278 小时前
设备监控数据存与查:Spring Boot 接入 TimescaleDB 超表的完整落地
android·spring boot·后端·springboot·时序数据库·es·设备监控
云贝贝贝8 小时前
MySQL 线上报 Lock wait timeout 别慌:6 招定位锁等待与死锁
android