协程与Compose:Compose中的协程使用、rememberCoroutineScope、LaunchedEffect与DisposableEffect、Compose中的Flow收集

Compose 和协程,说白了就是天生一对。你想想看,Compose 是声明式 UI,协程是异步编程的利器,两者结合能让你的代码既简洁又强大。我在项目中接手过一个遗留的页面,里面全是回调嵌套,改造成 Compose + 协程后,代码量直接砍了一半。嗯,今天我们就来聊聊这个组合拳怎么打。

Compose 中的协程环境

Compose 本身运行在 UI 线程上,但你不能在 Composable 函数里直接启动协程。为什么?因为 Composable 函数可能会被频繁重组,直接启动协程会导致泄漏或重复执行。你需要一个安全的协程作用域。

Compose 提供了三个核心工具来管理协程:

  • rememberCoroutineScope:在 Composable 中获取一个协程作用域
  • LaunchedEffect:在 Composable 进入组合时启动协程
  • DisposableEffect:在 Composable 离开组合时清理资源

我个人习惯把 rememberCoroutineScope 用在用户交互触发的异步操作上,比如按钮点击。而 LaunchedEffect 更适合页面初始化时的数据加载。DisposableEffect 则用来注册和反注册监听器。

核心原则:不要在 Composable 函数体内直接调用 suspend 函数或启动协程。必须通过 rememberCoroutineScope、LaunchedEffect 或 DisposableEffect 来管理。

rememberCoroutineScope:手动控制协程生命周期

rememberCoroutineScope 返回一个 CoroutineScope,它的生命周期与当前 Composable 的“位置”绑定。当这个 Composable 从组合中移除时,作用域会自动取消,所有子协程也会被清理。

我最常用的场景是按钮点击事件。比如用户点击“刷新”按钮,我需要发起网络请求:

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

    Button(
        onClick = {
            scope.launch {
                isLoading = true
                try {
                    // 模拟网络请求
                    delay(2000)
                    // 更新 UI 状态
                } finally {
                    isLoading = false
                }
            }
        },
        enabled = !isLoading
    ) {
        Text(if (isLoading) "加载中..." else "刷新")
    }
}

这里有个坑,我曾经踩过:如果你在 onClick 里直接使用 GlobalScope.launch,页面销毁后协程还在跑,轻则内存泄漏,重则更新已销毁的 UI 导致崩溃。rememberCoroutineScope 会自动处理这个问题。

小技巧:rememberCoroutineScope 默认使用 Dispatchers.Main,所以你在里面直接更新状态是安全的。如果需要切换线程,用 withContext(Dispatchers.IO) 即可。

LaunchedEffect:自动启动与取消

LaunchedEffect 会在 Composable 首次进入组合时启动一个协程,并在 Composable 离开组合或 key 发生变化时取消并重新启动。它的签名是:

@Composable
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
)

key 参数很关键。当 key 变化时,旧的协程会被取消,新的协程会启动。如果 key 不变,协程只会执行一次。我通常用它来做页面初始化加载:

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

    LaunchedEffect(userId) {
        // 当 userId 变化时,自动重新加载
        userData = fetchUserData(userId)
    }

    // 渲染 UI
    userData?.let {
        Text("用户名: ${it.name}")
    } ?: CircularProgressIndicator()
}

你想想看,如果用传统的回调方式,你得手动处理 userId 变化时的取消和重新请求。LaunchedEffect 帮你自动搞定了。我记得有一次重构一个列表详情页,用 LaunchedEffect 替换了原来的 LiveData + ViewModel 的加载逻辑,代码清晰了很多。

注意:LaunchedEffect 的 block 是挂起函数,如果你在里面启动子协程,记得用 coroutineScope 或 supervisorScope 来管理,否则子协程抛异常会导致整个 LaunchedEffect 取消。

DisposableEffect:资源清理的守护者

DisposableEffect 和 LaunchedEffect 类似,但它多了一个 onDispose 回调,专门用来清理资源。比如注册广播接收器、监听传感器、或者绑定生命周期观察者。

我曾经在项目中遇到一个问题:一个页面注册了 LocationManager 的位置监听,但页面退出时忘记取消注册,导致定位一直开着,耗电严重。用 DisposableEffect 就能完美解决:

