Compose 副作用全解析:LaunchedEffect、SideEffect、DisposableEffect 辨析
一句话收益:读完本文,你将清楚每种副作用 API 的触发时机、生命周期绑定方式与适用场景,再也不会在副作用选型上纠结或踩坑。 适用版本:Compose 1.4+(BOM 2023.06+),Kotlin 1.8+ 阅读时长:约 18 分钟

1. 为什么 Compose 需要「副作用 API」?
在 Compose 的声明式模型中,组合函数(Composable)随时可能因状态变化而被重组(Recompose)。重组本质上是"重新执行函数体",因此直接在 Composable 里启动协程、注册监听器或执行一次性操作是危险的——你根本无法预测它会被执行几次,也无从控制它的清理时机。
副作用 API 解决的就是这个问题:在受控的生命周期节点,执行一次「跨越重组边界」的操作。
// ❌ 错误写法:直接在 Composable 里启动协程
@Composable
fun WrongExample() {
val scope = rememberCoroutineScope()
// 每次重组都会重复启动,产生协程泄漏
scope.launch { fetchData() }
}
// ✅ 正确写法:用 LaunchedEffect 保证只启动一次
@Composable
fun CorrectExample(userId: String) {
LaunchedEffect(userId) {
fetchData(userId) // userId 变化时才重新执行
}
}
Compose 提供了三个核心副作用 API,它们都位于 androidx.compose.runtime 包下:
副作用 API 选型树
─────────────────────────────────────────────────
需要协程?
├─ 是 → LaunchedEffect(key)
│ ● 随组合进入启动,随组合离开取消
│ ● key 变化时:取消旧协程 → 启动新协程
│
└─ 否 → 需要在重组后同步非 Compose 状态?
├─ 是(每次重组后)→ SideEffect
│ ● 仅在重组成功后执行,失败则不执行
│
└─ 是(进入/离开时)→ DisposableEffect(key)
● 进入时执行 effect 体
● key 变化或离开组合时执行 onDispose
2. LaunchedEffect:协程副作用
2.1 源码与原理
LaunchedEffect 定义在 androidx.compose.runtime.Effects.kt:
// AOSP: frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.kt
@Composable
fun LaunchedEffect(
vararg keys: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(*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) ──运行中──► cancel
2.2 适用场景
| 页面首次加载数据 | key = Unit 或 key = true,仅执行一次 |
| 依赖特定参数的数据请求 | key = userId,userId 变化时重新请求 |
| 监听 Flow/Channel | 在协程里 collect,离开时自动取消 |
| 页面导航触发动画 | 在目标页显示时启动动画协程 |
2.3 典型用法与常见错误
场景:根据 userId 加载用户信息
@Composable
fun UserProfile(userId: String, viewModel: UserViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// ✅ userId 作为 key,切换用户时自动重新加载
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
when (uiState) {
is Loading -> CircularProgressIndicator()
is Success -> UserContent(uiState.data)
is Error -> ErrorView(uiState.message)
}
}
错误写法 → 问题 → 正确写法(三联组)
// ❌ 错误:key = Unit 但依赖外部变化的参数
@Composable
fun WrongUserProfile(userId: String, viewModel: UserViewModel) {
// userId 切换时,这个 LaunchedEffect 不会重新执行!
// 因为 Unit 作为 key 永远不变
LaunchedEffect(Unit) {
viewModel.loadUser(userId) // 用了闭包捕获的 userId,但不会响应变化
}
}
// 问题:userId 从 "alice" 切换到 "bob" 时,UI 仍展示 alice 的数据
// ✅ 正确:将变化的依赖作为 key
@Composable
fun CorrectUserProfile(userId: String, viewModel: UserViewModel) {
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
}
收集 Flow 的正确姿势:
@Composable
fun EventConsumer(viewModel: MyViewModel) {
val context = LocalContext.current
LaunchedEffect(Unit) {
// ✅ 在协程里收集一次性事件 Flow
viewModel.events.collect { event ->
when (event) {
is ShowToast -> Toast.makeText(context, event.msg, Toast.LENGTH_SHORT).show()
is Navigate -> { /* 导航逻辑 */ }
}
}
}
}
3. SideEffect:轻量同步副作用
3.1 源码与原理
// AOSP: frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.kt
@Composable
fun SideEffect(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 状态:
@Composable
fun AnalyticsTracker(screenName: String, analytics: AnalyticsService) {
// ✅ 每次重组成功后,将当前页面名同步给 Analytics SDK
// Analytics SDK 不在 Compose 管理范围内,需要主动推送
SideEffect {
analytics.setCurrentScreen(screenName)
}
}
@Composable
fun FirebaseUserSync(user: User, firebaseAnalytics: FirebaseAnalytics) {
SideEffect {
// 将 Compose 状态同步给 Firebase(非 Compose 对象)
firebaseAnalytics.setUserId(user.id)
firebaseAnalytics.setUserProperty("plan", user.plan)
}
}
错误写法 → 问题 → 正确写法:
// ❌ 错误:用 SideEffect 做耗时操作
@Composable
fun WrongNetworkCall(url: String) {
SideEffect {
// SideEffect 在主线程同步执行,网络请求会阻塞 UI!
val result = runBlocking { api.fetch(url) }
}
}
// 问题:主线程阻塞,ANR 风险
// ✅ 正确:耗时操作用 LaunchedEffect(在协程里)
@Composable
fun CorrectNetworkCall(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.kt
@Composable
fun DisposableEffect(
vararg keys: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
) {
remember(*keys) { DisposableEffectImpl(effect) }
}
DisposableEffectImpl 同样实现 RememberObserver:
DisposableEffect 生命周期回调
─────────────────────────────────────────
onRemembered() → 执行 effect 体,获得 DisposableEffectResult(含 onDispose lambda)
onForgotten() → 调用 onDispose()
onAbandoned() → 调用 onDispose()
key 变化时的完整流程:
key 变化
│
▼
1. 旧 DisposableEffectImpl.onForgotten() → 调用旧 onDispose()
2. 创建新 DisposableEffectImpl
3. 新 DisposableEffectImpl.onRemembered() → 执行 effect 体
4. 获得新 onDispose
DisposableEffect 生命周期示意
──────────────────────────────────────────────────────
进入组合 key 变化 离开组合
│ │ │
▼ ▼ ▼
effect() ────── onDispose() onDispose()
│
effect() ──────────
4.2 适用场景
凡是"注册了就必须注销"的操作,都应该用 DisposableEffect:
| 注册广播接收器 | context.registerReceiver() | context.unregisterReceiver() |
| 添加生命周期观察者 | lifecycle.addObserver() | lifecycle.removeObserver() |
| 注册传感器监听 | sensorManager.registerListener() | sensorManager.unregisterListener() |
| 订阅自定义事件总线 | bus.subscribe() | bus.unsubscribe() |
| 初始化第三方 View | 初始化操作 | 释放操作 |
4.3 典型用法
场景:监听网络状态变化
@Composable
fun NetworkStatusBanner(onNetworkChange: (Boolean) -> Unit) {
val context = LocalContext.current
DisposableEffect(Unit) {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
onNetworkChange(true)
}
override fun onLost(network: Network) {
onNetworkChange(false)
}
}
val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
// ✅ effect 体:注册回调
connectivityManager.registerNetworkCallback(request, networkCallback)
onDispose {
// ✅ onDispose:取消注册,防止内存泄漏
connectivityManager.unregisterNetworkCallback(networkCallback)
}
}
}
场景:监听 Lifecycle 事件
@Composable
fun LifecycleEventLogger(lifecycle: Lifecycle) {
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
Log.d("Lifecycle", "Event: $event")
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
}
}
}
错误写法 → 问题 → 正确写法:
// ❌ 错误:DisposableEffect 中忘记 onDispose
@Composable
fun WrongSensorEffect(sensorManager: SensorManager) {
val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) { /* … */ }
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { /* … */ }
}
DisposableEffect(Unit) {
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
onDispose { /* ❌ 忘记 unregisterListener! */ }
}
}
// 问题:页面离开后,listener 仍在监听,消耗电量,内存泄漏
// ✅ 正确:始终在 onDispose 中清理资源
@Composable
fun CorrectSensorEffect(sensorManager: SensorManager) {
val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val listener = remember {
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) { /* … */ }
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { /* … */ }
}
}
DisposableEffect(sensorManager) {
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
onDispose {
sensorManager.unregisterListener(listener) // ✅ 必须清理
}
}
}
5. 三者横向对比
| 是否支持协程 | ✅ suspend 函数 | ❌ 普通函数 | ❌ 普通函数 |
| 执行时机 | 进入组合后在协程中执行 | 重组成功后同步执行 | 进入组合时执行 |
| 清理机制 | 离开/key 变化时取消协程 | 无清理 | onDispose 回调 |
| 是否有 key | ✅ | ❌(无 key,每次重组) | ✅ |
| 主线程阻塞风险 | 无(在协程中) | ⚠️ 有(同步执行) | ⚠️ 有(同步执行) |
| 典型用途 | 数据加载、Flow 收集 | 同步状态到非 Compose 对象 | 注册/注销资源 |
6. 最佳实践
实践 1:key 的选择决定执行频率
做法:仅将真正影响 effect 逻辑的变量作为 key。 原因:key 变化会导致 effect 取消并重新执行,多余的 key 会导致不必要的重复执行;过少的 key 会导致 effect 使用过时的数据(stale closure)。 对比:若将整个 state 对象作为 key,state 中任何字段的变化都会触发重启,可能导致频繁取消进行中的网络请求。
// ❌ key 太宽泛:整个 state 变化都触发重启
LaunchedEffect(uiState) { /* … */ }
// ✅ 精准 key:只有 userId 变化时才重启
LaunchedEffect(uiState.userId) { /* … */ }
实践 2:DisposableEffect 的 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 上下文中调用。
@Composable
fun SubmitButton(onSubmit: suspend () -> Unit) {
val scope = rememberCoroutineScope() // ✅ 给事件处理用
Button(onClick = {
scope.launch { onSubmit() } // ✅ 命令式触发
}) {
Text("提交")
}
}
7. 常见坑点
坑 1:LaunchedEffect key 使用 lambda 或不稳定对象
现象:LaunchedEffect 在每次重组后都重新执行,数据被反复加载。 原因:传入了每次重组都会创建新实例的 lambda 或数据类(equals 未正确实现)作为 key,导致 key 每次都"变化"。 复现:LaunchedEffect(Pair(a, b)) { … } — Pair 每次重组新建,key 永远不等。 解决:使用基本类型或 data class(正确实现 equals)作为 key;多个 key 直接用 vararg:LaunchedEffect(a, b) { … }。
坑 2:DisposableEffect onDispose 中使用过时的变量
现象:onDispose 执行时,使用的是旧值而非当前值,导致清理不彻底。 原因:Kotlin lambda 闭包捕获的是变量引用,但在 Composable 里对于重组间变化的值,需要注意捕获时机。 复现:在 DisposableEffect 中闭包捕获了 listener,但后续重组创建了新 listener,onDispose 清理的是旧引用。 解决:将需要在 onDispose 中使用的对象用 remember 持久化,确保引用稳定。
坑 3:SideEffect 依赖重组顺序
现象:多个组件的 SideEffect 执行顺序与预期不符,状态同步出现竞态。 原因:Compose 的重组顺序不保证与组件树顺序完全一致,SideEffect 的执行顺序依赖重组完成顺序。 复现:父组件和子组件都有 SideEffect 修改同一个外部对象。 解决:避免多个 SideEffect 修改同一外部对象;将协调逻辑上移到 ViewModel 层。
坑 4:在 LaunchedEffect 中调用 UI 操作后的状态更新死锁
现象:协程挂起后更新状态,触发重组,重组又启动协程,循环往复导致无限重组。 原因:LaunchedEffect 的 key 是受协程更新影响的状态,状态更新→重组→key 变化→协程重启→状态更新…… 复现:LaunchedEffect(someState) { someState = newValue } — 永远在循环。 解决:确保 LaunchedEffect 的 key 是"输入"(驱动 effect),而非"输出"(被 effect 修改的状态)。
8. 总结
核心结论:副作用 API 的本质是让不纯的操作(IO、注册、协程)在 Compose 可控的生命周期节点运行,选型时先问"需要协程吗?需要清理吗?"两个问题即可定位。
参考资料
- Compose 副作用官方文档
- Compose API 设计指南 – 副作用
- AOSP 源码路径:
- frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.kt
- frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt(applyChanges 方法中的 sideEffect 分发)




