在 Android 应用开发中,"如何加载初始数据" 似乎是一个老生常谈的话题。然而,即使是经验丰富的开发者,常常也会在配置变更(Configuration Changes) 和单元测试(Unit Testing) 这两个极端之间左右为难。
你是否遇到过以下情况?
- 屏幕旋转后,数据又重新请求了一次,浪费了流量。
- 试图测试 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
我们需要的方案必须满足两点:
- 懒加载(Lazy) :只有 UI 开始监听时才加载(利于测试)。
- 缓存(Caching) :在屏幕旋转等短暂中断时,保持数据不重新加载。
我们可以利用 Kotlin Flow 的 onStart 操作符配合 stateIn 的 WhileSubscribed 策略来实现。
代码实现
看看这个优雅的实现方式:
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) |
|---|---|---|---|
| 抗屏幕旋转 | ❌ | ✅ | ✅ |
| 易于单元测试 | ✅ | ❌ | ✅ |
| 自动刷新过期数据 | ❌ | ❌ | ✅ |