Compose 的副作用 API:LaunchedEffect、DisposableEffect、SideEffect 到底什么时候用

Compose 的副作用 API:LaunchedEffect、DisposableEffect、SideEffect 到底什么时候用

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 的协程体默认跑在 ComposeMonotonicFrameClock 上下文里,也就是主线程事件循环。要切线程必须显式 withContext(Dispatchers.IO) 或者用 rememberCoroutineScope() 自己启动。很多人误以为 LaunchedEffectlifecycleScope.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.runtimeRecomposer 内部调整。实际影响是:如果你的协程里用了 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.01.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 的缓存语义不同,remembervisible 切回 true 时可以复用之前的计算结果(只要 key 不变),但 DisposableEffect 的 side effect 必须重新执行。如果你的初始化成本很高,这种频繁开关会导致性能问题。


另一个实战场景是 LifecycleObserver 的绑定。在 Compose 1.0 时代,很多人用 DisposableEffect 手动绑 LifecycleEventObserver,但 androidx.compose.ui:ui:1.4.0 引入了 LifecycleStartEffectLifecycleResumeEffect 这两个专门的 Lifecycle-aware API。不过它们底层还是 DisposableEffect 封装,理解 DisposableEffect 的原语仍然必要。


有个不太明显的坑:onDispose 里如果抛异常,会吞掉还是崩溃?我实测 Compose 1.5.4 的行为是:dispose 回调里的异常会被 Recomposer 捕获,通过 CompositionLocalLocalInspectionMode 或开发工具的异常处理器上报,但不会直接崩溃应用。这意味着你的 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 类里有明确区分。


LaunchedEffectComposer.startRestartGroup 之后的 apply 阶段,通过 CompositionCoroutineScope 启动协程。具体是 Effects.kt 里的 LaunchedEffectImpl,它实现了 RememberObserver,在 onRemembered 时启动,onForgotten 时取消,onAbandoned 时也取消(这个场景是重组中途失败,Compose 1.2.0 引入的优化)。


DisposableEffect 同样实现 RememberObserver,但 onRemembered 时执行 effect lambda 并记录返回的 DisposableEffectResultonForgotten 时调用 result.dispose()onAbandoned 也会 dispose,防止重组失败时的资源泄漏。


SideEffect 不走 RememberObserver,它直接注册到 CompositionsideEffects 列表里,在 CompositionImpl.applyChanges() 的末尾,所有 Change 应用完成后,按注册顺序执行。这意味着 SideEffect 能看到本次重组的最终状态,而 LaunchedEffect 的协程启动时可能还能看到上一帧的某些状态(协程调度有延迟)。


这个差异在快速连续重组时会有 observable 的行为区别。我做过一个测试:用一个 MutableState 驱动计数器,每 16ms(一帧)更新一次,同时观察 SideEffectLaunchedEffect 的触发次数。在 Compose 1.5.4 上,SideEffect 严格跟随 commit 次数,而 LaunchedEffect 因为协程调度,有时会合并多次快速更新,少触发几次。如果你的副作用需要 "每次状态变化都响应",SideEffect 更可靠;如果可以接受合并,LaunchedEffect 的协程语义更方便写异步逻辑。


derivedStateOf 和 snapshotFlow:不是副作用,但经常一起用


讲副作用时,经常有人把 derivedStateOfsnapshotFlow 混进来。它们不是副作用 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 之前,AndroidViewupdate 回调和 SideEffect 的执行顺序有 bug,update 有时在 SideEffect 之后跑,导致 previewView 的参数被覆盖。1.4.0 的 release note 里明确提到 AndroidView 的更新顺序修复,这个 case 当时坑了不少人。


性能考量:副作用的重组开销


副作用 API 本身不是性能瓶颈,但它们触发的操作可能是。我在一个列表页面做过 profiling,用 Android Studio Profiler 的 CPU 记录:


  • 100 项的 LazyColumn,每项有一个 LaunchedEffect(Unit) 做图片预加载
  • 快速滚动时,LaunchedEffect 的协程启动开销平均 0.3ms/项,但协程体里的图片解码是 15ms+/项
  • 虽然协程启动是轻量的,但 100 个并发协程同时解码图片,直接 OOM

  • 解决方案不是不用 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.0SideEffect 增加了 skippable 优化

  • 如果项目还在用 1.0-1.2 的版本,建议至少升到 1.4.0 以上,AndroidViewDisposableEffect 的 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 的坑,大概会一直踩下去。

    JRebel for Android 的热部署方案还在吗 2026-06-18
    Compose for iOS 的进展,真的能一套代码双端吗 2026-06-18

    评论区