24、副作用:LaunchedEffect、rememberCoroutineScope、snapshotFlow

Compose 的声明式 UI 看起来很美好——状态变了,界面自动更新。但真实项目里,总有一些「不听话」的操作:网络请求、数据库读写、定时器、动画回调……这些就是所谓的副作用

说白了,副作用就是「在 Composable 函数之外干的事」。Compose 本身是纯 UI 描述,它不负责管理这些异步任务。那怎么办?官方给了我们三把利器:LaunchedEffectrememberCoroutineScopesnapshotFlow

我个人习惯把这三种工具按场景分开记:跟着生命周期走的用 LaunchedEffect,需要手动控制的用 rememberCoroutineScope,把 Compose 状态转成冷流的用 snapshotFlow。下面我们一个一个拆开讲。

LaunchedEffect:跟着 Composable 生死走

LaunchedEffect 会在 Composable 进入组合时启动一个协程,退出组合时自动取消。它的核心价值在于:你不用手动管理协程的生命周期

来看一个最常见的场景——进入页面时加载数据:

@Composable
fun UserProfileScreen(userId: String) {
    var userData by remember { mutableStateOf<User?>(null) }

    LaunchedEffect(userId) {
        // 这个协程会在 userId 变化时重启
        userData = api.fetchUser(userId)
    }

    // UI 渲染...
}

这里有个关键点:LaunchedEffect 的第一个参数是 key。当 key 变化时,旧的协程会被取消,新的协程会启动。我在项目中遇到过一个问题——忘了传 key,结果页面切换了,协程还在跑,导致内存泄漏。嗯,这个坑踩过一次就记住了。

注意:如果 key 传的是常量(比如 Unit 或 true),LaunchedEffect 只会执行一次。适合做初始化操作,比如页面埋点。

rememberCoroutineScope:手动控制协程

有时候你需要在 Composable 外部启动协程,比如按钮点击事件里。这时候 LaunchedEffect 就不太合适了,因为它只能在 Composable 内部用。

rememberCoroutineScope 会返回一个 CoroutineScope,这个 scope 的生命周期和当前 Composable 节点绑定。你可以在任何回调里用它启动协程:

@Composable
fun LoginButton() {
    val scope = rememberCoroutineScope()
    var isLoading by remember { mutableStateOf(false) }

    Button(
        onClick = {
            scope.launch {
                isLoading = true
                try {
                    loginApi.login()
                } finally {
                    isLoading = false
                }
            }
        }
    ) {
        Text(if (isLoading) "登录中..." else "登录")
    }
}

你想想看,如果没有 rememberCoroutineScope,你就得自己搞一个 ViewModel 或者全局 scope,那生命周期管理就麻烦了。这个 API 说白了就是「给 Composable 里的回调一个安全的协程环境」。

我的习惯:如果协程和 UI 事件强相关(比如点击、滑动),用 rememberCoroutineScope。如果协程和 Composable 的「出现/消失」强相关,用 LaunchedEffect。

snapshotFlow:把 Compose 状态转成冷流

这个 API 可能用得少一些,但非常强大。snapshotFlow 可以把 Compose 的 State 对象转成一个 Flow。每当状态变化时,Flow 就会发射新值。

我举个例子——监听搜索框的输入,做防抖搜索:

@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }

    // 把 query 转成 Flow
    val debouncedQuery = snapshotFlow { query }
        .debounce(300)
        .flowOn(Dispatchers.Default)

    LaunchedEffect(Unit) {
        debouncedQuery.collect { value ->
            // 在这里做网络搜索
            searchApi.search(value)
        }
    }

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

这里 snapshotFlow { query } 会监听 query 这个 State 的变化。每次变化都会触发 Flow 的发射,然后我们利用 Flow 的 debounce 操作符做防抖。这个组合拳在搜索、过滤场景下特别好用。

核心理解:snapshotFlow 是 Compose 状态世界和 Kotlin Flow 世界之间的桥梁。它让你能用 Flow 的所有操作符(map、filter、debounce、flatMapLatest 等)来处理 Compose 状态。

三者的对比与选择

为了方便记忆,我画了一张对比图。你可以把它当作决策树来用:

副作用工具选择决策树 需要副作用? 跟着 Composable 生命周期走 在回调/事件中手动启动 LaunchedEffect rememberCoroutineScope 需要把 State 转成 Flow? 用 snapshotFlow 嵌套在 LaunchedEffect 里 需要取消协程? scope.launch 返回 Job,可手动 cancel

这张图应该能帮你快速定位该用哪个。我再补充一个表格,把关键区别列出来:

特性 LaunchedEffect rememberCoroutineScope snapshotFlow
启动方式 自动,进入组合时 手动,在回调中调用 自动,状态变化时
生命周期 跟随 Composable 跟随 Composable 跟随收集它的协程
重启条件 key 变化 手动控制 State 变化
典型场景 页面初始化加载 按钮点击、手势回调 搜索防抖、状态监听
返回值 无(直接执行) CoroutineScope Flow<T>

避坑指南

我曾经在项目里犯过一个低级错误——在 LaunchedEffect 里直接调用了 viewModel 的挂起函数,但忘了传 key。结果页面退出了,协程还在跑,导致 ViewModel 里的状态被更新,UI 却已经销毁了。嗯,后来我养成了一个习惯:所有和 UI 生命周期绑定的协程,一定要确认 key 是否正确

还有一个坑:snapshotFlow 默认在 Dispatchers.Main 上收集。如果你在里面做耗时操作,记得用 .flowOn(Dispatchers.Default) 切换线程。不然 UI 会卡顿,用户可不会管你用的是不是 Compose。

小技巧:如果你需要在 LaunchedEffect 里做多个独立的副作用,可以写多个 LaunchedEffect,每个负责一件事。这样代码更清晰,也更容易维护。

最后说一句:副作用管理是 Compose 开发中绕不开的坎。但只要你掌握了 LaunchedEffectrememberCoroutineScopesnapshotFlow 这三个工具,大部分场景都能轻松应对。我个人觉得,理解它们的生命周期差异比记住 API 签名更重要——API 可以查文档,但生命周期理解错了,bug 就藏得深了。


公众号:蓝海资料掘金营,微信deep3321