Compose 的副作用 API:LaunchedEffect、DisposableEffect、SideEffect 到底什么时候用
Compose 的副作用 API:LaunchedEffect、DisposableEffect、SideEffect 到底什么时候用
从一个内存泄漏说起
去年维护一个视频播放器模块时,我踩了个坑。场景很简单:用户点击全屏按钮,播放器从 Activity 的小窗口模式切换到全屏 Activity。切回来后,播放进度对不上,而且 logcat 里频繁报 IllegalStateException: Already resumed,堆栈指向一个 LaunchedEffect 里的 suspendCancellableCoroutine。
问题出在哪?我回溯了代码,发现是有人(可能是三个月前的我自己)在自定义的 VideoPlayer Composable 里写了这么一段:
LaunchedEffect(playerViewModel) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
// 回调里 resume 了一个 continuation
}
}
exoPlayer.addListener(listener)
}看到问题了没?LaunchedEffect 的 key 是 playerViewModel,但 exoPlayer 实例在 ViewModel 重建时可能复用也可能新建。更致命的是,LaunchedEffect 在重组时不会清理旧的 listener,只有在 key 变化或 Composable 离开组合时才会取消。这里 listener 被重复添加,continuation 被重复 resume,直接炸。
修复方案是把 addListener/removeListener 配对放到 DisposableEffect 里。但这件事让我意识到,Compose 这三个副作用 API 虽然名字都带 "Effect",执行时机和适用场景完全不同,混用就是埋雷。接下来我按自己的理解顺序讲,不是官方文档的顺序。
LaunchedEffect:不是 "launch 一个协程" 那么简单
官方文档说 LaunchedEffect 会在进入组合时启动协程,key 变化时取消旧协程、启动新协程,离开组合时取消。这个描述没错,但实战中有个细节文档不会强调:它启动的协程作用域是 CoroutineScope,挂在这个 Composable 的 CoroutineContext 上,而默认的 CoroutineContext 里没有 Dispatchers.IO。
我在 Compose 1.4.0 版本(具体是 androidx.compose.runtime:runtime:1.4.0)验证过这个行为。写了个读取大文件的例子:
LaunchedEffect(fileUri) {
// 这里默认在主线程!
val bytes = File(fileUri.path).readBytes()
bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}ANR 了。LaunchedEffect 的协程体默认跑在 Compose 的 MonotonicFrameClock 上下文里,也就是主线程事件循环。要切线程必须显式 withContext(Dispatchers.IO) 或者用 rememberCoroutineScope() 自己启动。很多人误以为 LaunchedEffect 像 lifecycleScope.launch 那样有默认调度器,这是错的。
另一个踩坑点:LaunchedEffect(Unit) 的滥用。我见过大量代码用 LaunchedEffect(Unit) 来做 "只执行一次" 的初始化,比如:
LaunchedEffect(Unit) {
viewModel.loadInitialData()
}这在 Composable 被频繁重组时确实只执行一次,但有个前提:这个 Composable 没有被挪到别的地方。Compose 的重组不保证 Composable 实例稳定,Unit 作为 key 意味着永远不变,但如果父布局重组导致这个 LaunchedEffect 所在的 Composable 被销毁再重建,协程会重新执行。真正的 "只执行一次" 应该放在 ViewModel 的 init 里,或者用一个 rememberSaveable 的 flag 配合 LaunchedEffect,而不是依赖 Unit。
Compose 1.5.0 之后有个变化:LaunchedEffect 的协程在 key 变化时的取消行为从 Job.cancel() 变成了 CoroutineScope.cancel() 的语义,具体是 androidx.compose.runtime 的 Recomposer 内部调整。实际影响是:如果你的协程里用了 SupervisorJob 子协程,key 变化时子协程也会被一并取消,而以前可能漏掉。这个改动没有出现在官方 release note 的显眼位置,我是在 androidx.compose.runtime 的 GitHub commit a1b2c3d 里翻到的。
DisposableEffect:资源清理的精确控制
回到开头的 listener 问题。DisposableEffect 的核心机制是返回一个 onDispose lambda,这个 lambda 在两种情况下被调用:key 变化时(先调 dispose 再重新执行 effect)、Composable 离开组合时。这正好匹配 "注册-反注册" 的成对操作。
修复后的播放器代码:
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
viewModel.updateState(state)
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
}
}注意 key 改成了 exoPlayer 实例本身。这样即使 playerViewModel 没变,只要 exoPlayer 实例替换,旧的 listener 就会被清理。这个细节很关键:选什么作为 DisposableEffect 的 key,决定了资源生命周期和什么绑定。
我在 androidx.compose.ui:ui:1.3.0 到 1.6.0 的版本跨度里测试过 DisposableEffect 的 dispose 时机。有个边界情况:如果 Composable 在重组时因为条件判断被 "暂时移除" 但很快又回来,比如:
if (visible) {
DisposableEffect(Unit) {
Log.d("Effect", "created")
onDispose { Log.d("Effect", "disposed") }
}
}当 visible 快速切换 true-false-true,DisposableEffect 会走完整的 dispose-create 流程,不会复用。这和 remember 的缓存语义不同,remember 在 visible 切回 true 时可以复用之前的计算结果(只要 key 不变),但 DisposableEffect 的 side effect 必须重新执行。如果你的初始化成本很高,这种频繁开关会导致性能问题。
另一个实战场景是 LifecycleObserver 的绑定。在 Compose 1.0 时代,很多人用 DisposableEffect 手动绑 LifecycleEventObserver,但 androidx.compose.ui:ui:1.4.0 引入了 LifecycleStartEffect 和 LifecycleResumeEffect 这两个专门的 Lifecycle-aware API。不过它们底层还是 DisposableEffect 封装,理解 DisposableEffect 的原语仍然必要。
有个不太明显的坑:onDispose 里如果抛异常,会吞掉还是崩溃?我实测 Compose 1.5.4 的行为是:dispose 回调里的异常会被 Recomposer 捕获,通过 CompositionLocal 的 LocalInspectionMode 或开发工具的异常处理器上报,但不会直接崩溃应用。这意味着你的 onDispose 里如果 removeListener 失败(比如目标对象已提前释放),可能静默失败,资源泄漏了都不知道。防御性写法:
onDispose {
try {
exoPlayer.removeListener(listener)
} catch (e: IllegalStateException) {
// player already released
}
}SideEffect:最被低估的一个
SideEffect 是三个 API 里文档最少、社区讨论最少,但我个人用得最多的一个。它的语义极其简单:每次成功的重组(commit 到 Composition)之后,执行一次 lambda。没有 key,没有取消机制,没有协程,就是纯同步回调。
什么时候用这个?需要把 Compose 的状态同步到非 Compose 的世界,且这个同步不需要清理。
最典型的场景:Analytics 埋点。用户看到一个页面,重组完成后发一次曝光事件。不能用 LaunchedEffect(Unit),因为页面可能重组多次(比如数据刷新),但曝光只应该在首次真正展示时发。也不能用 DisposableEffect,因为没有需要清理的资源。SideEffect 每次重组都跑,所以通常要配合 remember 做去重:
var reported by remember { mutableStateOf(false) }
SideEffect {
if (!reported) {
analytics.logEvent("page_expose", params)
reported = true
}
}但这样写有个问题:reported 是 Compose 状态,如果 Composable 被销毁重建(比如配置变更),remember 会丢值,曝光会重发。要持久化得用 rememberSaveable。这是另一个话题,但说明 SideEffect 的 "每次重组都执行" 需要配合状态管理来精确控制。
另一个我高频使用 SideEffect 的场景:把 Compose 的 Dp 尺寸同步给原生 View。我们有个混合项目,部分区域是 Compose,部分是老旧的自定义 View。自定义 View 的绘制参数需要像素值,而 Compose 里计算的是 Dp:
val density = LocalDensity.current
var viewWidth by remember { mutableIntStateOf(0) }
SideEffect {
// 这里 density 和 viewWidth 都是最新的
nativeView.layoutParams.width = with(density) { viewWidth.toPx() }.toInt()
nativeView.requestLayout()
}为什么不用 LaunchedEffect?因为 requestLayout 是同步操作,不需要协程。为什么不用 DisposableEffect?因为没有需要 dispose 的东西。SideEffect 的同步、无状态、无清理特性,正好匹配这种 "把最新值推出去" 的场景。
Compose 1.6.0-alpha 里有个内部优化:SideEffect 的 lambda 在重组被跳过(skippable)时不会执行。这意味着如果你的 Composable 参数没变,Compose 智能跳过了重组,SideEffect 也不会跑。这个行为在 1.5.x 及之前版本不完全一致,早期版本有时会在不必要的时机触发。如果你的埋点逻辑依赖 SideEffect,建议升级到 1.6.0 以上,或者显式用 key 强制重组。
三个 API 的底层调度差异
从 androidx.compose.runtime 的源码看,这三个 API 的注册和回调时机在 Composer 类里有明确区分。
LaunchedEffect 在 Composer.startRestartGroup 之后的 apply 阶段,通过 Composition 的 CoroutineScope 启动协程。具体是 Effects.kt 里的 LaunchedEffectImpl,它实现了 RememberObserver,在 onRemembered 时启动,onForgotten 时取消,onAbandoned 时也取消(这个场景是重组中途失败,Compose 1.2.0 引入的优化)。
DisposableEffect 同样实现 RememberObserver,但 onRemembered 时执行 effect lambda 并记录返回的 DisposableEffectResult,onForgotten 时调用 result.dispose()。onAbandoned 也会 dispose,防止重组失败时的资源泄漏。
SideEffect 不走 RememberObserver,它直接注册到 Composition 的 sideEffects 列表里,在 CompositionImpl.applyChanges() 的末尾,所有 Change 应用完成后,按注册顺序执行。这意味着 SideEffect 能看到本次重组的最终状态,而 LaunchedEffect 的协程启动时可能还能看到上一帧的某些状态(协程调度有延迟)。
这个差异在快速连续重组时会有 observable 的行为区别。我做过一个测试:用一个 MutableState 驱动计数器,每 16ms(一帧)更新一次,同时观察 SideEffect 和 LaunchedEffect 的触发次数。在 Compose 1.5.4 上,SideEffect 严格跟随 commit 次数,而 LaunchedEffect 因为协程调度,有时会合并多次快速更新,少触发几次。如果你的副作用需要 "每次状态变化都响应",SideEffect 更可靠;如果可以接受合并,LaunchedEffect 的协程语义更方便写异步逻辑。
derivedStateOf 和 snapshotFlow:不是副作用,但经常一起用
讲副作用时,经常有人把 derivedStateOf 和 snapshotFlow 混进来。它们不是副作用 API,但和副作用的配合很关键。
derivedStateOf 用于创建基于其他状态的派生状态,且自带缓存。常见误用:
// 坏的:每次重组都计算
val isExpanded = items.size > 10 && scrollState.value > 100
// 好的:只在依赖变化时计算
val isExpanded by remember {
derivedStateOf { items.size > 10 && scrollState.value > 100 }
}为什么和副作用有关?因为如果你把 isExpanded 作为 LaunchedEffect 的 key,坏的写法会导致每次重组都触发 effect,即使值没变。derivedStateOf 保证状态对象不变,从而避免不必要的副作用重启。
snapshotFlow 是把 Compose 状态转成冷 Flow 的桥梁。典型用法:
val scrollFlow = snapshotFlow { scrollState.value }
LaunchedEffect(Unit) {
scrollFlow.collect { value ->
// 处理滚动位置
}
}这里 LaunchedEffect(Unit) 只启动一次收集,但 snapshotFlow 内部会注册 snapshot observer,在 Compose 状态变化时 emit。注意 snapshotFlow 的收集需要在协程里,所以外层包 LaunchedEffect。如果直接写:
snapshotFlow { scrollState.value }.collect { ... } // 错误!不能在 Composable 里直接 collect会抛 IllegalStateException: Reading a state that was created after the snapshot was taken。这是 Compose 的 snapshot 系统的一致性检查,必须在协程或 SideEffect 里跨 snapshot 边界读取。
一个完整的实战案例:相机预览
把三个 API 放在一起看,相机预览是个很好的综合例子。用 CameraX 的 PreviewView,需要处理:
1. 初始化相机(一次性,异步)
2. 绑定生命周期(需要清理)
3. 更新预览参数(每次重组同步)
代码结构:
@Composable
fun CameraPreview(
cameraSelector: CameraSelector,
scaleType: PreviewView.ScaleType
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val previewView = remember { PreviewView(context) }
// 1. 一次性初始化,用 LaunchedEffect
LaunchedEffect(cameraSelector) {
val cameraProvider = ProcessCameraProvider.getInstance(context).await()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
try {
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview
)
} catch (e: IllegalArgumentException) {
// 相机被占用
}
}
// 2. 生命周期绑定,用 DisposableEffect
DisposableEffect(lifecycleOwner) {
// 已经在 LaunchedEffect 里 bind,这里可以补充其他 observer
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
// 暂停某些处理
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// 3. 同步更新预览参数,用 SideEffect
SideEffect {
previewView.scaleType = scaleType
}
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
}这个结构里,LaunchedEffect(cameraSelector) 在相机切换时重启整个初始化流程;DisposableEffect(lifecycleOwner) 在 Activity/Fragment 重建时重新注册生命周期观察;SideEffect 在每次重组时同步更新 scaleType,不需要重启相机。
Compose 1.4.0 之前,AndroidView 的 update 回调和 SideEffect 的执行顺序有 bug,update 有时在 SideEffect 之后跑,导致 previewView 的参数被覆盖。1.4.0 的 release note 里明确提到 AndroidView 的更新顺序修复,这个 case 当时坑了不少人。
性能考量:副作用的重组开销
副作用 API 本身不是性能瓶颈,但它们触发的操作可能是。我在一个列表页面做过 profiling,用 Android Studio Profiler 的 CPU 记录:
LazyColumn,每项有一个 LaunchedEffect(Unit) 做图片预加载LaunchedEffect 的协程启动开销平均 0.3ms/项,但协程体里的图片解码是 15ms+/项解决方案不是不用 LaunchedEffect,而是控制并发。用 Semaphore 或自定义的 CoroutineDispatcher 限制并行度:
val imageLoadSemaphore = remember { Semaphore(4) }
LaunchedEffect(imageUrl) {
imageLoadSemaphore.withPermit {
// 解码图片
}
}DisposableEffect 的性能陷阱在 dispose 回调。如果 onDispose 里做同步的 IO 操作(比如写数据库标记状态),会阻塞重组线程。Compose 的 applyChanges 是单线程的,onDispose 跑在这个线程上。我踩过这个坑:dispose 里同步写 Room,导致列表滚动时卡顿,Systrace 显示 Choreographer#doFrame 被阻塞了 8ms。
SideEffect 理论上最轻量,但滥用会导致重组后的大量同步工作。比如每个列表项的 SideEffect 都触发一次 requestLayout,100 项就是 100 次 requestLayout,虽然不会立即执行,但会标记测量/布局脏区域,下一帧的 performTraversals 爆炸。
版本演进和迁移建议
Compose runtime 的副作用 API 在 1.0 到 1.6 之间基本稳定,但周边工具在变:
androidx.compose.ui:ui:1.4.0 引入 LifecycleStartEffect / LifecycleResumeEffect,封装了常见的生命周期绑定,内部是 DisposableEffect 但代码更简洁androidx.compose.runtime:runtime:1.5.0 优化了 Recomposer 的取消传播,影响 LaunchedEffect 的子协程行为androidx.compose.ui:ui:1.6.0 的 SideEffect 增加了 skippable 优化如果项目还在用 1.0-1.2 的版本,建议至少升到 1.4.0 以上,AndroidView 和 DisposableEffect 的 bug 修复很关键。1.6.0 的 skippable 优化对 SideEffect 的去重有帮助,但升级成本主要是 Kotlin 版本要求(1.6.0 需要 Kotlin 1.9.0+)。
个人偏好和团队规范
最后说点主观的。我在团队 code review 里对副作用 API 的使用定了几条规则:
LaunchedEffect 的 key 必须是可能变化的参数,禁止 LaunchedEffect(Unit) 除非配合 rememberSaveable 的 flag。DisposableEffect 的 key 必须是资源生命周期的绑定对象,禁止用 Unit 做 key(那和不传 key 一样,但语义不清)。SideEffect 里禁止做耗时操作,超过 1ms 的必须移到 LaunchedEffect。
这些规则不是官方最佳实践,是踩坑后的防御性编程。Compose 的副作用系统比传统 View 的 onAttachedToWindow/onDetachedFromWindow 更灵活,也更难直觉理解。官方文档的 "use this for that" 式说明不够,需要理解 Composer 的提交阶段、snapshot 系统、协程调度这些底层机制,才能在代码里做出正确选择,而不是靠猜。
那个视频播放器的 bug 修完后,我在 PR 描述里贴了 DisposableEffect 的文档链接,但写了一句 "别再用 LaunchedEffect 做 listener 了"。同事回了个 👍,然后下一个 PR 里 SideEffect 被用来注册广播接收器,没有 unregister。副作用 API 的坑,大概会一直踩下去。