Compose中初始加载逻辑究竟应该放在哪里?

本文译自「Where Should Initial Load Logic Actually Live in Jetpack Compose?」,原文链接proandroiddev.com/where-shoul...,由Sergey Nes发布于2026年3月11。

永无休止的争论

我写了一篇 LinkedIn 帖子,称 LaunchedEffect(Unit) { viewModel.loadData() } 是一种代码异味。社区强烈反对。

我又写了一篇后续帖子,力挺 init{} 作为一种更简洁的替代方案。社区的反对声浪更大。

然后我又写了一篇关于 .onStart + stateIn 作为响应式方法的文章。结果还是一样。

50 多位工程师在三篇帖子中发表了看法。有些人赞同,有些人强烈反对,还有一些人提出了我完全没有考虑过的模式。我花在评论上的时间比写文章的时间还多。

我得出了一个令人不安的结论:它们都对。

并非因为每种模式都同样好(在特定情况下,有些模式确实比其他模式更优),而是因为答案取决于大多数文章完全忽略的限制条件。例如,你的团队规模、你的测试规范、是否需要重试、你的屏幕是响应式密集型还是简单的单次加载。

关于这个主题的大多数现有内容可以归为以下三类:"使用 LaunchedEffect(Unit)"(老旧、幼稚)、"迁移到 init{}"(矫枉过正,缺乏细致分析)或"使用 stateIn"(片面,忽略了陷阱)。每一种都将单一模式作为_唯一_答案。

本文则不同。它涵盖了所有实际存在的模式,并坦诚地权衡了各种方案,提供了一个实际的决策框架,而不是"视情况而定",并通过生产环境和来自日常从事相关工作的工程师的社区反馈来验证所有内容。

我们先来探讨一下为什么这个问题比看起来更难。

为什么这个问题比看起来更难

Android 开发有一系列独特的限制,使得这个问题确实很难回答。这并非是开发者想得太复杂,而是因为这个平台迫使你去思考其他平台上不存在的问题。

根本矛盾在于:ViewModel 和 Composable 的生命周期不同。

ViewModel 的生命周期不会因配置更改而改变。Composable 的生命周期则会改变。当用户旋转手机时,Composable 对象会被销毁并重新创建,但 ViewModel 对象仍然存活。这是设计使然,也是 ViewModel 的核心价值所在,但这造成了一种不匹配,所有初始加载模式都必须面对这个问题。

以下是生命周期中的主要挑战,迫使每种方法都必须做出权衡:

  • 配置更改 :Composable 对象会被销毁并重新创建,但 ViewModel 对象仍然存在。任何 LaunchedEffect 都会重新触发,而任何 init{} 都不会。

  • 进程终止 + SavedStateHandle :系统可能会在后台终止你的应用。当用户返回时,ViewModel 对象会被重新创建。SavedStateHandle 是应对这种情况的机制,但并非所有模式都能完美地与之兼容。

  • 返回栈:当用户导航到另一个屏幕时,该屏幕的 Composable 组件可能会完全退出组合,但 ViewModel 仍然存活(作用域限定在导航图内)。当用户返回时,Composable 组件会重新进入组合,效果再次触发。

  • 协程单元测试 :有些模式使测试变得简单直接;而另一些则需要使用 TestScopeadvanceUntilIdle() 以及仔细的协程管理。

每种模式都至少会与上述限制之一进行权衡。没有捷径可走。

没错,repeatOnLifecycle 解决了集合层面的生命周期感知问题(当屏幕处于后台时停止收集),但它并没有解决加载触发器的位置问题。本文将探讨这个问题。

模式

以下每个模式都遵循相同的结构:它的外观、人们使用它的原因、真正的陷阱(不仅仅是"小心"),以及何时才是正确的选择。

模式 0 --- 代码异味

kotlin 复制代码
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    LaunchedEffect(Unit) {
        viewModel.loadData()  // Why here? What triggers this?
    }
    // ... UI
}

这是大多数教程都会教授的模式。它也是生产环境中最容易出问题的模式。

人们使用它的原因: 它很简单。当 Composable 进入组合状态时,它只运行一次。在理想情况下,它"开箱即用"。

