【Android 进阶】为什么你应该停止在 ViewModel `init` 中加载数据?

在 Android 应用开发中,"如何加载初始数据" 似乎是一个老生常谈的话题。然而,即使是经验丰富的开发者,常常也会在配置变更(Configuration Changes)单元测试(Unit Testing) 这两个极端之间左右为难。

你是否遇到过以下情况?

  1. 屏幕旋转后,数据又重新请求了一次,浪费了流量。
  2. 试图测试 ViewModel,但数据在实例化时就自动加载了,导致无法 Mock 初始状态。

本文将介绍一种结合了 Flow 操作符的"最佳实践",它能完美解决上述两个痛点,让你的代码既健壮又易于测试。


⛔ 常见的两种"不完美"写法

在介绍最佳方案之前,我们先快速回顾一下目前最主流的两种做法,以及它们的问题所在。

1. 这种写法:LaunchedEffect

在 Compose UI 中直接触发:

Kotlin 复制代码
@Composable
fun MyScreen(viewModel: MyViewModel) {
    LaunchedEffect(Unit) {
        viewModel.loadData()
    }
    // ...
}
  • 优点 :测试方便,因为你可以控制何时调用 loadData
  • 痛点配置变更噩梦 。当用户旋转屏幕时,Activity 重建,LaunchedEffect 重新执行,导致重复的网络请求。这不仅浪费资源,还可能导致 UI 闪烁。

2. 这种写法:ViewModel init

在 ViewModel 初始化时触发:

Kotlin 复制代码
class MyViewModel(private val repo: Repository) : ViewModel() {
    init {
        loadData()
    }
}
  • 优点 :解决了旋转问题。ViewModel 在配置变更期间是存活的,所以 init 不会重新运行。
  • 痛点测试地狱 。数据加载作为构造函数的副作用立即发生。你无法在 loadData 执行之前设置测试环境(比如 Mock 特定的流发射),这让编写精准的单元测试变得非常困难。

✅ 最佳实践:onStart + stateIn

我们需要的方案必须满足两点:

  1. 懒加载(Lazy) :只有 UI 开始监听时才加载(利于测试)。
  2. 缓存(Caching) :在屏幕旋转等短暂中断时,保持数据不重新加载。

我们可以利用 Kotlin Flow 的 onStart 操作符配合 stateInWhileSubscribed 策略来实现。

代码实现

看看这个优雅的实现方式:

Kotlin 复制代码
class MyViewModel(private val repository: MyRepository) : ViewModel() {
​
    // 1. 我们不再使用 init 块,也不使用 private MutableStateFlow
    
    val uiState: StateFlow<UiState> = repository.observeData()
        // 2. 关键点:使用 onStart 触发副作用(加载数据)
        .onStart {
            repository.refreshData() // 或者 emit(Loading)
        }
        // 3. 转换为 StateFlow
        .stateIn(
            scope = viewModelScope,
            // 4. 核心:设置 5 秒的超时缓存
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = UiState.Loading
        )
}

为什么这样做是完美的?

让我们深入分析这段代码背后的机制。

1. 解决测试难题 (onStart)

onStart 是一个中间操作符,它只有在 Flow 被收集(collected) 时才会执行。这意味着当你实例化 ViewModel 时,网络请求不会立即发生。

在单元测试中,你有充足的时间去 Mock 你的 Repository,然后再调用 viewModel.uiState.test { ... } 来触发加载。

2. 解决屏幕旋转问题 (WhileSubscribed(5000))

这是整个方案的灵魂所在。

  • 场景:用户旋转屏幕。
  • 发生的事情:旧的 Activity 销毁,停止收集 Flow;新的 Activity 创建,重新收集 Flow。
  • WhileSubscribed(5000) 的作用 :当订阅者数量从 1 变为 0 时,上游的数据流不会立即停止,而是会保持活跃状态 5 秒钟(stopTimeoutMillis)。
  • 结果 :因为屏幕旋转通常在几百毫秒内完成,新的订阅者会在 5 秒内出现。此时,Flow 认为"一直有订阅者",因此不会 重新触发 onStart,也就避免了重复请求。

3. 智能的后台行为

如果用户按 Home 键将应用退到后台,由于 WhileSubscribed 的超时机制:

  • 5秒内返回:数据无需重载,无缝衔接。
  • 超过5秒返回 :Flow 停止。当用户再次打开应用,onStart 重新执行。这其实是一个特性而非 Bug------如果用户离开很久,他们通常希望看到最新的数据,而不是陈旧的缓存。

总结

通过将数据加载逻辑从 init 块移动到 Flow 的 onStart 操作符,并结合 SharingStarted.WhileSubscribed(5000),我们达成了一个完美的平衡:

特性 LaunchedEffect ViewModel init Flow (onStart + stateIn)
抗屏幕旋转
易于单元测试
自动刷新过期数据
相关推荐
Rubin智造社14 分钟前
见路不走:从《天幕红尘》读懂2026年的创新密码
android·开发语言·kotlin
冷雨夜中漫步35 分钟前
Python入门——__init__.py文件作用
android·java·python
学习3人组1 小时前
采用EVENT定时任务同步视图到物理表提升视图查询效率
android
followYouself1 小时前
ViewPager+Fragment
android·前端
吴声子夜歌1 小时前
RxJava——概述
android·rxjava
Dreamboat¿11 小时前
解析PHP安全漏洞:Phar反序列化、Filter链与文件包含的高级利用与防御
android·网络·php
周杰伦的稻香13 小时前
MySQL中常见的慢查询与优化
android·数据库·mysql
他们叫我技术总监13 小时前
Python 列表、集合、字典核心区别
android·java·python
2401_8823515217 小时前
Flutter for OpenHarmony 商城App实战 - 地址编辑实现
android·java·flutter