Jetpack Compose中的LaunchedEffect与rememberCoroutineScope

Jetpack Compose中的LaunchedEffect与rememberCoroutineScope

深入了解Compose副作用API LaunchedEffectrememberCoroutineScope
探索使用LaunchedEffectrememberCoroutineScope的区别和使用场景。

什么是副作用?
副作用是指在可组合函数范围之外发生的任何事情,最终会影响可组合函数,可能是一些状态的改变或在用户界面上发生的与可组合有关的用户操作。这两个API都是为了在受控环境中处理这些影响而构建的。

首先,让我们详细了解LaunchedEffect

LaunchedEffect副作用API

LaunchedEffect是一个可组合函数,只能从另一个可组合函数中执行。LaunchedEffect至少需要一个参数和一个挂起函数。它通过在容器可组合的范围内启动一个协程来执行该挂起函数。当第一次进入组合时,LaunchedEffect会立即执行该挂起函数,以及当其传递的变量之一的值发生改变时。当LaunchedEffect必须执行一个新的挂起函数以处理副作用时,它会取消先前正在运行的协程,并使用新的挂起函数启动一个新的协程。当离开组合本身时,LaunchedEffect也会取消已启动的协程。协程始终在容器可组合函数的范围内启动。

LaunchedEffect底层实现

让我们看一下LaunchedEffect的函数声明之一。

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    
    
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) {
    
     LaunchedEffectImpl(applyContext, block) }
}
internal class LaunchedEffectImpl(
    parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
    
    
    private val scope = CoroutineScope(parentCoroutineContext)
    private var job: Job? = null

    override fun onRemembered() {
    
    
        job?.cancel("Old job was still running!")
        job = scope.launch(block = task)
    }

    override fun onForgotten() {
    
    
        job?.cancel()
        job = null
    }

    override fun onAbandoned() {
    
    
        job?.cancel()
        job = null
    }
}

通过查看上面的代码,以下是要点回顾:

  • LaunchedEffect 是一个可组合函数,因此只能在另一个可组合函数内执行。
  • LaunchedEffect 接受一个参数和一个必须执行的挂起函数。
  • LaunchedEffect 将当前可组合的协程上下文传递给 LaunchedEffectImpl,并传递一个将要执行的挂起函数,显示协程将在父可组合函数范围内启动。
  • LaunchedEffectImpl 将挂起函数作为代码块,启动协程,如果存在先前运行的协程,则取消它。
  • LaunchedEffect 期望至少传递一个参数。如果您不想传递任何参数,您可以传递 null Unit。在这种情况下,我选择传递 Unit 作为参数。

如果您传递 Unit null 作为参数,则挂起函数将在组合阶段仅执行一次。
LaunchedEffect 在可组合函数范围内的代码块中启动协程,在 LaunchedEffect 离开组合或任何 LaunchedEffect 参数变化时,正在执行的协程将被取消。

LaunchedEffect 示例

让我们来看一下下面的代码示例,以了解 LaunchedEffect 的一些特点

@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LaunchedEffectTestScreen (
    snackbarHostState: SnackbarHostState,
    viewModel: LaunchedEffectTestViewModel
) {
    
    
    val snackbarCount =  viewModel.snackbarCount.collectAsState()
    LaunchedEffect(snackbarCount.value) {
    
    
        Log.d("launched-effect","displaying launched effect for count ${
      
      snackbarCount.value}")
        try {
    
    
            snackbarHostState.showSnackbar("LaunchedEffect snackbar", "ok")
        } catch(e: Exception){
    
    
            Log.d("launched-effect","launched Effect coroutine cancelled exception $e")
        }
    }

    Scaffold(
        snackbarHost = {
    
     SnackbarHost(hostState = snackbarHostState) }
    ) {
    
    
        Column {
    
    
            Text(text = "LaunchedEffect Test")
        }
    }

}

在上面的代码示例中,LaunchedEffectTestScreen组合使用LaunchedEffect来在第一次和传递的参数snackbarCount更改时显示一个snackbar。相应的viewModel代码如下所示。

class LaunchedEffectTestViewModel : ViewModel() {
    
    