@Composable
fun LocationTracker(
    onLocationChanged: (Location) -> Unit
) {
    val context = LocalContext.current

    DisposableEffect(context) {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        val listener = LocationListener { location ->
            onLocationChanged(location)
        }

        // 注册监听
        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER,
            1000L,
            0f,
            listener
        )

        // 清理资源
        onDispose {
            locationManager.removeUpdates(listener)
        }
    }
}

onDispose 里的代码会在 Composable 离开组合时执行。不管是页面回退、配置变更还是被系统回收,它都会触发。嗯,这里要注意:onDispose 是同步执行的,不要在里面做耗时操作。

对比总结

API 适用场景 生命周期
rememberCoroutineScope 用户交互触发的异步操作(点击、滑动) 与 Composable 位置绑定
LaunchedEffect 页面初始化、数据加载、动画 进入组合时启动,离开或 key 变化时取消
DisposableEffect 注册/反注册监听器、绑定生命周期 进入组合时启动,离开时执行 onDispose

Compose 中的 Flow 收集

Flow 是协程的产物,在 Compose 中收集 Flow 有几种方式。最直接的是用 collectAsState:

@Composable
fun TimerDisplay() {
    val timeFlow = remember {
        flow {
            var seconds = 0
            while (true) {
                emit(seconds++)
                delay(1000)
            }
        }
    }

    val seconds by timeFlow.collectAsState(initial = 0)

    Text("已过去 $seconds 秒")
}

collectAsState 会把 Flow 转换为 State,每次 Flow 发射新值,Composable 就会重组。但这里有个性能问题:如果 Flow 发射频率很高(比如每秒几十次),重组次数也会很多。我建议在 ViewModel 层做防抖或采样。

另一种方式是结合 LaunchedEffect 手动收集:

@Composable
fun OrderStatusScreen(viewModel: OrderViewModel) {
    var status by remember { mutableStateOf("等待中") }

    LaunchedEffect(Unit) {
        viewModel.statusFlow.collect { newStatus ->
            status = newStatus
        }
    }

    Text("订单状态: $status")
}

手动收集的好处是你可以控制收集的生命周期,比如在页面可见时才收集,不可见时暂停。我曾经用这种方式优化过一个实时行情页面,在页面不可见时停止收集,大大减少了不必要的网络请求。

最佳实践

  • 简单的 UI 状态用 collectAsState
  • 需要精细控制生命周期时用 LaunchedEffect + collect
  • Flow 发射频率高时,考虑使用 debounce、sample 等操作符
  • 避免在 Composable 中创建 Flow,应该从 ViewModel 或 Repository 中获取

知识体系图

下面这张图展示了 Compose 中协程使用的核心脉络:

Compose 协程核心体系 Compose 协程 rememberCoroutineScope LaunchedEffect DisposableEffect 用户交互触发(点击、滑动) 手动控制协程生命周期 页面初始化、数据加载 key 变化时自动重启 注册/反注册监听器 onDispose 清理资源 Flow 收集:collectAsState / LaunchedEffect

从图中可以看出,三个核心 API 各有侧重,但最终都指向 Flow 收集。在实际项目中,我通常这样组合使用:

  • ViewModel 暴露 StateFlow 或 SharedFlow
  • Composable 用 collectAsState 或 LaunchedEffect 收集
  • 用户操作通过 rememberCoroutineScope 触发
  • 资源监听用 DisposableEffect 管理

这套组合拳打下来,代码既安全又优雅。你想想看,以前用 LiveData + 生命周期观察者,写起来多啰嗦。现在一个 LaunchedEffect 就搞定了。嗯,这就是 Compose 的魅力所在。

避坑指南

  • 我曾经在 LaunchedEffect 里直接调用 viewModel 的 suspend 函数,结果因为 ViewModel 的 scope 和 Compose 的 scope 不一致,导致协程泄漏。后来统一用 viewModelScope 才解决。
  • collectAsState 默认使用 Dispatchers.Main,如果你的 Flow 在 IO 线程发射,记得用 flowOn(Dispatchers.IO) 切换。
  • DisposableEffect 的 onDispose 里不要调用 suspend 函数,它是同步回调。

好了,这一章的内容就到这里。记住这三个 API 的适用场景,你的 Compose 代码会变得既简洁又健壮。

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