真正的陷阱:

  • 它将业务意图隐藏在 UI 底层机制之后。 Composable 现在负责决定何时加载数据。这是一个伪装成生命周期事件的业务决策。

  • 它会在重新进入组合状态时再次触发。 离开页面,然后再回来,LaunchedEffect(Unit) 会再次触发。旋转设备,再次触发。ViewModel 可能已经加载了数据,但 Composable 并不知道。最终会导致重复的网络调用、竞态条件和状态闪烁。

  • 没有 Compose 测试框架,无法进行测试。 无法单独进行单元测试。加载触发器嵌入在 UI 层中。

  • 不支持重试或刷新。 需要添加更多状态管理来处理重试,这会进一步混淆 UI 层和业务层。

更深层次的问题: LaunchedEffect(Unit)组合驱动的,而非 UI 驱动的。 组合(将 Composable 添加到树的过程)是渲染系统的实现细节,而不是语义事件。

_ViewModel 应该响应业务语义:"屏幕打开"、"用户请求刷新"、"导航参数已更改"。它不应该对渲染机制做出反应:"这个可组合组件已添加到树中。"

这就是根本的不匹配之处。组合恰好与屏幕出现同时发生,但这并不意味着它是构建的正确抽象。这只是巧合,而非约定,而基于巧合构建会导致出现仅在返回栈导航、标签页切换和配置更改时才会出现的 bug。

最佳用途: 任何情况都不适合。总有更好的选择。

模式 1 --- init {}

kotlin 复制代码
class MyViewModel : ViewModel() {
    init {
        viewModelScope.launch { load() }
    }

    private suspend fun load() {
        // fetch data, update state
    }
}

当人们意识到模式 0 存在问题时,这是他们的第一反应。将加载操作移到 init{} 中,现在 ViewModel 拥有了它。简洁,对吧?

人们喜欢它的原因: 加载操作在 ViewModel 创建时发生。它不会因配置更改而丢失(ViewModel 不会在屏幕旋转时重新创建)。完全没有组合的副作用。UI 只需观察状态即可。

真正的陷阱:

  • 无法感知屏幕可见性。 ViewModel 在导航图实例化时创建,这可能发生在用户看到屏幕之前。如果你预取了 ViewModel,即使用户从未导航到该屏幕,init{} 也会立即触发。

  • 在单元测试中立即运行。 在测试中实例化 ViewModel 时,init{} 会立即触发。你需要使用 TestScopeadvanceUntilIdle() 来控制时间,这会增加每个测试的复杂性。

  • 失去条件加载控制。 使用 init{},加载总是会发生。你无法根据导航参数、屏幕状态或用户意图有条件地跳过它。注意:SavedStateHandle 在这里通过构造函数注入可以正常工作,这不是问题所在。问题在于 init{} 是无条件的。

  • 不支持重试或刷新。 与模式 0 一样,此模式没有内置的重试或下拉刷新机制。你需要添加一个单独的方法来实现这些功能,而这实际上已经接近模式 2 的一半了。

最适合: 简单的原型、无需重试的一次性屏幕,或能够接受这种权衡取舍的快速迭代团队。

模式 2 --- 显式 Action 分发(Clean MVVM / TOAD)

kotlin 复制代码
// Composable: signals lifecycle, NOT business logic
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    LaunchedEffect(Unit) {
        viewModel.onAction(Action.ScreenStarted)
    }

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // ... render uiState
}

// ViewModel: stays idle until told to start
class MyViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun onAction(action: Action) {
        when (action) {
            Action.ScreenStarted -> viewModelScope.launch { load() }
            Action.Retry -> viewModelScope.launch { load() }
        }
    }

    private suspend fun load() {
        _uiState.value = UiState.Loading
        // fetch data, update _uiState
    }
}

sealed interface Action {
    data object ScreenStarted : Action
    data object Retry : Action
}

等等,这难道不是用了 LaunchedEffect(Unit) 吗?我刚才不是说它有问题吗?

是的。但区别很重要:模式 0 将 LaunchedEffect 用作​​业务触发器。 模式 2 将其用作生命周期信号。 Composable 组件并不决定加载什么或何时加载,它只是发出"屏幕现在可见"的信号。ViewModel 决定如何处理这个信号。

人们喜欢它的原因:

  • 显式且可审计。 每次状态更改都可以追溯到指定的 Action。你可以搜索代码库中的 Action.ScreenStarted,准确地找到加载发生的位置和原因。

  • 易于测试。 在单元测试中,你可以直接调用 viewModel.onAction(Action.ScreenStarted)。无需 Compose 运行时、LaunchedEffect 或测试框架。

  • SavedStateHandle.toRoute<>() 完美兼容。 你可以在决定是否加载之前访问导航参数。

  • 一流的重试和刷新功能。 只需在 when 代码块中添加一行代码即可添加 Action.RetryAction.PullToRefresh

