第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 泄漏检测框架 未接入,登记专题 常态化泄漏监控 本周只点名不展开
相关推荐
雨声不在4 小时前
android 动画掉帧
android
在多学一点5 小时前
MYSQL第二次作业
android·数据库·mysql
骑着蜗牛撵大象3275 小时前
MySQL 8.0 迁移后 SQL 变慢:执行计划里 3 类被忽略的成本差异
android·数据库·sql·mysql·优化器·执行计划
我叫张土豆6 小时前
Android 14+ 模拟器抓包实录:把 Burp CA 装成系统 CA,让 targetSdk≥34 的 App 乖乖解密
android
booboosui9 小时前
Android 悬浮窗藏不住自己:一个标志位与 140 毫秒
android
传奇开心果9 小时前
【Flutter踩坑排雷】第5课:扩展方法冲突坑|同名扩展引发编译歧义,故障规避手段
android·flutter
.痞老板.12 小时前
mysql初阶--数据类型
android·数据库·mysql
StarsGreen12 小时前
Android 启动流程详解
android·linux·单片机·嵌入式硬件
booboosui1 天前
Android 上来电怎么判:7 个条件加 5 个中文词
android
MardaWang1 天前
App 内嵌 H5 的信任边界设计(ArkWeb/HarmonyOS 实战)
android