【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 操作符配合 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)
抗屏幕旋转 ❌ ✅ ✅
易于单元测试 ✅ ❌ ✅
自动刷新过期数据 ❌ ❌ ✅
相关推荐
驰骋工作流6 小时前
流程时效考核的表结构:从待办时限到 WF_CH 考核结果驰骋BPM驰骋低代码低代码工作流引擎
android·低代码·rxjava
传奇开心果编程8 小时前
【Flutter踩坑排雷】第7课:巨型 build 函数坑|全部 UI 写在同一个 build,维护难、隐性异常
android·flutter·ios·移动开发·跨平台·dart·声明式ui
方白羽11 小时前
Android 双开环境底层剖析与风控检测落地
android·安全·app
weixin_5633714712 小时前
Android Studio 2024 版 Device Explorer 打开方法
android·ide·android studio
AIsoft_868914 小时前
职场音频文件怎么整理?录音APP有哪些用法
android·音视频
booboosui15 小时前
第一批个人 Agent 用户正在卸载,问题不在模型,在它住在哪儿
android
洞见前行15 小时前
点击图标到内容出现,你的 App 在这几秒里做了什么:Android 冷启动全链路优化实战
android·性能优化·android studio
凯基迪科技15 小时前
芯片UID绑定修改 加密与逆向修改思路分析
android
Liben16 小时前
Weex开发踩坑记录
android·前端·ios
救救孩子把18 小时前
结构化输出为何“格式正确、答案错误”?JSON Schema 的能力边界
android·java·json·agent·agent服务