    private var _snackbarCount = MutableStateFlow(1)
    val snackbarCount: StateFlow<Int> get() = _snackbarCount

    init {
    
    
        viewModelScope.launch {
    
    
            var displayCount = 1
            while (displayCount < 3) {
    
    
                delay(1000L)
                displayCount += 1
                _snackbarCount.value = displayCount
            }
        }
    }
}

在 ViewModel 中,snackbarCount StateFlow 的初始值为1。ViewModel 进一步启动一个协程,以每秒更新 snackbarCount StateFlow 的值,最多更新3次。由于 snackbarCount 的值将会改变,LaunchedEffect 将在每次值变化时执行,并且会启动一个新的协程,取消之前的协程。以上代码的日志输出如下所示。

D/launched-effect: displaying launched effect for count 1
D/launched-effect: launched Effect coroutine cancelled exception kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{
    
    Cancelling}@28abbfe
D/launched-effect: displaying launched effect for count 2
D/launched-effect: launched Effect coroutine cancelled exception kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{
    
    Cancelling}@14b5985
D/launched-effect: displaying launched effect for count 3

显示LaunchedEffect在启动时使用snackbarCount值为1执行协程,并在下次启动时使用snackbarCount值为2启动新的协程,取消前一个协程。你可以在日志中看到协程1和协程2的JobCancellationException

LaunchedEffect的应用

当我们希望在组合阶段开始时执行与UI相关的任务(挂起函数),LaunchedEffect通常非常有效。但是当传递的状态参数值发生变化时,它也会执行。以下是一些LaunchedEffect的应用场景。

  1. 滚动到特定位置的惰性列表:在聊天应用程序中,当用户第一次加载应用程序或聊天屏幕时,我们希望用户看到最新的消息,所以我们将聊天消息滚动到列表底部,可以使用以下代码实现,使用LaunchedEffect
LaunchedEffect(Unit, block = {
    
    
    lazyListState.scrollToItem(messages.size - 1)
})

我们正在将Unit作为参数传递,这意味着我们只想在用户首次进入屏幕即合成阶段时调用此suspend块。一旦用户进入屏幕,它将滚动到列表底部。

以下是包含此示例的Github项目存储库。

https://github.com/saqib-github-commits/BasicCompose

  1. 在组合中添加 Composable 时立即执行动画。有一篇关于在 Jetpack Compose 中使用动画的文章,你可以从那里阅读 -> 自定义画布动画在 JetpackCompose 中的使用

https://medium.com/androiddevelopers/custom-canvas-animations-in-jetpack-compose-e7767e349339

  1. 应用程序加载屏幕:在应用程序启动时显示加载屏幕也是 LaunchedEffect 的用例之一。我们如何实现它?看下面的代码。
    我们将创建一个 LoadingScreen 的组合函数。
@Composable
fun LoadingScreen(onTimeout: () -> Unit) {
    
    
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
    
    
        LaunchedEffect(Unit) {
    
    
            delay(5000L)
            onTimeout()
        }

        CircularProgressIndicator()
    }
}

LoadingScreen的可组合项(composable)显示一个全屏的可组合项(composable),其中央显示一个CircularProgressIndicator,以在UI中显示加载状态。LoadingScreen还使用Api LauncedEffect,并将Unit作为参数传递,因为我们希望仅在LoadingScreen进入屏幕,即在组合阶段期间,才启动传递的块。LaunchedEffect将执行一个暂停函数,该函数使用延迟(delay)模拟后端响应(我们尚未拥有后端),并在调用onTimeOut方法之前等待5秒钟来显示加载屏幕。

现在,我们需要更改MainActivity中的初始代码,以添加一个用于LoadingScreen的开关,如下所示。

var showLoading by remember {
    
    
      mutableStateOf(true)
}
  
if (showLoading) {
    
    
      LoadingScreen {
    
     showLoading = false }
} else {
    
    
      val snackbarHostState = SnackbarHostState()
      LaunchedEffectTestScreen(snackbarHostState, LaunchedEffectTestViewModel())
}

