Android 关于activity-ktx的 by viewModels()踩坑记录与分析
LiveData 订阅问题分析与总结
问题描述
在 Android Activity 中,使用 viewModel.data.observe(this) { ... }
对 LiveData 进行订阅时,出现概率性订阅失败的现象:代码确定已执行,但数据更新后 Observer 回调始终无法触发。问题在调换 initObserver()
和 lifecycleScope.launch { vm.refreshData() }
两行代码的顺序后复现或消失。
1. 问题起因
问题的根本起因是一个多线程环境下的初始化竞态条件。
- 错误操作顺序:在 Activity 的
onCreate
方法中,先启动一个 IO 协程并在其中首次调用vm.refreshData()
,然后再在主线程调用initObserver()
进行订阅。
private val vm: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {lifecycleScope.launch(Dispatchers.IO) {vm.update("1234567890")}vm.data.observe(this) {Toast.makeText(this, it, Toast.LENGTH_SHORT).show()}
}
- 触发机制:
by viewModels()
是延迟属性委托(lazy
),ViewModel 实例的创建发生在首次访问vm
属性时。上述错误顺序导致了对vm
的首次访问发生在错误的线程(IO线程)和错误的时间点(Activity初始化阶段)。
2. 分析过程
2.1 初步分析与错误假设
- 表面现象:LiveData 有时能订阅到,有时不能,像是随机失败。
- 初步排查:
-
检查了 ViewModel 的实现(后备属性模式),确认实现正确。
-
检查了生命周期状态,确认 Observer 已添加但为非活跃状态。
-
出现问题时,获取data的订阅者列表为空白
-
- 错误假设:最初怀疑是数据更新时机与生命周期状态不匹配导致的、初始化途中抛了异常导致订阅代码没执行
2.2 决定性证据与深入排查
- 哈希码比对:在订阅和设置值的地方打印
LiveData
和ViewModel
的System.identityHashCode()
,发现两者哈希码均不相同,证明操作的是完全不同的对象实例。 - 构造函数日志:在 ViewModel 的
init
块中添加日志,发现构造函数被调用了两次,铁证如山地表明创建了两个实例。 - 内存分析(Heap Dump):使用 Android Profiler 捕获堆转储,发现内存中确实存在两个 ViewModel 实例:
-
实例A:[正确用法] 先
observe(this)
再在IO中执行刷新数据,被正式存储在Activity
的ViewModelStore
中,UI 线程订阅于此。
-
实例B:[错误用法] 先在IO中执行刷新数据再
observe(this)
,被by viewModels()
委托的内部缓存 (ViewModelLazy.cached
) 持有。
-
2.3 根本原因定位
基于所有证据,推断出根本原因:
IO线程:执行了首次访问,创建了实例B,并将其缓存在了 ViewModelLazy 的 cached 字段中。
主线程:几乎同时也执行了首次访问。由于IO线程的缓存操作可能尚未对主线程可见,或者框架检测到某种状态不一致,主线程的 ViewModelProvider 绕过了缓存,选择去 ViewModelStore 中重新创建并存储了一个新实例(实例A)。
// 顺序二(出问题的顺序)
override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)lifecycleScope.launch(Dispatchers.IO) { // [线程: IO]// 在IO线程和Activity初始化阶段,框架状态可能不稳定。// 创建了实例B,并可能被缓存。vm.refreshData() // 操作的是实例B}initObserver() { // [线程: Main]// 再次首次访问 `vm`?由于竞态条件,主线程的初始化流程// 可能未感知到IO线程创建的实例,于是创建了实例A。vm.data.observe(this) { ... } // 订阅的是实例A}
}
结论:由于 by viewModels()
委托在 Dispatchers.IO
上首次初始化时,与主线程的初始化流程发生竞态条件,导致框架内部状态不一致,最终错误地创建了两个实例。订阅和更新操作分别应用在了不同的实例上。
3. 结论与解决方案
3.1 结论
- 问题性质:这不是 LiveData 或 ViewModel 的 bug,而是对
by viewModels()
委托机制理解不足而引发的使用方式错误。 - 核心原因:在 Activity 生命周期的初始化阶段 (
onCreate
),在后台线程触发 ViewModel 的首次初始化,导致框架内部产生竞态条件,创建了多个实例。 - 最终表现:UI 订阅了一个实例,数据更新发生在另一个实例,造成“订阅失效”的假象。
3.2 解决方案与最佳实践
解决方案:调整代码顺序,确保 by viewModels()
的首次初始化发生在主线程。
// 正确顺序
override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)// 1. 先在主线程完成订阅(这会触发ViewModel的安全初始化)initObserver()// 2. 然后再启动异步任务更新数据loadData()
}private fun initObserver() {vm.data.observe(this) { ... } // 首次访问 `vm`,在主线程创建实例
}private fun loadData() {lifecycleScope.launch(Dispatchers.IO) {vm.refreshData() // 后续访问,直接使用已创建的实例}
}
最佳实践:
- 唯一初始化点:将在
onCreate
/onViewCreated
中在主线程进行订阅作为初始化 ViewModel 的标准方式。 - 避免跨线程初始化:绝对避免在后台线程或异步回调中首次访问
by viewModels()
委托的属性。 - 依赖传递:如果其他类需要 ViewModel,应通过参数传递已获取到的实例,而不是重新获取。
4. 经验教训
- 理解委托机制:深刻理解
by viewModels()
是延迟执行的,其初始化时机至关重要。
*:如果其他类需要 ViewModel,应通过参数传递已获取到的实例,而不是重新获取。
4. 经验教训
- 理解委托机制:深刻理解
by viewModels()
是延迟执行的,其初始化时机至关重要。 - 线程安全意识:Android 组件的初始化(尤其是生命周期相关的组件)通常对线程敏感,应严格遵守在主线程操作的原则,使用
by viewModels()
时,需要保证先在主线程使用一次viewmodel,使他创建好实例!!!