Compose 副作用全解析LaunchedEffect、SideEffect、DisposableEffect 辨析一句话收益读完本文你将清楚每种副作用 API 的触发时机、生命周期绑定方式与适用场景再也不会在副作用选型上纠结或踩坑。适用版本Compose 1.4BOM 2023.06Kotlin 1.8阅读时长约 18 分钟1. 为什么 Compose 需要「副作用 API」在 Compose 的声明式模型中组合函数Composable随时可能因状态变化而被重组Recompose。重组本质上是重新执行函数体因此直接在 Composable 里启动协程、注册监听器或执行一次性操作是危险的——你根本无法预测它会被执行几次也无从控制它的清理时机。副作用 API 解决的就是这个问题在受控的生命周期节点执行一次「跨越重组边界」的操作。// ❌ 错误写法直接在 Composable 里启动协程ComposablefunWrongExample(){valscoperememberCoroutineScope()// 每次重组都会重复启动产生协程泄漏scope.launch{fetchData()}}// ✅ 正确写法用 LaunchedEffect 保证只启动一次ComposablefunCorrectExample(userId:String){LaunchedEffect(userId){fetchData(userId)// userId 变化时才重新执行}}Compose 提供了三个核心副作用 API它们都位于androidx.compose.runtime包下副作用 API 选型树 ───────────────────────────────────────────────── 需要协程 ├─ 是 → LaunchedEffect(key) │ ● 随组合进入启动随组合离开取消 │ ● key 变化时取消旧协程 → 启动新协程 │ └─ 否 → 需要在重组后同步非 Compose 状态 ├─ 是每次重组后→ SideEffect │ ● 仅在重组成功后执行失败则不执行 │ └─ 是进入/离开时→ DisposableEffect(key) ● 进入时执行 effect 体 ● key 变化或离开组合时执行 onDispose2. LaunchedEffect协程副作用2.1 源码与原理LaunchedEffect定义在androidx.compose.runtime.Effects.kt// AOSP: frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.ktComposablefunLaunchedEffect(varargkeys:Any?,block:suspendCoroutineScope.()-Unit){valapplyContextcurrentComposer.applyCoroutineContextremember(*keys){LaunchedEffectImpl(applyContext,block)}}LaunchedEffectImpl实现了RememberObserver接口RememberObserver 生命周期回调 ──────────────────────────────── onRemembered() → 进入组合调用 coroutineScope.launch(block) onForgotten() → 离开组合调用 job.cancel() onAbandoned() → 组合放弃未挂载调用 job.cancel()key 变化时remember(*keys)会触发旧LaunchedEffectImpl的onForgotten取消旧协程并创建新实例执行onRemembered启动新协程。LaunchedEffect 生命周期示意 ─────────────────────────────────────────────────────────── Composition 进入 key 变化 Composition 离开 │ │ │ ▼ ▼ ▼ launch(block) ──运行中──► cancel → launch(block) ──运行中──► cancel2.2 适用场景场景说明页面首次加载数据key Unit或key true仅执行一次依赖特定参数的数据请求key userIduserId 变化时重新请求监听 Flow/Channel在协程里collect离开时自动取消页面导航触发动画在目标页显示时启动动画协程2.3 典型用法与常见错误场景根据 userId 加载用户信息ComposablefunUserProfile(userId:String,viewModel:UserViewModelhiltViewModel()){valuiStatebyviewModel.uiState.collectAsStateWithLifecycle()// ✅ userId 作为 key切换用户时自动重新加载LaunchedEffect(userId){viewModel.loadUser(userId)}when(uiState){isLoading-CircularProgressIndicator()isSuccess-UserContent(uiState.data)isError-ErrorView(uiState.message)}}错误写法 → 问题 → 正确写法三联组// ❌ 错误key Unit 但依赖外部变化的参数ComposablefunWrongUserProfile(userId:String,viewModel:UserViewModel){// userId 切换时这个 LaunchedEffect 不会重新执行// 因为 Unit 作为 key 永远不变LaunchedEffect(Unit){viewModel.loadUser(userId)// 用了闭包捕获的 userId但不会响应变化}}// 问题userId 从 alice 切换到 bob 时UI 仍展示 alice 的数据// ✅ 正确将变化的依赖作为 keyComposablefunCorrectUserProfile(userId:String,viewModel:UserViewModel){LaunchedEffect(userId){viewModel.loadUser(userId)}}收集 Flow 的正确姿势ComposablefunEventConsumer(viewModel:MyViewModel){valcontextLocalContext.currentLaunchedEffect(Unit){// ✅ 在协程里收集一次性事件 FlowviewModel.events.collect{event-when(event){isShowToast-Toast.makeText(context,event.msg,Toast.LENGTH_SHORT).show()isNavigate-{/* 导航逻辑 */}}}}}3. SideEffect轻量同步副作用3.1 源码与原理// AOSP: frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.ktComposablefunSideEffect(effect:()-Unit){currentComposer.recordSideEffect(effect)}recordSideEffect会将effect注册到当前Composition的副作用列表中。关键点只有当重组成功完成apply 阶段结束时这些 effect 才会被顺序调用。如果重组被丢弃effect 不会执行。SideEffect 执行时序 ──────────────────────────────────────────────────────── 重组开始 │ ▼ Composable 函数体执行构建 SlotTable 差异 │ ▼ apply 阶段将差异写入 SlotTable── 失败 ──► SideEffect 不执行 │ ▼成功 SideEffect 按注册顺序同步执行重要特性无 key每次重组成功后都执行同步执行不在协程里不能调用 suspend 函数无清理没有 dispose 回调3.2 适用场景SideEffect 最典型的用途是向非 Compose 管理的对象同步 Compose 状态ComposablefunAnalyticsTracker(screenName:String,analytics:AnalyticsService){// ✅ 每次重组成功后将当前页面名同步给 Analytics SDK// Analytics SDK 不在 Compose 管理范围内需要主动推送SideEffect{analytics.setCurrentScreen(screenName)}}ComposablefunFirebaseUserSync(user:User,firebaseAnalytics:FirebaseAnalytics){SideEffect{// 将 Compose 状态同步给 Firebase非 Compose 对象firebaseAnalytics.setUserId(user.id)firebaseAnalytics.setUserProperty(plan,user.plan)}}错误写法 → 问题 → 正确写法// ❌ 错误用 SideEffect 做耗时操作ComposablefunWrongNetworkCall(url:String){SideEffect{// SideEffect 在主线程同步执行网络请求会阻塞 UIvalresultrunBlocking{api.fetch(url)}}}// 问题主线程阻塞ANR 风险// ✅ 正确耗时操作用 LaunchedEffect在协程里ComposablefunCorrectNetworkCall(url:String,viewModel:MyViewModel){LaunchedEffect(url){viewModel.fetchData(url)// 在后台线程执行}}4. DisposableEffect有清理的副作用4.1 源码与原理// AOSP: frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.ktComposablefunDisposableEffect(varargkeys:Any?,effect:DisposableEffectScope.()-DisposableEffectResult){remember(*keys){DisposableEffectImpl(effect)}}DisposableEffectImpl同样实现RememberObserverDisposableEffect 生命周期回调 ───────────────────────────────────────── onRemembered() → 执行 effect 体获得 DisposableEffectResult含 onDispose lambda onForgotten() → 调用 onDispose() onAbandoned() → 调用 onDispose()key 变化时的完整流程key 变化 │ ▼ 1. 旧 DisposableEffectImpl.onForgotten() → 调用旧 onDispose() 2. 创建新 DisposableEffectImpl 3. 新 DisposableEffectImpl.onRemembered() → 执行 effect 体 4. 获得新 onDisposeDisposableEffect 生命周期示意 ────────────────────────────────────────────────────── 进入组合 key 变化 离开组合 │ │ │ ▼ ▼ ▼ effect() ────── onDispose() onDispose() │ effect() ──────────4.2 适用场景凡是注册了就必须注销的操作都应该用 DisposableEffect场景effect 体onDispose 体注册广播接收器context.registerReceiver()context.unregisterReceiver()添加生命周期观察者lifecycle.addObserver()lifecycle.removeObserver()注册传感器监听sensorManager.registerListener()sensorManager.unregisterListener()订阅自定义事件总线bus.subscribe()bus.unsubscribe()初始化第三方 View初始化操作释放操作4.3 典型用法场景监听网络状态变化ComposablefunNetworkStatusBanner(onNetworkChange:(Boolean)-Unit){valcontextLocalContext.currentDisposableEffect(Unit){valnetworkCallbackobject:ConnectivityManager.NetworkCallback(){overridefunonAvailable(network:Network){onNetworkChange(true)}overridefunonLost(network:Network){onNetworkChange(false)}}valconnectivityManagercontext.getSystemService(ConnectivityManager::class.java)valrequestNetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build()// ✅ effect 体注册回调connectivityManager.registerNetworkCallback(request,networkCallback)onDispose{// ✅ onDispose取消注册防止内存泄漏connectivityManager.unregisterNetworkCallback(networkCallback)}}}场景监听 Lifecycle 事件ComposablefunLifecycleEventLogger(lifecycle:Lifecycle){DisposableEffect(lifecycle){valobserverLifecycleEventObserver{_,event-Log.d(Lifecycle,Event:$event)}lifecycle.addObserver(observer)onDispose{lifecycle.removeObserver(observer)}}}错误写法 → 问题 → 正确写法// ❌ 错误DisposableEffect 中忘记 onDisposeComposablefunWrongSensorEffect(sensorManager:SensorManager){valsensorsensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)vallistenerobject:SensorEventListener{overridefunonSensorChanged(event:SensorEvent){/* ... */}overridefunonAccuracyChanged(sensor:Sensor,accuracy:Int){/* ... */}}DisposableEffect(Unit){sensorManager.registerListener(listener,sensor,SensorManager.SENSOR_DELAY_UI)onDispose{/* ❌ 忘记 unregisterListener */}}}// 问题页面离开后listener 仍在监听消耗电量内存泄漏// ✅ 正确始终在 onDispose 中清理资源ComposablefunCorrectSensorEffect(sensorManager:SensorManager){valsensorsensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)vallistenerremember{object:SensorEventListener{overridefunonSensorChanged(event:SensorEvent){/* ... */}overridefunonAccuracyChanged(sensor:Sensor,accuracy:Int){/* ... */}}}DisposableEffect(sensorManager){sensorManager.registerListener(listener,sensor,SensorManager.SENSOR_DELAY_UI)onDispose{sensorManager.unregisterListener(listener)// ✅ 必须清理}}}5. 三者横向对比维度LaunchedEffectSideEffectDisposableEffect是否支持协程✅ suspend 函数❌ 普通函数❌ 普通函数执行时机进入组合后在协程中执行重组成功后同步执行进入组合时执行清理机制离开/key 变化时取消协程无清理onDispose 回调是否有 key✅❌无 key每次重组✅主线程阻塞风险无在协程中⚠️ 有同步执行⚠️ 有同步执行典型用途数据加载、Flow 收集同步状态到非 Compose 对象注册/注销资源6. 最佳实践实践 1key 的选择决定执行频率做法仅将真正影响 effect 逻辑的变量作为 key。原因key 变化会导致 effect 取消并重新执行多余的 key 会导致不必要的重复执行过少的 key 会导致 effect 使用过时的数据stale closure。对比若将整个 state 对象作为 keystate 中任何字段的变化都会触发重启可能导致频繁取消进行中的网络请求。// ❌ key 太宽泛整个 state 变化都触发重启LaunchedEffect(uiState){/* ... */}// ✅ 精准 key只有 userId 变化时才重启LaunchedEffect(uiState.userId){/* ... */}实践 2DisposableEffect 的 key 与注册对象保持一致做法将注册目标对象如lifecycle、sensorManager作为 DisposableEffect 的 key。原因当注册目标对象引用变化时旧的注册需要先清理再对新对象重新注册。若 key 与注册对象不一致可能出现对旧对象的泄漏注册。对比若写DisposableEffect(Unit)当lifecycle对象引用更换时旧的 observer 不会从旧 lifecycle 移除。实践 3避免在 SideEffect 中调用耗时操作做法SideEffect 只做轻量的状态同步赋值、属性设置禁止 I/O 或复杂计算。原因SideEffect 在主线程同步执行阻塞会导致帧率下降甚至 ANR。对比若在 SideEffect 中做 SharedPreferences 写操作即使很快频繁重组时的累积耗时也会造成 jank。实践 4使用 rememberCoroutineScope 做用户触发的副作用做法用户点击等手势触发的协程操作应使用rememberCoroutineScope而非LaunchedEffect。原因LaunchedEffect是声明式的组合驱动而点击事件是命令式的事件驱动。混用会导致点击无法触发或执行多次。对比若在 onClick 里调用LaunchedEffect会报编译错误因为LaunchedEffect只能在 Composable 上下文中调用。ComposablefunSubmitButton(onSubmit:suspend()-Unit){valscoperememberCoroutineScope()// ✅ 给事件处理用Button(onClick{scope.launch{onSubmit()}// ✅ 命令式触发}){Text(提交)}}7. 常见坑点坑 1LaunchedEffect key 使用 lambda 或不稳定对象现象LaunchedEffect在每次重组后都重新执行数据被反复加载。原因传入了每次重组都会创建新实例的 lambda 或数据类equals 未正确实现作为 key导致 key 每次都变化。复现LaunchedEffect(Pair(a, b)) { ... }—Pair每次重组新建key 永远不等。解决使用基本类型或data class正确实现equals作为 key多个 key 直接用varargLaunchedEffect(a, b) { ... }。坑 2DisposableEffect onDispose 中使用过时的变量现象onDispose 执行时使用的是旧值而非当前值导致清理不彻底。原因Kotlin lambda 闭包捕获的是变量引用但在 Composable 里对于重组间变化的值需要注意捕获时机。复现在 DisposableEffect 中闭包捕获了listener但后续重组创建了新listeneronDispose 清理的是旧引用。解决将需要在 onDispose 中使用的对象用remember持久化确保引用稳定。坑 3SideEffect 依赖重组顺序现象多个组件的 SideEffect 执行顺序与预期不符状态同步出现竞态。原因Compose 的重组顺序不保证与组件树顺序完全一致SideEffect 的执行顺序依赖重组完成顺序。复现父组件和子组件都有 SideEffect 修改同一个外部对象。解决避免多个 SideEffect 修改同一外部对象将协调逻辑上移到 ViewModel 层。坑 4在 LaunchedEffect 中调用 UI 操作后的状态更新死锁现象协程挂起后更新状态触发重组重组又启动协程循环往复导致无限重组。原因LaunchedEffect 的 key 是受协程更新影响的状态状态更新→重组→key 变化→协程重启→状态更新……复现LaunchedEffect(someState) { someState newValue }— 永远在循环。解决确保 LaunchedEffect 的 key 是输入驱动 effect而非输出被 effect 修改的状态。8. 总结LaunchedEffect 协程 组合生命周期适合异步操作加载数据、收集 FlowSideEffect 无 key、无清理、轻量同步适合向非 Compose 对象推送状态DisposableEffect 有进入、有清理适合资源的注册与注销key 选型是核心精准的 key 决定 effect 何时重启影响功能正确性与性能用户触发操作用 rememberCoroutineScope声明式初始化用 LaunchedEffect核心结论副作用 API 的本质是让不纯的操作IO、注册、协程在 Compose 可控的生命周期节点运行选型时先问需要协程吗需要清理吗两个问题即可定位。参考资料Compose 副作用官方文档Compose API 设计指南 - 副作用AOSP 源码路径frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.ktframeworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.ktapplyChanges 方法中的 sideEffect 分发