MainActivity中,我们记住了一个布尔状态,用来存储关于何时显示LoadingScreen的信息。初始值为true,所以会调用LoadingScreen,并传入一个lambda表达式,将showLoading标志设置为false。这个方法将在LoadingScreenLaunchedEffect内部的5秒后调用,正如我们之前看到的代码一样。所以在5秒后,showLoading标志变为false,然后进入else部分,显示LaunchedEffectTestScreen

完整的代码如下。

https://github.com/saqib-github-commits/JetpackComposeSuspendFunctions

  1. 当网络不可用时显示 Snackbar 消息:在实际项目中,通常我们希望在页面中显示一个自定义的通知视图,以显示网络状态(已连接/离线),通常在应用栏下面的页面顶部。但为了展示LaunchedEffect的示例,我在这里使用了 Snackbar

让我们来看一下 Composable。

@Composable
fun LaunchedEffectNetworkState(
    snackbarHostState: SnackbarHostState,
    viewModel: LaunchedEffectNetworkStateViewModel
) {
    
    
    val showNetworkUnavailable by viewModel.networkUnavailable.collectAsState()
    if (showNetworkUnavailable) {
    
    
        LaunchedEffect(Unit) {
    
    
            snackbarHostState.showSnackbar("Network Unavailable")
        }
    }

    Scaffold(
        snackbarHost = {
    
     SnackbarHost(hostState = snackbarHostState) }
    ) {
    
    
        Text(text = "Network State using LaunchedEffect")

    }
}

Composable正在观察showNetworkUnavailable中来自viewModel的状态。如果值为true,它将执行LaunchedEffect,显示一个关于网络不可用的snackbar消息,当值变为false时,LaunchedEffect将离开组合并取消之前启动的协程。

让我们看一下ViewModel以获得完整的信息。

class LaunchedEffectNetworkStateViewModel: ViewModel() {
    
    

    private var _networkUnavailable = MutableStateFlow(false)
    val networkUnavailable get() = _networkUnavailable.asStateFlow()

    init {
    
    
        viewModelScope.launch {
    
    
            delay(2000L)
            _networkUnavailable.value = true
        }
    }

}

ViewModel 模仿了网络不可用的效果,因为我们不需要为了示例而实现完整的网络状态监听器。ViewModel 使用初始值为 false 的 networkUnavailable StateFlow,并在协程中经过 2 秒后将 networkUnavailable 的值更改为 true。由于值在 2 秒后发生了变化,Composable 将在 2 秒后执行挂起函数,显示一个 Snackbar 消息。

就关于 LaunchedEffect 而言,就是这些。LaunchedEffect 有许多其他实际应用,但希望这些例子可以帮助您了解 LaunchedEffect 的一般用法。

rememberCoroutineScope 是副作用 API

LaunchedEffect 的副作用 API 有助于在组合阶段通过协程调用挂起函数。但是,在某些情况下,我们希望执行一些操作,但不是在组合中立即执行,而是在以后的某个时间点执行,例如当用户在 UI 上执行某些操作时。为此,我们需要一个作用域来启动协程,而 rememberCoroutineScope 提供了一个协程作用域,与调用它的 Composable 的作用域绑定,以便了解 Composable 的生命周期,并在离开组合时取消协程。通过该作用域,我们可以在不在组合内部时调用协程,即可以在用户操作期间在非 Composable 的作用域内启动协程。

rememberCoroutineScope 的底层实现

让我们来看看 rememberCoroutineScope 函数。

@Composable
inline fun rememberCoroutineScope(
    getContext: @DisallowComposableCalls () -> CoroutineContext = {
    
     EmptyCoroutineContext }
): CoroutineScope {
    
    
    val composer = currentComposer
    val wrapper = remember {
    
    
        CompositionScopedCoroutineScopeCanceller(
            createCompositionCoroutineScope(getContext(), composer)
        )
    }
    return wrapper.coroutineScope
}

一些要验证的要点如下:

  • rememberCoroutineScope是一个可组合函数。
  • 它创建了一个与当前可组合相关联的协程作用域,因此它将知道可组合的生命周期,并且在可组合离开组合时会自动取消。

