精解 rememberCoroutineScope:手动协程管理与生命周期

513 阅读4分钟

rememberCoroutineScope()

rememberCoroutineScope 是除了 LaunchedEffect 在 Compose 使用协程的另一种方式。

为什么不能直接用 lifecycleScope.launch?

那为什么不能使用常用的方式来写协程呢?

比如我直接这样写:

lifecycleScope.launch {
    // ..
}

这样写,会有一个警告:Calls to launch should happen inside a LaunchedEffect and not composition

直接使用 lifecycleScope 的警告

那为什么不能这样写呢?

我们启动协程所使用的 launch 函数其实是 CoroutineScope 接口的扩展函数,所以必须要提供一个 CoroutineScope 的上下文,才能调用它。比如 lifecycleScope 就提供了 LifecycleCoroutineScope 上下文。(LifecycleCoroutineScopeCoroutineScope 接口的实现类)

提供 CoroutineScope 上下文的目的:

  1. 让这个 Scope 被取消时,可以自动取消其启动的所有协程,而 Scope 会在 Activity/Fragment 被销毁时被取消,这使得协程的生命周期与 Activity/Fragment 的生命周期相绑定,确保了协程不会发生泄漏。
  2. 提供协程启动需要的上下文信息,并对这些协程进行管理。

关键在于,Composable 函数的生命周期和 Activity/Fragment 的生命周期不同,Composable 的生命周期是:因首次调用或重组进入组合 (Composition) 作为其开始,因不再是 UI 树的一部分而离开组合 (Leaving Composition) 作为其结束。

因此,我们在 Composable 函数中启动的协程,也应该在该 Composable 离开组合时自动被取消掉,以避免潜在的内存泄漏和不必要的后台工作。

补充:不能使用 viewModelScope 来启动协程,也是同样的道理, viewModelScope 的生命周期与 ViewModel 的生命周期作用相绑定。

rememberCoroutineScope()

Compose 给我们提供了 rememberCoroutineScope() 函数,它能创建一个 CoroutineScope 实例。这个 Scope 的生命周期与调用它的 Composable 的生命周期同步。

也就是说,当该 Composable 进入组合时,Scope 被创建;当它离开组合时,Scope 被取消,从而自动取消所有由它启动的协程。

但是你直接这样写的话,依然会有警告:Calls to launch should happen inside a LaunchedEffect and not composition

val coroutineScope = rememberCoroutineScope()
coroutineScope.launch {
    // ..
}
直接使用 rememberCoroutineScope().launch 的警告

这是因为在 Composable 函数直接启动协程,那么每当 Composable 函数发生重组时,导致协程会被重复启动,造成资源浪费甚至逻辑错误等问题。

所以我们可以使用 remember 函数包住协程,像这样:

val coroutineScope = rememberCoroutineScope()
val coroutine = remember {
    coroutineScope.launch {
        Log.d("Snow","获取远程数据")
    }
}

并且我们去看 LaunchedEffect 的源码,它也是类似的做法:

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

rememberCoroutineScope 的使用场景:用户交互触发的协程

这么看来,rememberCoroutineScopeLaunchedEffect 都能启动一个协程, LaunchedEffect 反而更简单,我就无脑使用 LaunchedEffect

这显然是不对的。

首先 LaunchedEffect 会在 Composable 首次进入组合或者 key 参数改变时,自动被执行,它主要用于处理那些需要与 Composable 生命周期或其依赖数据变化同步的副作用,例如进入界面时加载初始数据。

rememberCoroutineScope() 提供一个 CoroutineScope 实例,允许我们手动调用 launch 来启动协程,与 Composable 的组合/重组流程无关。

因此,rememberCoroutineScope 专用于需要在用户交互(如按钮点击、滑动操作)中启动协程的场景。

比如:

@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun DataFetcherButton() {
    val coroutineScope = rememberCoroutineScope()
    var dataText by remember { mutableStateOf("No data loaded") }
    var isLoading by remember { mutableStateOf(false) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Button(
            onClick = {
                if (!isLoading) { // 防止重复点击
                    isLoading = true
                    dataText = "Loading..."
                    coroutineScope.launch {
                        try {
                            val result = fetchDataFromServer() // 模拟网络请求
                            dataText = result
                        } catch (e: Exception) {
                            dataText = "Error: ${e.message}"
                        } finally {
                            isLoading = false
                        }
                    }
                }
            },
            enabled = !isLoading // 加载时禁用按钮
        ) {
            Text(if (isLoading) "Loading..." else "Fetch Data")
        }
        Text(text = dataText, modifier = Modifier.padding(top = 8.dp))
    }
}

// 模拟的挂起函数
@RequiresApi(Build.VERSION_CODES.O)
suspend fun fetchDataFromServer(): String {
    delay(2000) // 模拟耗时操作
    
    val currentTime = LocalDateTime.now()
    val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
    val formattedTime = currentTime.format(formatter)

    return "Data fetched successfully at $formattedTime"
}

在这个例子中,点击按钮后,我们在 coroutineScope 中启动一个协程来模拟获取数据,并根据加载状态和结果更新UI。

运行结果:

image.gif

协程的生命周期

最后声明一点,通过 rememberCoroutineScope() 函数获取的 CoroutineScope 实例启动的协程,其生命周期是与调用 rememberCoroutineScope() 的那个 Composable 函数的生命周期绑定的,本质上是与CoroutineScope 实例的生命周期相绑定。

就比如上面的 DataFetcherButton 示例中,如果 DataFetcherButton 因为某些条件离开了组合(离开了UI树,不再显示在界面),那么其内部创建的 CoroutineScope 实例就会被取消,从而所有由这个 CoroutineScope 实例启动的协程,也会被取消,即使仍然在运行。