真正的陷阱:

  • init{} 更繁琐。 你需要在 Composable 中使用 Action 密封接口、onAction 方法和 LaunchedEffect。对于简单的屏幕来说,这可能显得过于复杂。

  • **这种模式值得投入。**一旦你的应用拥有超过几个屏幕,这种模式带来的一致性和可测试性优势将远远超过投入本身。

这种模式的一个更严格的变体是 TOAD(类型化对象操作分发),其中 ViewModel 将类型化的事件分发给外部处理类。它是一个纯粹的状态机,需要更高的规范性,学习曲线也更陡峭,但状态转换完全清晰明确。如果你的团队遵循 MVI 原则,那么 TOAD 就是模式 2 的逻辑极致体现。

最适合: 大多数生产应用。除非你有特殊原因需要选择其他方案,否则这是默认推荐方案。

模式 3 --- .onStart / .onSubscription + stateIn

kotlin 复制代码
class MyViewModel : ViewModel() {
    val uiState: StateFlow<UiState> = repository.getDataFlow()
        .onStart { emit(UiState.Loading) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UiState.Loading
        )
}

这是响应式纯粹主义者的答案:完全不在 Composable 中使用 LaunchedEffect。当 UI 订阅 StateFlow 时,加载开始;当屏幕消失超过 5 秒时,加载停止。ViewModel 是一个纯响应式管道。

人们喜欢它的原因:

  • 零 Composable 副作用。 Composable 只负责收集状态。没有 LaunchedEffect,没有生命周期信号,UI 层也没有命令式代码。

  • UI 观察时立即开始。 加载由订阅触发,而非显式调用。这在语义上很清晰:如果没有人观察,则不会发生任何事情。

  • 屏幕消失时停止。 WhileSubscribed(5_000) 表示在最后一个订阅者消失 5 秒后,上游流将被取消。不会有后台运行的幽灵网络调用。

  • 自然融入响应式链。 如果你的屏幕已经使用了 .combine.flatMapLatestSavedStateHandle.getStateFlow,那么添加 .onStart 只需一行代码,不会破坏响应式流程。

真正的陷阱:

  • 热→冷→热语义可能会让你措手不及。 当最后一个订阅者消失且超时到期时,流程会进入冷状态。当新的订阅者出现时,.onStart 会再次触发,这意味着加载可能会重新触发。对于大多数屏幕来说,这没问题(甚至很理想),但它会让用户措手不及。

  • 多个订阅者可能会触发两次 onStart 如果两个 Composable 对象观察同一个 StateFlowonStart 会为该流程触发一次,但基于 SharedFlow 的链的行为可能会有所不同。这时,.onSubscription 就派上用场了。

  • 没有内置重试机制。 你无法从外部向冷流发送请求。如果用户点击"重试",则需要单独的机制,这通常意味着需要使用模式 4。

  • 测试时序细节。 你需要使用 advanceUntilIdle() 来让流管道在单元测试中稳定下来。

onStart 与 onSubscription: 实际上,在 stateIn(WhileSubscribed) 和单个 UI 收集器的情况下,它们的行为完全相同。当可能附加多个收集器时,.onSubscription 在语义上更精确,因为它按订阅者触发,而不是按流启动触发。社区工程师报告称,在生产环境中使用 .onSubscription 没有出现任何问题。选择与你的流拓扑结构相匹配的即可。

最适合: 具有多个上游流的响应式密集型屏幕、对任何 Compose 副作用过敏的团队以及纯粹的转换管道 ViewModel。

模式 4 --- 触发模式

kotlin 复制代码
class MyViewModel : ViewModel() {
    private val retryTrigger = MutableSharedFlow<Unit>(
        extraBufferCapacity = 1
    )

    val uiState: StateFlow<UiState> = retryTrigger
        .onStart { emit(Unit) }  // auto-trigger on first subscription
        .transformLatest {
            emit(UiState.Loading)
            val result = repository.loadData()
            emit(result.fold(
                onSuccess = { UiState.Success(it) },
                onFailure = { UiState.Error(it.message) }
            ))
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UiState.Loading
        )

        fun retry() {
        retryTrigger.tryEmit(Unit)
    }
}

这是模式 3 的升级版。它结合了 .onStart + stateIn 的响应式优势,以及一流的重试和刷新支持,所有这些都集成在一个响应式管道中。

