最近在几个 Compose 项目里做代码评审发现一个出现频率特别高的共性问题很多人知道要用LaunchedEffect但要么把它当成“包一层就没事”的万能药要么不理解 key 参数的含义写出了意料之外的无限重启。有一次看到一个页面初始化请求被触发了四五次定位到最后就是因为 key 传了一个每次重组都会新建的对象。这篇文章就把LaunchedEffect的作用、原理和使用场景系统梳理一遍帮大家把这个 API 吃透。这篇文章适合三类人读刚开始接触 Jetpack Compose 的开发者被重组和副作用绕晕过的同学以及希望在项目里少踩几个坑的实战派。我会从“为什么需要它”讲起再拆解底层机制、经典使用场景、容易混淆的相关 API最后聊聊真实项目中高频踩坑的排查思路。整个过程我会尽量用大白话配合可靠的代码片段确保你能直接照抄进项目里。1. 在组合函数里直接写请求代码为什么注定要出乱子很多人刚从 View 体系切到 Compose 时第一个直觉就是把网络请求、数据库读取这类逻辑直接写在组合函数体里。毕竟 kotlin 函数就是从上往下执行我在函数里调用viewModel.loadData()好像没什么问题但实际运行起来就发现这个请求莫名其妙被重复执行了好多次甚至页面旋转一下又重新请求。问题就出在“可组合函数不是普通函数”这件事上。1.1 可组合函数不是普通函数重组和丢弃随时可能发生在传统的 Android View 体系里Activity的onCreate在整个生命周期里一般只执行一次所以我们很自然地会把初始化逻辑放在里面。但 Compose 的组合函数不是这样。组合函数可以因为状态变化而重组也可以因为性能优化而被框架跳过最极端的情况下系统认为某个组合位置不再需要时还会直接把这一帧的 UI 从组合中扔掉。打个比方普通的函数像是工厂里的固定工位你按一次按钮它生产一次产品。而组合函数像是一个随时可能调整布局的流水线哪怕只是某个零件尺寸变了比如一个Text的文案变化整个工位都可能被重新安排一次。如果你的“生产动作”网络请求就写在工位上那么每次调整布局它都会跟着执行一遍。组合阶段还有另一个特点它不是“执行一次就记住结果”而是每次重组都从头跑一遍函数体然后对比前后差异更新真正变化的部分。这意味着函数体内任何一个非组合代码都可能被执行多次。注意这和“重组后 UI 自动更新”是两码事重组只是重新执行函数体来产生新的 UI 描述但函数体里的普通语句也一样会被反复执行。1.2 “函数体无论写什么都会重跑”带来的三种事故现场直接写在函数体里的副作用代码最典型的有三类事故。第一类是重复请求。比如下面的错误示例Composable fun UserProfileScreen(viewModel: UserViewModel) { // 危险写法每次重组都会重新请求 viewModel.loadUserProfile() val user by viewModel.user.observeAsState() Text(user?.name ?: 加载中) }这段代码里只要user状态还没回来任何其他状态变化触发重组loadUserProfile()就会再执行一次。如果 ViewModel 里没有做去重实际上就会发出去好几次网络请求。我在真实项目里见过登录页面因为一个Text的焦点状态变化登录接口被连续调用了十几次的情况服务器压力倒是小事用户看到重复弹窗才是真崩溃。第二类是无限循环。有些场景下你在组合函数体里更新了某个状态这个状态又恰好是触发重组的条件于是“函数体执行 → 状态更新 → 重组 → 函数体再执行 → 状态再更新”形成了一个死循环。写过var count by remember { mutableStateOf(0) }再在函数体里count的人应该秒懂。这种循环轻则界面卡死重则直接 App 崩溃。第三类是资源未释放。比如在函数体里注册了某个系统监听器但组合一旦被丢弃你根本没有合适的时机去注销它轻则内存泄漏重则引发空指针异常。你可能会说“我在函数体最后注销不就行了”但重组时函数体会重新执行你很难保证成对的注册和注销一定按正确顺序出现。所以Compose 提供了一系列专门处理副作用的 API其中LaunchedEffect是使用频率最高的那个。它的核心价值在于帮你把一段代码限定在一个明确的、可预测的生命周期里执行解决“什么时候跑、什么时候停、什么时候重跑”这三个问题。2. LaunchedEffect 是把副作用装进了一个会随 key 重启的协程容器LaunchedEffect的签名用大白话翻译就是你给我一个或多个 key再给我一段挂起函数我负责在合适的时机帮你启动协程并且在 key 变化时自动取消旧协程、启动新协程在组合离开时取消整个协程。Composable fun LaunchedEffect( key1: Any?, block: suspend CoroutineScope.() - Unit )源码里还有多个 key 的重载以及 vararg 版本但语义都是一样的。理解它要从三个角度切入协程作用域从哪来、生命周期到哪去、key 怎么影响重启。2.1 它的生命周期从哪来到哪去LaunchedEffect在实现上依赖 Compose 组合中的remember机制。当它第一次进入组合时组合会创建一个协程作用域这个作用域的调度上下文默认来自组合所在的CoroutineContext在普通 Android App 中通常就是AndroidUiDispatcher本质上是和主线程绑定的调度器。然后在作用域里启动你传进来的 block。这个作用域的生命周期和组合中的这个位置严格绑定。当LaunchedEffect所在的组合位置从组合中移除时作用域会被取消。所以你可以放心不会出现“组合都销毁了协程还在跑”的问题前提是你别自己手滑开了一个外部作用域。这里有个容易忽略的点LaunchedEffect的 block 是一个suspend CoroutineScope.() - Unit也就是说它里面可以直接调用挂起函数比如delay、withContext、各种 Flow 的 collect而且this就是协程作用域本身。这让它在写异步逻辑时有天然优势——不需要像传统写法那样维护一个Job去手动取消。2.2 key 怎么决定“重启”还是“不重启”key 是LaunchedEffect最精髓的设计也是新手最容易搞混的地方。它的规则是每次重组时Compose 会拿当前传入的 key 和上次的 key 做比较用 equals 判断如果发生了变化就会取消旧协程并用新 key 启动一个新协程如果 key 没有变化那么即使整个组合函数体重新执行了协程也不会重启。这个机制解决了一个痛点组合函数的函数体每次重组都会执行但我们往往只希望在某几个特定状态改变时才重新执行副作用。比如搜索框的场景输入内容每次变化都会导致重组但我们希望只有在用户输入停顿 300ms 后才真正发起搜索。这时把query作为 key就能精准控制“只有 query 变化才触发搜索逻辑”而不是每次重组都触发。另一种视角是把 key 理解为“这个副作用运行在哪个版本上”。key 变了说明版本号变了旧版本的工作就没意义了应该停掉换新版本。2.3 LaunchedEffect 不传 key 的差异有一个容易被忽略的细节LaunchedEffect { }这种写法其实是不传 key 的它会把 lambda 当作 blockkeys 数组为空。空数组永远等于空数组所以它永远不会因为重组而重启只在进入组合时执行一次离开组合时取消。而LaunchedEffect(Unit) { }显式传了一个Unit作为 key语义上非常接近都是“不依赖任何状态执行一次”。那两者有区别吗源码实现上LaunchedEffect(Unit)的 keys 数组是arrayOf(Unit)LaunchedEffect { }的 keys 数组是emptyArray()比较结果都是永不变化。实际使用区别不大但社区和官方示例更推荐写LaunchedEffect(Unit)因为一眼就能看出来“这是有意为之”而LaunchedEffect { }容易让读者误以为 lambda 是 key。我自己长期用下来也建议团队统一写LaunchedEffect(Unit)代码可读性更好。3. 从数据加载到输入防抖五个最常见的实战写法原理讲完就该上手了。这里我会列出LaunchedEffect在真实项目中出镜率最高的五个场景每个都给出可以直接抄的写法和注意事项。这些场景之间没有严格的递进关系你可以当成字典来查。3.1 场景一进入页面加载一次数据最基础也最常见的用法就是页面初始化时加载数据。比如你进到一个用户详情页需要根据userId拉取用户信息在页面存活期间只需要加载一次并且用户 ID 变化时要重新加载Composable fun UserDetailScreen(userId: String, viewModel: UserDetailViewModel) { val userState by viewModel.userState.collectAsState() LaunchedEffect(userId) { viewModel.loadUser(userId) } // 渲染 UI... }这里userId作为 key进入页面首次组合时会执行一次如果上层因为某种原因传入了一个新的userId比如从列表 A 跳到详情页再切换到列表 BLaunchedEffect会取消上一次加载协程并通过新 key 重新执行。这比传统 View 体系里在onCreate拿 intent 参数、然后在onNewIntent里再手动处理一遍数据刷新要优雅得多。注意如果viewModel.loadUser是一个普通挂起函数它是在LaunchedEffect的默认调度器主线程调度器上执行的。如果函数内部没有自己切线程网络请求前记得用withContext(Dispatchers.IO)包裹否则主线程会被阻塞。这个细节后面专门展开。3.2 场景二根据状态变化重新加载很多页面的数据不是只加载一次而是跟着某个筛选条件走。比如商品列表页用户选中分类后要重新拉数据这时把筛选状态作为 keyComposable fun ProductListScreen( categoryId: String, viewModel: ProductListViewModel ) { val products by viewModel.products.collectAsState() LaunchedEffect(categoryId) { viewModel.loadProducts(categoryId) } LazyColumn { items(products) { product - ProductItem(product) } } }当你切换分类时categoryId变化旧的协程会被取消新的加载协程启动。这个模式特别适合那种“参数一变就要刷新”的页面。但有一个要小心的地方如果categoryId变化非常频繁比如滑动条拖拽协程会被频繁取消和重启这本身也有开销。更合理的做法是对输入做防抖或节流而不是把每次变化都实时同步给LaunchedEffect。3.3 场景三安全地收集 Flow 事件流LaunchedEffect非常适合在组合内安全地收集Flow。假设 ViewModel 暴露了一个SharedFlow用于发送一次性事件比如打开弹窗、跳转页面你在可组合函数里收集它Composable fun MainScreen(viewModel: MainViewModel) { LaunchedEffect(Unit) { viewModel.uiEvent.collect { event - when (event) { is MainUiEvent.ShowSnackbar - { // 处理一次性事件 } is MainUiEvent.NavigateToDetail - { // 处理导航 } } } } }这样写的好处是只要组合还在事件流就一直在收集组合一旦被移除协程自动取消不用手动去onCleared()里取消。不过要提醒一句如果用LaunchedEffect(Unit)收集在配置变更比如屏幕旋转导致 Activity 重建时组合会重新创建事件流也会从头收集可能会重复消费同一个事件。这是单次事件框架的经典问题解决思路通常是给事件加唯一 ID或者用Channel、ConflatedBroadcastChannel的消费确认机制这里就不展开了。另外如果 ViewModel 暴露的是StateFlow官方现在推荐用collectAsStateWithLifecycle()替代手动 collect因为它能感知生命周期停止状态避免后台收集。但一次性事件流SharedFlow依然适合用LaunchedEffect收集。3.4 场景四搜索输入防抖这是LaunchedEffect最能体现价值的地方之一。实现搜索框输入防抖不需要引入额外的 RxJava 或者自己做 Handler 定时器直接让输入关键词作为 key配合delay就能实现Composable fun SearchScreen( query: String, onSearch: (String) - Unit ) { LaunchedEffect(query) { // 每次 query 变化都会取消上一次的 delay重新计时 delay(300) onSearch(query) } }原理很简单用户输入akey 变成a协程开始 300ms 倒计时输入变成abkey 变化旧协程被取消新协程重新 300ms 倒计时。只有用户停下来 300ms 后搜索动作才会真正执行。这就是天然的防抖机制而且无需清理任何资源协程取消时 delay 会被自动中断。有一点要特别说明onSearch如果引用了外部的latestQuery值要确保它在闭包里读到的是最新值。把query作为参数传入onSearch是最稳妥的方式避免引用已经被重组捕获的旧值造成“搜的是上一次的关键词”这种诡异问题。3.5 场景五轮询与定时刷新有些页面需要定时刷新数据比如股票行情、订单状态。LaunchedEffect配合while循环和delay也能实现简单的轮询Composable fun StockScreen(stockCode: String, viewModel: StockViewModel) { LaunchedEffect(stockCode) { while (isActive) { viewModel.refreshStock(stockCode) delay(5_000) } } }这里的isActive是CoroutineScope的扩展属性用于检查当前协程是否还在活跃状态。当 key 变化或组合离开时协程被取消delay抛出CancellationException循环退出。这个写法比while(true)更安全避免在协程已被取消但逻辑还在跑的情况下继续执行无关操作。需要提醒的是这种轮播式轮询只适合简单场景。在复杂的生产环境中建议使用Flow.periodic、repeatOnLifecycle或 WorkManager 来做更精细的控制避免页面处于后台时还持续刷新浪费资源。4. 别和 rememberCoroutineScope、DisposableEffect 混为一谈我在解答社区问题的时候发现很多人用LaunchedEffect写了一阵子后遇到“点击按钮发请求”这种需求就卡住了因为LaunchedEffect里写不了点击回调逻辑。还有人会问rememberCoroutineScope是不是可以完全替代LaunchedEffect这俩到底什么区别这一节就把几个容易混淆的 API 放一起讲清楚。4.1 一张表理清三个常用副作用 API先看总结表API是否提供协程作用域生命周期特征典型用途LaunchedEffect是作用域跟随组合位置进入组合时启动key 变化时重启离开组合时取消与组合生命周期绑定的立即执行的异步逻辑rememberCoroutineScope是作用域跟随组合位置进入组合时创建离开组合时取消不随重组或 key 重启在回调如 onClick中手动启动协程DisposableEffect否不提供协程作用域进入组合时执行离开组合时执行 onDispose 清理注册/反注册监听器、系统服务等资源清理场景LaunchedEffect是你希望“这段代码作为组合的一部分自动启动并管理生命周期”时的首选。rememberCoroutineScope则是你想在组合内保留一个协程作用域用于在事件回调里手动启动协程。至于DisposableEffect它没有协程功能但提供了一个可靠的onDispose清理时机适合处理那些必须成对出现的“注册/反注册”逻辑。下面分别看一下后两者的使用姿势。rememberCoroutineScope典型场景是点击事件里启动协程Composable fun LoginScreen(viewModel: LoginViewModel) { val scope rememberCoroutineScope() val loading by viewModel.loading.collectAsState() Button( onClick { scope.launch { viewModel.login() } }, enabled !loading ) { Text(if (loading) 登录中... else 登录) } }DisposableEffect典型场景是注册生命周期观察者Composable fun LifecycleAwareScreen( lifecycleOwner: LifecycleOwner, onResume: () - Unit ) { DisposableEffect(lifecycleOwner) { val observer LifecycleEventObserver { _, event - if (event Lifecycle.Event.ON_RESUME) { onResume() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } }4.2 点击回调里发请求到底该用哪个这个问题几乎每次技术分享都会被问到。结论放在前面点击回调里启动协程用rememberCoroutineScope不要用LaunchedEffect。原因是LaunchedEffect的代码块是在组合过程中自动执行的它的触发时机是“组合进入/ key 变化”而不是用户点击事件。你没法把一个 onClick 的业务写进LaunchedEffect里除非把点击通过状态提升变成一个 State变相触发LaunchedEffect但这样绕了一大圈可读性反而更差。如果你非要在LaunchedEffect里等一个点击信号常见写法是这样的Composable fun OrderScreen(viewModel: OrderViewModel) { var clickCount by remember { mutableStateOf(0) } LaunchedEffect(clickCount) { if (clickCount 0) { viewModel.submitOrder() } } Button(onClick { clickCount }) { Text(提交订单) } }这种写法虽然能跑但在并发和可维护性上都不如直接用rememberCoroutineScope。因为每次点击都会使 key 变化协程被取消重启如果上一次请求还没完成就被 cancel你就得自己处理取消时的资源问题。而rememberCoroutineScope里启动的协程除非作用域被取消否则可以正常并发执行你也可以自己维护 Job 列表做去重。所以我的建议很明确自动触发的异步逻辑用LaunchedEffect事件驱动的异步逻辑用rememberCoroutineScope。5. key 引发的“无限重启”和“陈旧闭包”两个高频坑的排查思路写LaunchedEffect不怕写错就怕写错了不知道哪里错。这一节我挑两个在真实项目里出现频率最高的坑完整还原一下排查链路和修复方式同时也聊一聊协程取消和清理代码之间的微妙关系。5.1 无限重启key 传了一个每次都新建的对象现象页面加载数据之后请求没有停而是一遍又一遍地重复执行甚至导致 UI 卡顿、内存持续上涨。很多人第一反应是 ViewModel 的问题但排查到最后发现是LaunchedEffect的 key 出了问题。看这段错误示例Composable fun ProductDetailScreen(product: Product, viewModel: ProductViewModel) { // 错误product 是 data class但如果是普通 class 且没有正确实现 equals // 每次重组都会产生新对象引用LaunchedEffect 判定 key 变化无限重启 LaunchedEffect(product) { viewModel.loadProduct(product.id) } }如果Product是一个普通的 class没有重写equals/hashCode那么即使两次Product数据内容完全相同只要它们是不同的对象实例LaunchedEffect在比较 key 时就会认为“发生了变化”从而取消旧协程、启动新协程。如果上层一直在重组并传入新对象就会陷入无限重启循环。排查链路通常是这样的先在LaunchedEffectblock 入口加日志确认 block 被执行了多少次。如果发现 block 频繁执行检查 key 的equals行为是否稳定。把 key 换成稳定且能唯一标识业务含义的值比如product.id而不是整个对象。如果必须传对象检查该对象是否实现了正确的equals/hashCode。修复方式LaunchedEffect(product.id) { viewModel.loadProduct(product.id) }再补充一个隐蔽性更强的变体有人会把一个MutableState类型当 key比如LaunchedEffect(userState) { }。MutableState的 equals 是基于引用比较的通常状态值变了状态对象还是那一个所以不会无限重启。但如果你用remember { mutableStateOf(Product()) }然后在别处又替换了整个MutableState对象就可能有意外问题。最好的习惯仍然是“只把那个真正驱动逻辑变化的值作为 key”。5.2 陈旧闭包没把变化状态放进 key另一个坑是block 里读到了过期的状态值。看这个例子Composable fun UserGreetingScreen(userName: String) { LaunchedEffect(Unit) { // 每次 userName 变化时这里的 userName 并不会更新 // 因为 LaunchedEffect(Unit) 只执行一次block 捕获的是第一次组合时的值 log(Hello, $userName) delay(1000) log(After delay: $userName) } }现象是userName从Tom变成Jerry后日志里打印的还是Tom。由于LaunchedEffect(Unit)不随 key 变化重启block 里的userName是第一次进入组合时捕获的旧值。这其实就是 Kotlin 闭包的经典问题lambda 捕获变量但不保证变量后续更新后的新值除非是MutableState并且在组合中读取时会触发读取跟踪但这里显然不是。解决方式有两种。一种是直接把这个状态放进 key 里让它变化时重启整个协程LaunchedEffect(userName) { log(Hello, $userName) delay(1000) log(After delay: $userName) }另一种是如果你需要最新值但不想重启协程可以把状态提升为MutableState在 block 内部读取最新的.valueComposable fun UserGreetingScreen(userName: String) { val latestUserName by rememberUpdatedState(userName) LaunchedEffect(Unit) { log(Hello, $latestUserName) delay(1000) log(After delay: $latestUserName) } }rememberUpdatedState是 Compose 专门为这种场景设计的 API它保证协程里读到的永远是当前最新的状态值。在比较底层的事件 handler 中经常用到。5.3 取消与 finally清理代码别再处理耗时请求协程取消是一个经常被误解的机制。很多人以为协程被取消后代码会立刻停止执行。实际上协程取消是“协作式”的它只会把协程标记为取消并让挂起点比如delay、网络请求的 await抛出CancellationException但如果你的代码没有在正确的挂起点做检查后面的逻辑可能还会继续执行。下面这段代码就藏着一个隐患LaunchedEffect(key) { try { viewModel.loadData() } finally { // 如果这里做耗时操作而非挂起协程取消后它依然会跑完 // 如果在这里调用挂起函数则会再次抛出 CancellationException cleanUp() } }finally在协程取消时会执行这本身没问题但如果你在finally里调用了一个挂起函数它会立刻再次抛出CancellationException导致清理逻辑根本没跑完。如果你在finally里做的是耗时同步操作比如写数据库或操作大文件取消并不会阻止它继续执行这期间协程实际上还占用着线程。更稳的做法是使用withContext(NonCancellable)来执行必须在取消后仍然完成的清理任务LaunchedEffect(key) { try { viewModel.loadData() } finally { withContext(NonCancellable) { // 即使协程已取消这里也能正常完成 cleanUpDatabase() } } }还有一个容易踩的坑在LaunchedEffect里用runCatching捕获了CancellationException。因为CancellationException是Exception的子类所以runCatching会把它当成普通异常吃掉导致协程无法正常取消后续代码继续执行。处理方式是在捕获时判断异常类型把CancellationException单独抛出去LaunchedEffect(key) { try { viewModel.loadData() } catch (e: CancellationException) { throw e // 重新抛出让协程正常取消 } catch (e: Exception) { // 处理真正的业务异常 viewModel.showError(e.message) } }这个细节不处理好轻则导致取消失效重则引发状态更新到已经销毁的页面出现崩溃。6. 调度器不是 IO以及让页面更省心的几个优化习惯最后一节聊几个让LaunchedEffect在真实项目中跑得更稳的优化习惯。这些内容不属于核心 API但直接影响线上稳定性和用户体验。6.1 默认调度器为什么不能做耗时操作LaunchedEffect默认运行的调度器是组合上下文中继承的调度器。在普通 Android 应用里它绑定的是主线程调度器。这意味着你在LaunchedEffect里直接写LaunchedEffect(Unit) { val result repository.fetchFromNetwork() // 耗时操作 viewModel.updateState(result) }如果fetchFromNetwork是一个同步阻塞方法主线程会被卡住轻则卡顿重则 ANR。正确的姿势是把耗时操作切到 IO 调度器LaunchedEffect(Unit) { val result withContext(Dispatchers.IO) { repository.fetchFromNetwork() } viewModel.updateState(result) }更推荐的做法是把线程切换下沉到数据层。即ViewModel 或 Repository 里的挂起函数内部自己切 IOUI 层保持简单。这样LaunchedEffect里就只是调用一个挂起函数线程切换对 UI 层透明代码也干净很多。6.2 轮询任务怎么写得稳isActive 与延迟的组合前面场景五已经写了一个轮询示例这里再补充一个细节轮询里的 5 秒延迟是从上一次任务结束开始计算还是从轮询周期开始计算两种语义在实际项目里完全不同。// 方式一任务执行完再等 5 秒总周期 任务耗时 5 秒 LaunchedEffect(key) { while (isActive) { val start System.currentTimeMillis() viewModel.refresh() val cost System.currentTimeMillis() - start if (cost 5_000) { delay(5_000 - cost) } } }这个写法保证刷新动作的间隔至少是 5 秒任务本身耗时不长时周期稳定在 5 秒左右。如果直接写while (isActive) { viewModel.refresh(); delay(5000) }任务每次耗时 500ms那么实际刷新周期就是 5.5 秒时间一长累计误差可能会影响某些强一致场景。不过说实话在普通业务里这种差异通常可以忽略我更建议把轮询做成一个独立 Flow 并用collectLatest来处理方便单元测试fun tickerFlow(period: Long): FlowUnit flow { while (true) { emit(Unit) delay(period) } } Composable fun StockScreen(viewModel: StockViewModel) { LaunchedEffect(Unit) { tickerFlow(5_000).collectLatest { viewModel.refresh() } } }这样逻辑更清晰取消和异常传播也更符合 Kotlin 协程的惯用法。6.3 和生命周期组件搭配时要注意什么LaunchedEffect的生命周期跟随“组合位置”但如果你在Activity重建或Navigation切换时组合位置被移除协程也会被取消这在大多数情况下是符合预期的。但也有一些特殊场景需要额外小心。比如你用LaunchedEffect收集一个冷流flow冷流的收集会启动数据生产一旦组合离开协程取消生产也停止。这通常没问题。但如果你用LaunchedEffect收集一个SharedFlow它不因收集者取消而停止生产数据会继续存在上游等下次组合回来时会收到一批积压数据。如果这些数据是敏感的一次性事件就可能出现重复处理。解决方向是区分“ UI 刷新数据”和“业务事件”。刷新数据建议用collectAsStateWithLifecycle()它内部已经处理了生命周期感知。业务事件则考虑用Channel并且容量设为BUFFERED或者给事件设计一个消费 ID。在 Compose 的世界里事件流和状态流的处理方式一定要分开混用迟早要踩坑。另一个实用习惯是如果一个页面里有多个互不依赖的副作用不要全部塞进一个大LaunchedEffect而是拆成多个// 不推荐 LaunchedEffect(Unit) { launch { viewModel.loadUser() } launch { viewModel.loadConfig() } } // 推荐 LaunchedEffect(Unit) { viewModel.loadUser() } LaunchedEffect(Unit) { viewModel.loadConfig() }拆开的好处是职责清晰每个副作用独立管理自己的生命周期也不会因为其中一个协程出现异常导致另一个任务被连带取消。异常处理时也能针对性地给不同的LaunchedEffect包 try/catch而不会互相干扰。写到这里LaunchedEffect的作用、原理、场景、对比和坑基本都讲透了。它本身不复杂复杂的是它背后那套“组合生命周期 协程”的心智模型。只要记住一句话自动触发的组合副作用交给LaunchedEffect事件驱动的协程交给rememberCoroutineScope需要成对清理的资源交给DisposableEffectkey 只放稳定可靠的业务标识。你的 Compose 代码会少掉一大半莫名其妙的 bug。