rememberCoroutineScope示例

让我们看一下下面代码中使用rememberCoroutineScope的基本示例。

@Composable
fun RememberCoroutineScopeTestScreen ( ) {
    
    
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
    
    
        val coroutineScope = rememberCoroutineScope()
        var counter by remember {
    
     mutableStateOf(0) }

        Text(text = counter.toString())
        Spacer(modifier = Modifier.height(8.dp))
        Button(
            onClick = {
    
    
                coroutineScope.launch {
    
    
                    counter += 1
                }
            }
        ) {
    
    
            Text(text = "Button")
        }
    }
}

上述代码在屏幕上显示了一个文本和一个按钮。我们使用rememberCoroutineScope获取一个协程范围,并在按钮的onClick事件监听器中使用它来启动一个协程。该协程在每次用户按钮按下事件发生时递增计数器。onClick事件监听器不在组合范围内,它是一个事件监听器,所以我们需要显式地使用协程范围来在组合范围之外启动协程,但它与组合生命周期有关。

rememberCoroutineScope的应用

rememberCoroutineScope有许多实际应用。我们将看到一些我已经使用过的应用。

  1. 带有“返回顶部/底部”按钮的懒加载列表:通常情况下,我们会在用户执行特定操作时,通过UI上的按钮将列表内容滚动到底部或顶部。下面的代码展示了使用rememberCoroutineScope和启动协程执行那些挂起函数的情况,并应用在懒加载列表上。
// Button to Go To Bottom of the list
Button(onClick = {
    
    
  coroutineScope.launch {
    
     lazyListState.animateScrollToItem(messages.size - 1) }
}) {
    
    
  Text(text = "Go To Bottom")
}

// Button to Go To Top of the list
Button(onClick = {
    
    
  coroutineScope.launch {
    
     lazyListState.animateScrollToItem(0) }
}) {
    
    
  Text(text = "Go To Top")
}
  1. 使用Next和Prev按钮的ViewPager:滚动ViewPager以响应Next和Prev按钮操作也是使用rememberCoroutineScope的理想应用,如下面的代码所示。
 Button(
                enabled = prevButtonVisible.value,
                onClick = {
    
    
                    val prevPageIndex = pagerState.currentPage - 1
                    coroutineScope.launch {
    
     pagerState.animateScrollToPage(prevPageIndex) }
                },
            ) {
    
    
                Text(text = "Prev")
            }

            Button(
                enabled = nextButtonVisible.value ,
                onClick = {
    
    
                    val nextPageIndex = pagerState.currentPage + 1
                    coroutineScope.launch {
    
     pagerState.animateScrollToPage(nextPageIndex) }
                },
            ) {
    
    
                Text(text = "Next")
            }

下面是ViewPager实现示例的完整代码。

https://github.com/saqib-github-commits/JetpackComposeViewPager

LaunchedEffectrememberCoroutineScope的比较

以下是两者比较的重要点总结:

  • LaunchedEffectrememberCoroutineScope都是副作用API,用于以受控且可预测的方式执行副作用操作。
  • LaunchedEffect在可组合项的范围内执行挂起函数,而rememberCoroutineScope在可组合项的范围之外执行,但仍然受到可组合项生命周期的影响。
  • LaunchedEffectrememberCoroutineScope这两个API都在生命周期感知的方式下运行,并且在所创建的可组合项离开组合时立即取消启动的协程。
  • 通常在想要在可组合项的组合阶段执行操作(即用户第一次进入屏幕)或者当传递给它的任何状态参数发生变化时,会使用LaunchedEffect。 而当我们不在组合中,通常是用户执行某些操作,比如按钮点击,我们希望通过副作用来更新UI状态时,会使用rememberCoroutineScope
  • LaunchedEffectrememberCoroutineScope应只执行与UI相关的任务,不应违反单向数据流原则。

参考源码

https://github.com/saqib-github-commits/JetpackComposeSuspendFunctions

参考

https://developer.android.com/jetpack/compose/side-effects
https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects#0

猜你喜欢

转载自blog.csdn.net/u011897062/article/details/131243852