人们喜欢它的原因:

  • 所有操作都在同一个管道中完成。 初始加载、重试和下拉刷新都通过同一个 transformLatest 代码块进行。没有单独的方法,也没有重复的逻辑。

  • transformLatest 会取消正在进行的调用。 如果用户在之前的加载仍在进行时点击重试,则之前的协程会自动取消。避免竞态条件。

  • Compose 中无需使用 LaunchedEffect 与模式 3 的优势相同,UI 只需收集状态即可。

  • 声明式。 整个加载生命周期都以流程转换的形式表达,而不是命令式的方法调用。

真正的陷阱:

  • 更复杂的思维模型。 如果你的团队不熟悉 transformLatestSharedFlow 和响应式流程操作符,那么这种模式的学习曲线比模式 2 更陡峭。

  • 不要在外部链中添加额外的 onStart 一个常见的错误是在 transformLatest 之后的外部链中添加 .onStart { emit(UiState.Loading) },这会导致重复的事件触发。

最适合: 需要重试、下拉刷新或定期重新加载的屏幕,并结合响应式上游流程。

决策框架

这是我当初开始写这个系列时希望看到的流程图:

屏幕出现时是否需要加载数据? 是 → 请继续。

问题 1:是否需要重试/下拉刷新/多次重新加载触发器?

  • 是 → 模式 2 (显式操作分发)或 模式 4(基于触发器的响应式)。

  • 否 → 请继续查看问题 2。

问题 2:你的屏幕是否大量使用响应式操作? (多个上游流程,.combine.flatMapLatestSavedStateHandle.getStateFlow

  • 是 → 模式 3.onStart + stateIn)。自然地融入响应式流程。

  • 否 → 继续执行问题 3。

问题 3:你的团队是否注重严格的可审计性,确保每次状态更改都可追溯到指定的意图?

  • 是 → 模式 2 ,使用 MVVM 或 TOAD。LaunchedEffect(Unit) { onAction(ScreenStarted) } 在这里是安全且符合预期的。

  • 否 → 继续执行问题 4。

问题 4:这是一个简单的、一次性的加载操作,没有重试,并且团队规模较小或发展迅速吗?

  • 是 → 模式 1init{})。接受权衡:更难的单元测试,无法控制屏幕可见性。

  • 否 → 默认使用模式 2。仪式是值得的。

对比表

元教训

真正的代码异味并非来自 LaunchedEffect 本身。

而是将 UI 层用作命令式业务触发器

解决方案并非单一模式,而是一个清晰的约定:

  • ViewModel 拥有状态和逻辑。

  • Composable 观察状态并发出生命周期信号。

一旦明确了这一点,即使是 LaunchedEffect(Unit) 也可以很简洁,只要它发送的是生命周期信号(例如 Action.ScreenStarted),而不是直接触发业务逻辑。

这就是 Pattern 2 的优势所在。LaunchedEffect 仍然存在,但它承担着截然不同的功能。它是 Composable 组件发出"我现在在屏幕上"的信号,而不是"请去获取用户个人资料"。

Pattern 3Pattern 4 更进一步,完全消除了生命周期信号。ViewModel 响应的是订阅,而不是显式调用。两种方法都遵循相同的约定:ViewModel 拥有逻辑,UI 只负责显示。

你真的需要 ViewModel 吗?

这是整个系列中最尖锐的批评:

"如果剥离[ViewModel]的用途......你可能根本就不需要ViewModel,而可以用其他方式加载数据。"

这是一个合理的挑战,值得认真回答。

保留ViewModel的理由:

  • 配置变更的持久性。 ViewModel在轮换后仍然存在;Composable则不然。如果没有它,每次配置更改都需要重新获取数据。

  • 进程终止 + SavedStateHandle。 仅靠响应式流程无法在进程终止后序列化状态。ViewModel + SavedStateHandle 的组合才能实现这一点。

  • 可测试性边界。 ViewModel是单元测试中用虚拟存储库替换真实存储库的分界线。移除它,你的测试边界就会转移到Composable,或者完全消失。

  • 共享状态。 当同一屏幕上的多个 Composable 组件需要相同的数据时,ViewModel 自然而然地成为唯一的所有者。

质疑 ViewModel 的理由:

  • 对于真正无状态、只读且无副作用的屏幕,ViewModel 只是一种形式主义。

  • 对于简单的场景,使用简单的 produceState 或支持 Compose 的库(例如 MoleculeCircuit)会更简洁。

  • ViewModel 是一种模式,而非强制性要求。

客观评价: 模式 3 和 4 并没有剥夺 ViewModel 的用途,它们只是将加载逻辑保留在 ViewModel 内部,并以响应式而非命令式的方式触发。这仍然是 ViewModel 在履行其职责。问题仅仅在于它如何决定开始工作,而不是它是否拥有状态。

