Jetpack Compose中的LaunchedEffect与rememberCoroutineScope
深入了解Compose副作用API LaunchedEffect
和rememberCoroutineScope
。
探索使用LaunchedEffect
和rememberCoroutineScope
的区别和使用场景。
什么是副作用?
副作用是指在可组合函数范围之外发生的任何事情,最终会影响可组合函数,可能是一些状态的改变或在用户界面上发生的与可组合有关的用户操作。这两个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
的应用场景。
- 滚动到特定位置的惰性列表:在聊天应用程序中,当用户第一次加载应用程序或聊天屏幕时,我们希望用户看到最新的消息,所以我们将聊天消息滚动到列表底部,可以使用以下代码实现,使用
LaunchedEffect
。
LaunchedEffect(Unit, block = {
lazyListState.scrollToItem(messages.size - 1)
})
我们正在将Unit作为参数传递,这意味着我们只想在用户首次进入屏幕即合成阶段时调用此suspend
块。一旦用户进入屏幕,它将滚动到列表底部。
以下是包含此示例的Github项目存储库。
https://github.com/saqib-github-commits/BasicCompose
- 在组合中添加 Composable 时立即执行动画。有一篇关于在 Jetpack Compose 中使用动画的文章,你可以从那里阅读 -> 自定义画布动画在 JetpackCompose 中的使用
https://medium.com/androiddevelopers/custom-canvas-animations-in-jetpack-compose-e7767e349339
- 应用程序加载屏幕:在应用程序启动时显示加载屏幕也是
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。这个方法将在LoadingScreen
的LaunchedEffect
内部的5秒后调用,正如我们之前看到的代码一样。所以在5秒后,showLoading
标志变为false,然后进入else部分,显示LaunchedEffectTestScreen
。
完整的代码如下。
https://github.com/saqib-github-commits/JetpackComposeSuspendFunctions
- 当网络不可用时显示
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
有许多实际应用。我们将看到一些我已经使用过的应用。
- 带有“返回顶部/底部”按钮的懒加载列表:通常情况下,我们会在用户执行特定操作时,通过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")
}
- 使用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
LaunchedEffect
和rememberCoroutineScope
的比较
以下是两者比较的重要点总结:
LaunchedEffect
和rememberCoroutineScope
都是副作用API,用于以受控且可预测的方式执行副作用操作。LaunchedEffect
在可组合项的范围内执行挂起函数,而rememberCoroutineScope
在可组合项的范围之外执行,但仍然受到可组合项生命周期的影响。LaunchedEffect
和rememberCoroutineScope
这两个API都在生命周期感知的方式下运行,并且在所创建的可组合项离开组合时立即取消启动的协程。- 通常在想要在可组合项的组合阶段执行操作(即用户第一次进入屏幕)或者当传递给它的任何状态参数发生变化时,会使用
LaunchedEffect
。 而当我们不在组合中,通常是用户执行某些操作,比如按钮点击,我们希望通过副作用来更新UI状态时,会使用rememberCoroutineScope
。 LaunchedEffect
和rememberCoroutineScope
应只执行与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