【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)
抗屏幕旋转
易于单元测试
自动刷新过期数据
相关推荐
十六年开源服务商2 小时前
2026服务器配置优化与WordPress运维实战指南
android·运维·服务器
音视频牛哥4 小时前
大牛直播SDK(SmartMediaKit)Android平台Unity3D RTSP/RTMP播放器集成实践
android·unity3d·rtsp播放器·rtmp播放器·unity3d rtmp播放器·安卓unity rtsp播放器·安卓unity rtmp播放器
w1wi4 小时前
安卓抓包完全指南(一):从入门到 SSL Pinning 绕过
android·网络协议·ssl
aqi005 小时前
一文理清 HarmonyOS 6.0.2 涵盖的十个升级点
android·华为·harmonyos·鸿蒙·harmony
赏金术士6 小时前
Jetpack Compose 状态提升(State Hoisting)完全指南
android·kotlin·compose
BoomHe7 小时前
git Rebase 为任意一笔提交补上 Change-Id
android·git·android studio
TDengine (老段)7 小时前
TDengine 超级表/子表/普通表 — 设计理念与内部表示
android·大数据·数据库·物联网·时序数据库·tdengine·涛思数据
shuaiqinke7 小时前
【分享】Edge浏览器|内置扩展仓库|支持油猴|上网无限制
android·前端·人工智能·edge
Carson带你学Android8 小时前
见证历史!Swift 6.3 官方支持 Android,跨平台要变天了?
android