这种区别很重要。响应式模式并非证明 ViewModel 不必要,而是证明 ViewModel 可以更智能地决定何时开始工作。

结论

没有万全之策。Android 的限制、配置变更、进程终止、回退栈、重构等机制,都保证了每种模式都有其优缺点。任何告诉你并非如此的人,要么是在开发一个简单的应用,要么就是还没遇到过极端情况。

但"视情况而定"这种说法远远不够。这就是决策框架存在的意义:它将模糊的架构问题转化为四个具体的"是/否"判断,从而引导你找到合适的模式。

正确的模式应该是你的团队在六个月后仍然能够阅读、测试和理解的模式。它不是 Twitter 上流行的模式,也不是教程里教你的模式,而是符合你自身限制的模式。

如果你不确定,可以从"模式 2"开始。显式 action dispatch 方法适用于大多数生产应用,可扩展性强,并且如果你以后需要响应式重试,它能为你提供一条清晰的路径过渡到"模式 4"。虽然过程比较繁琐,但当你第一次调试到与负载相关的 bug 并能够准确追踪其触发原因时,它的价值就显而易见了。

感谢所有在本系列文章评论区提出宝贵意见的朋友们。正是你们的帮助,才使得这篇指南比我独自撰写的任何版本都更加完善。

本文源自四篇 LinkedIn 文章系列。如果你想阅读简短版本:

参考资料

Android / Jetpack

  • Jetpack Compose --- Android 的现代声明式 UI 工具包。

  • ViewModel --- 生命周期感知组件,即使配置发生更改也能继续运行。

  • SavedStateHandle --- 通过保存状态机制在进程终止后仍然有效的键值映射。

  • StateFlow / SharedFlow --- Kotlin 协程,用于可观察状态和事件流。

  • LaunchedEffect --- Compose 的副作用 API,在进入组合时运行一个挂起代码块。

  • collectAsStateWithLifecycle --- Compose 的生命周期感知 Flow 收集器。

  • repeatOnLifecycle --- 每次生命周期达到目标状态(例如,STARTED)时运行一个代码块。

  • produceState --- Compose API,用于将非 Compose 状态转换为 Compose State

  • Navigation Compose --- 为 Compose 提供类型安全的导航,包括用于参数解析的 toRoute<>()

其他架构库

  • Molecule (Cash App) --- 使用 Compose 运行时构建 StateFlow 流,无需 UI 树。适用于完全在 Compose 中管理展示逻辑/状态。

  • Circuit (Slack) --- 面向 Kotlin/Android 的 Compose 优先架构库:包含 presenter、screens、导航等。基于与 Molecule 类似的理念,但是一个更完整的应用框架。 文档

  • TOAD --- Typed Object Action Dispatch (Murtaza Khursheed) --- 一种 Kotlin 优先架构模式,它将复杂性从臃肿的 ViewModel 转移到类型化的、可分发的 Action 对象。这是模式 2 的逻辑极致体现。

  • Orbit MVI --- 一个简单、类型安全的 Kotlin MVI 框架,具有出色的多平台支持(包括通过 KMP 支持 iOS)。它是最流行且备受推荐的 MVI 库之一。

  • MVIKotlin (Arkadii Ivanov) --- 成熟且久经考验的 MVI 框架,具备时间旅行调试功能,并着重于 KMP(Kotlin 跨平台管理)。广泛应用于 Kotlin 多平台项目。文档

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
zh_xuan2 小时前
启动RN服务端口被占用
android·react native
Code-keys4 小时前
Android Codec2 Filter 算法模块开发指南
android·算法·音视频·视频编解码
y = xⁿ5 小时前
MySQL:count(1)与count(*)有什么区别,深分页问题
android·数据库·mysql
程序员陆业聪7 小时前
Android启动全景图:一次冷启动背后到底发生了什么
android
安卓程序员_谢伟光9 小时前
m3颜色定义
android·compose
麻辣璐璐10 小时前
EditText属性运用之适配RTL语言和LTR语言的输入习惯
android·xml·java·开发语言·安卓
北京自在科技10 小时前
谷歌 Find Hub 网页端全面升级:电脑可直接管理追踪器与耳机
android·ios·安卓·findmy
Rush-Rabbit10 小时前
魅族21Pro刷ColorOS16.0操作步骤
android
爪洼传承人10 小时前
AI工具MCP的配置,慢sql优化
android·数据库·sql