官方说明文档:https://developer.android.google.cn/develop/ui/compose/side-effects
前言
附带效应是指发生在可组合函数作用域之外的应用状态的变化。由于可组合项的生命周期和特性(例如可组合函数可以按任何顺序执行、可组合函数可以并行运行、重组会跳过尽可能多的内容),可组合项在理想情况下应该是无附带效应的。
不过,有时附带效应是必要的,例如,触发一次性事件(例如显示信息提示控件),或在满足特定状态条件时进入另一个屏幕。这些操作应从能感知可组合项生命周期的受控环境中调用。这就需要使用Jetpack Compose 提供的不同附带效应 API。
LaunchedEffect:在可组合项的作用域内运行挂起函数
LaunchedEffect
本身也是一个可组合函数,同时是一个绑定了可组合函数生命周期的协程。如果需要从可组合项内安全调用suspend
挂起函数,需要使用LaunchedEffect
可组合项。当LaunchedEffect
进入组合时,它会启动一个协程,并将代码块作为参数传递。如果LaunchedEffect
退出组合,协程将取消。LaunchedEffect
可以传递一个参数key
,如果key
发生变化,系统将取消现有协程,并在新的协程中启动新的挂起函数。
@Composable
fun MyScreen(
state: UiState<List<Movie>>,
snackbarHostState: SnackbarHostState
) {
// If the UI state contains an error, show snackbar
if (state.hasError) {
// `LaunchedEffect` will cancel and re-launch if
// `scaffoldState.snackbarHostState` changes
LaunchedEffect(snackbarHostState) {
// Show snackbar using a coroutine, when the coroutine is cancelled the
// snackbar will automatically dismiss. This coroutine will cancel whenever
// `state.hasError` is false, and only start when `state.hasError` is true
// (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
// ...
}
}
在Scaffold中显示Snackbar是通过SnackbarHostState.showSnackbar函数完成的,该函数为挂起函数,因此需要LaunchedEffect
使在可组合项内安全调用挂起函数。
在上面的代码中,如果状态包含错误,LaunchedEffect
进入组合内,则会触发协程,展示snackbar,如果没有错误,LaunchedEffect
退出组合,协程将被取消。当snackbarHostState改变时,协程将被重启。
rememberCoroutineScope:获取组合感知作用域,以在可组合项外启动协程
由于 LaunchedEffect
是可组合函数,因此只能在其他可组合函数中使用。为了在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消,这就需要使用rememberCoroutineScope
。此外,如果需要手动控制一个或多个协程的生命周期,也需要使用rememberCoroutineScope
,例如在用户事件发生时取消动画。
rememberCoroutineScope
是一个可组合函数,会返回一个 CoroutineScope
,该 CoroutineScope
绑定到调用它的组合点。调用退出组合后,作用域将取消。
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// Creates a CoroutineScope bound to the MoviesScreen's lifecycle
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// Create a new coroutine in the event handler to show a snackbar
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
在上面的代码中,rememberCoroutineScope
创建了一个绑定了可组合函数生命周期的协程作用域,在onClick
代码块中启动了一个协程来执行挂起函数showSnackbar。
解释两点:
-
onClick
代码块并不是一个可组合点,所以这里不能执行可组合函数,所以这里不能使用LaunchedEffect
来启动协程。 - 如果需要在
onClick
中执行一个网络请求或者运行其它挂起函数都应该使用rememberCoroutineScope
来通过启动协程的方式执行。
rememberUpdatedState:在效应中引用值,该值在值发生更改时不应重启
当其中一个键参数发生变化时,LaunchedEffect
会重启。不过,在某些情况下,可能希望在效应中捕获某个值,但如果该值发生变化,您不希望效应重启。为此,需要使用 rememberUpdatedState
来创建对可捕获和更新的该值的引用。这种方法对于包含长期操作的效应十分有用,因为重新创建和重启这些操作可能代价高昂或令人望而却步。
看描述有点复杂,让我们来看个例子:假设应用的 LandingScreen
在一段时间后消失。即使 LandingScreen
已重组,等待一段时间并发出时间已过通知的效应也不应该重启。
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes, the delay shouldn't start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* Landing screen content */
}
在上面的代码中,为创建与调用点的生命周期相匹配的效应,永不发生变化的常量(如 Unit
或 true
)将作为参数传递。在以上代码中,使用 LaunchedEffect(true)
。为了确保 onTimeout
lambda 始终包含重组 LandingScreen
时使用的最新值,onTimeout
需使用 rememberUpdatedState
函数封装。效应中应使用代码中返回的 State
、currentOnTimeout
。
rememberUpdatedState()
函数的使用场景实在有些特殊,让我们来总结一下:
- 在长生命周期的 lambda 里引用某个外部变量(常见于
LaunchedEffect
或DisposableEffect
); - 这个变量的值会在重组中被更新;
- 希望使用这个变量的最新值;
- 不希望新值导致长生命周期 lambda 的重新执行(例如导致
LaunchedEffect
或DisposableEffect
的重启)。
DisposableEffect:需要清理的效果
对于需要在键发生变化或可组合项退出组合后进行清理的附带效应,请使用DisposableEffect
。如果DisposableEffect
键发生变化,可组合项需要处理(执行清理操作)其当前效应,并通过再次调用效应进行重置。
例如:当需要使用LifecycleObserver
,根据Lifecycle
事件发送分析事件。在Compose中监听这些事件,根据需要使用 DisposableEffect
注册和取消注册观察器。
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// Safely update the current lambdas when a new one is provided
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// If `lifecycleOwner` changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
// Create an observer that triggers our remembered callbacks
// for sending analytics events
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)
// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
在上面的代码中,效应将 observer
添加到 lifecycleOwner
。
当可组合项退出组合后,会触发onDispose
,将 observer
移除。当lifecycleOwner
发生变化,会触发onDispose
进行处理并再次重启效应。
DisposableEffect
必须在其代码块中添加 onDispose
子句作为最终语句。否则,IDE 将显示构建时错误。
SideEffect:将Compose状态发布到非Compose代码
如需与非Compose管理的对象共享Compose状态,请使用SideEffect
可组合项。使用 SideEffect
可保证效果在每次成功重组后都会执行。
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// On every successful composition, update FirebaseAnalytics with
// the userType from the current User, ensuring that future analytics
// events have this metadata attached
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
在上面的代码中,当可组合函数rememberAnalytics
发生重组时,analytics
都会更新用户信息。
关于SideEffect
补充以下几点:
- 使用
SideEffect
后在每次成功重组,都会调用这个附带效应,LaunchedEffect
并不是每次都会调用,如果LaunchedEffect
的key
不变化是不会被调用的。 - 如果
analytics
是有状态的变量,使用SideEffect
后不会引起可组合函数重组。
produceState:将非Compose状态转换为Compose状态
produceState
会启动一个协程,该协程将作用域限定为可将值推送到返回的State
的组合。和SideEffect
相反,使用此协程将非Compose状态转换为Compose状态,例如将外部订阅驱动的状态(如Flow
、LiveData
或RxJava
)引入组合。
该效应在produceState
进入组合时启动,在其退出组合时取消。返回的 State
冲突;设置相同的值不会触发重组。
即使 produceState
创建了一个协程,它也可用于观察非挂起的数据源。如需移除对该数据源的订阅,请使用 awaitDispose
函数。
以下示例展示了如何使用 produceState
从网络加载图像。loadNetworkImage
可组合函数会返回可以在其他可组合项中使用的 State
。
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
// Creates a State<T> with Result.Loading as initial value
// If either `url` or `imageRepository` changes, the running producer
// will cancel and will be re-launched with the new inputs.
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// In a coroutine, can make suspend calls
val image = imageRepository.load(url)
// Update State with either an Error or Success result.
// This will trigger a recomposition where this State is read
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
要点:produceState
在后台充分利用其他效应!它使用 remember { mutableStateOf(initialValue) }
保留 result
变量,并在 LaunchedEffect
中触发 producer
块。每当 producer
块中的 value
更新时,result
状态都会更新为新值。
derivedStateOf:将一个或多个状态对象转换为其他状态
在Compose中,每次观察到的状态对象或可组合输入出现变化时都会发生重组。状态对象或输入的变化频率可能高于界面实际需要的更新频率,从而导致不必要的重组。
当可组合项输入的变化频率超过您需要的重组频率时,就应该使用derivedStateOf
函数。这种情况通常是指,某些内容(例如滚动位置)频繁变化,但可组合项只有在超过某个阈值时才需要对其做出响应。derivedStateOf
会创建一个新的Compose状态对象,您可以观察到该对象只会按照您的需要进行更新。这样,它的作用就与Kotlin Flow distinctUntilChanged()
运算符类似。
@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
在上面的代码中,每当第一个可见项发生变化时,firstVisibleItemIndex
都会跟着发生变化。当滚动屏幕时,它的值会变为0
、1
、2
、3
、4
、5
等。但是,只有当值大于0
时才需要进行重组。这种更新频率的不匹配意味着,这种情形很适合使用derivedStateOf
。
注意: derivedStateOf
的成本较高,只应该在结果没有变化时用来避免不必要的重组。
一种常见的错误用法是,在组合两个Compose状态对象时使用derivedStateOf
,因为这是在“派生状态”。然而,这纯粹只是开销,并不是必需的,如以下代码段所示:
// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct
在上面的代码段中,fullName
只需要以与 firstName
和 lastName
一样的频率进行更新。因此,不会发生过多的重组,没有必要使用 derivedStateOf
。
snapshotFlow:将Compose的State转换为Flow
使用snapshotFlow
将State<T>
对象转换为冷 Flow。snapshotFlow
会在收集到块时运行该块,并发出从块中读取的 State
对象的结果。当在 snapshotFlow
块中读取的 State
对象之一发生变化时,如果新值与之前发出的值不相等,Flow 会向其收集器发出新值(此行为类似于 Flow.distinctUntilChanged
的行为)。
下列示例显示了一项附带效应,是系统在用户滚动经过要分析的列表的首个项目时记录下来的:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
在上方代码中,listState.firstVisibleItemIndex
被转换为一个 Flow,从而可以受益于 Flow 运算符的强大功能。