欢迎光临
我们一直在努力

Compose 副作用全解析:LaunchedEffect、SideEffect、DisposableEffect 辨析

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:

场景effect 体onDispose 体
注册广播接收器 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. 三者横向对比

维度LaunchedEffectSideEffectDisposableEffect
是否支持协程 ✅ 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. 总结

  • LaunchedEffect = 协程 + 组合生命周期,适合异步操作(加载数据、收集 Flow)
  • SideEffect = 无 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.kt
      • frameworks/support/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt(applyChanges 方法中的 sideEffect 分发)
    赞(0)
    未经允许不得转载:171主机测评 » Compose 副作用全解析:LaunchedEffect、SideEffect、DisposableEffect 辨析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址