首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >[Android 从零到一] Compose 副作用管理:LaunchedEffect、DisposableEffect 与 rememberCoroutine

[Android 从零到一] Compose 副作用管理:LaunchedEffect、DisposableEffect 与 rememberCoroutine

原创
作者头像
hunter android
发布2026-07-27 10:21:21
发布2026-07-27 10:21:21
810
举报

Compose 副作用管理:LaunchedEffect、DisposableEffect 与 rememberCoroutineScope 的边界

Jetpack Compose 的 UI 由状态驱动:状态变化,函数重新执行,界面得到更新。但真实页面不只有“根据数据返回 UI”这一类工作。请求数据、显示 Toast、注册监听器、启动动画、写入日志,都可能需要和 Compose 外部的系统或对象交互,这些工作就是副作用。

副作用本身并不可怕,真正容易出问题的是把它放在错误的位置:在组合函数体里直接发请求、用 LaunchedEffect(true) 偷偷承载整个页面生命周期、在 DisposableEffect 中忘记注销监听,或者在点击回调里启动了没有归属的协程。本文通过一个搜索页面,梳理几种常用 API 的职责边界。

为什么不能在组合函数体里做副作用

组合函数可能因为任意状态变化反复执行。下面的代码看起来直观,但每次重新组合都可能发起一次请求:

代码语言:javascript
复制
@Composable
fun SearchScreen(repository: SearchRepository) {
    var keyword by remember { mutableStateOf("") }

    repository.search(keyword) // 错误:组合过程不应该直接调用外部操作

    TextField(
        value = keyword,
        onValueChange = { keyword = it }
    )
}

组合阶段应该尽量保持可重入、可取消和无副作用。把外部操作放进受 Compose 管理的 Effect 中,Compose 才能根据 key 决定何时启动、取消和重新启动它。

LaunchedEffect:由状态驱动的挂起工作

LaunchedEffect 适合“进入组合后启动协程,并在 key 变化或离开组合时取消”的工作。搜索页面可以把关键词作为 key:

代码语言:javascript
复制
@Composable
fun SearchScreen(
    viewModel: SearchViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    var keyword by rememberSaveable { mutableStateOf("") }

    LaunchedEffect(keyword) {
        if (keyword.isBlank()) {
            viewModel.clearResult()
        } else {
            delay(300)
            viewModel.search(keyword)
        }
    }

    SearchContent(
        keyword = keyword,
        state = uiState,
        onKeywordChange = { keyword = it }
    )
}

用户连续输入时,旧的 Effect 会被取消,新的 Effect 重新计时。这个行为适合防抖,但请求本身最好仍由 ViewModel 或仓库统一管理,因为网络请求、错误映射和缓存都属于业务层。Effect 负责把 UI 事件转换为挂起调用,不应逐渐膨胀成业务控制器。

key 决定协程的生命周期

可以把 key 理解为这段副作用依赖的输入:

  • key 保持不变:重组不会重新启动协程;
  • key 发生变化:旧协程先取消,再启动新协程;
  • Composable 离开组合:协程被取消。

LaunchedEffect(Unit)LaunchedEffect(true) 常用于只执行一次的进入动作,例如首次加载或播放进入动画。但“只执行一次”是相对于当前组合实例而言的,并不等同于进程生命周期内只执行一次。页面重新进入组合后,它仍可能再次启动,因此不能用它替代持久化任务或业务去重。

如果 Effect 需要读取一个会变化、但不应该触发重启的回调或对象,可以使用 rememberUpdatedState

代码语言:javascript
复制
@Composable
fun SessionTimeoutMessage(
    onTimeout: () -> Unit
) {
    val latestOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(30_000)
        latestOnTimeout()
    }
}

这样计时协程不会因为回调实例变化而重启,但执行时拿到的是最新回调。这里的重点不是“绕过 key”,而是明确区分“会改变副作用生命周期的依赖”和“只需要读取最新值的依赖”。

rememberCoroutineScope:事件回调里的短任务

LaunchedEffect 由组合启动,rememberCoroutineScope 则适合由用户事件启动。例如点击按钮后弹出 Snackbar:

代码语言:javascript
复制
@Composable
fun SaveBar(snackbarHostState: SnackbarHostState) {
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                snackbarHostState.showSnackbar("已保存")
            }
        }
    ) {
        Text("保存")
    }
}

这个 Scope 的生命周期绑定到当前组合位置,Composable 离开组合后会取消其中的协程。它比在回调里直接创建 CoroutineScope(Dispatchers.Main) 更可靠,因为后者没有自动清理,页面销毁后仍可能继续持有引用。

它也不是 ViewModel Scope 的替代品。需要跨越配置变化、继续执行或保存业务结果的工作,应交给 ViewModel;只服务于当前界面交互的短任务,才适合放在 rememberCoroutineScope 中。

DisposableEffect:需要成对清理的资源

当副作用需要注册监听器、观察生命周期或订阅系统回调时,使用 DisposableEffect。例如记录页面可见状态:

代码语言:javascript
复制
@Composable
fun TrackScreenVisible(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    analytics: Analytics
) {
    DisposableEffect(lifecycleOwner, analytics) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                analytics.log("screen_resume")
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

onDispose 必须位于 Effect 末尾,并且要撤销注册动作。key 变化时,Compose 会先执行旧实例的 onDispose,再创建新实例,所以 key 必须包含真正决定注册对象的依赖。只要 lifecycleOwner 可能改变,就不能把它漏掉。

如果只是向外部对象发送一次同步更新,不需要清理资源,SideEffect 通常更合适:它会在成功组合之后执行,适合把 Compose 状态同步给已有对象,但不能在里面启动挂起函数。

处理最新值与生命周期对象

常见的生命周期监听写法是:

代码语言:javascript
复制
val currentOwner = LocalLifecycleOwner.current

DisposableEffect(currentOwner) {
    val observer = LifecycleEventObserver { _, event ->
        // 读取简单值可以直接使用;复杂回调要考虑最新值问题
    }
    currentOwner.lifecycle.addObserver(observer)
    onDispose { currentOwner.lifecycle.removeObserver(observer) }
}

如果 observer 存活期间需要使用外部变化的配置,例如最新的埋点参数,不要为了更新参数而反复注册 observer。可以让 observer 读取 rememberUpdatedState 保存的最新引用:

代码语言:javascript
复制
val latestScreenName by rememberUpdatedState(screenName)

DisposableEffect(currentOwner) {
    val observer = LifecycleEventObserver { _, event ->
        if (event == Lifecycle.Event.ON_START) {
            analytics.log("start:$latestScreenName")
        }
    }
    currentOwner.lifecycle.addObserver(observer)
    onDispose { currentOwner.lifecycle.removeObserver(observer) }
}

这种组合可以同时满足两个要求:生命周期对象变化时重新注册,普通参数变化时只更新读取到的值。

常见误区与排查方法

把网络请求写进多个 Effect

页面加载、下拉刷新和重试如果各自维护一套请求状态,很容易出现竞态。建议让 UI 只发出事件,ViewModel 统一处理请求,状态通过 StateFlow 暴露。Effect 可以负责“进入页面时发送一次加载事件”,但不要再复制一套 loading、错误和结果状态。

key 使用不稳定对象

把每次重组都会创建的新对象作为 key,会导致 Effect 反复取消和启动。优先使用稳定的 ID、值类型参数或明确的生命周期对象。若对象本身是业务实体,应确认其相等性和实例更新策略。

忽略取消异常

协程取消是正常控制流,不应该被宽泛的异常捕获转换成错误提示:

代码语言:javascript
复制
try {
    repository.load()
} catch (error: CancellationException) {
    throw error
} catch (error: IOException) {
    showNetworkError()
}

当用户离开页面或 key 变化时,及时传播取消信号可以避免旧请求继续更新界面。

把一次性事件当成状态反复消费

导航、Toast、Snackbar 这类事件如果直接作为普通状态渲染,重组或旋转后可能重复消费。可以在 ViewModel 侧设计明确的事件流,或者让事件携带唯一 ID,并在消费后确认状态。选择哪种方式取决于事件是否需要可靠送达,但都比在组合函数体里直接调用更容易推理。

测试 Effect 的关键行为

Effect 测试不应只验证“函数执行了”,还要验证生命周期边界。可以使用 Compose UI Test 和测试时钟覆盖这些场景:

  • 关键词变化后,旧的防抖任务被取消;
  • 页面离开组合后,挂起任务不再更新界面;
  • DisposableEffect 注册监听,销毁时完成注销;
  • 回调变化不会无意义地重启长任务,但执行时使用最新回调;
  • 用户快速点击时,Snackbar 或导航事件符合预期。

业务请求本身则在 ViewModel 和仓库测试中验证,避免把网络依赖带入组合测试。对于 LaunchedEffect 中的 delay,使用测试调度器推进虚拟时间,可以让防抖测试稳定且快速。

一张选择表

场景

推荐 API

生命周期重点

由状态变化触发挂起工作

LaunchedEffect(key)

key 变化取消并重启

点击、手势触发短协程

rememberCoroutineScope

离开组合时取消

注册监听并需要注销

DisposableEffect(key)

onDispose 成对清理

组合成功后同步外部对象

SideEffect

不能承载挂起操作

读取最新回调但不重启 Effect

rememberUpdatedState

区分最新值与生命周期依赖

总结

Compose 副作用 API 的差异,本质上是启动者和生命周期的差异。LaunchedEffect 适合状态驱动的挂起工作,rememberCoroutineScope 适合事件驱动的短任务,DisposableEffect 负责需要清理的外部资源,SideEffect 用来同步成功组合后的结果,rememberUpdatedState 则解决长任务读取最新值的问题。

使用前先回答三个问题:这项工作由谁触发?哪些输入变化应该重启它?离开组合时需要怎样清理?答案明确后,API 的选择通常就不会靠猜。把业务状态留在 ViewModel,把界面副作用限制在清晰的生命周期内,Compose 页面会更稳定,也更容易测试。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • Compose 副作用管理:LaunchedEffect、DisposableEffect 与 rememberCoroutineScope 的边界
    • 为什么不能在组合函数体里做副作用
    • LaunchedEffect:由状态驱动的挂起工作
      • key 决定协程的生命周期
    • rememberCoroutineScope:事件回调里的短任务
    • DisposableEffect:需要成对清理的资源
    • 处理最新值与生命周期对象
    • 常见误区与排查方法
      • 把网络请求写进多个 Effect
      • key 使用不稳定对象
      • 忽略取消异常
      • 把一次性事件当成状态反复消费
    • 测试 Effect 的关键行为
    • 一张选择表
    • 总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档