Compose 中的附带效应

附带效应是指发生在可组合函数作用域之外的应用状态的变化。由于可组合项的生命周期和属性(例如不可预测的重组、以不同顺序执行可组合项的重组或可以舍弃的重组),可组合项在理想情况下应该是无附带效应的

不过,有时附带效应是必要的,例如,触发一次性事件(例如显示信息提示控件),或在满足特定状态条件时进入另一个屏幕。这些操作应从能感知可组合项生命周期的受控环境中调用。在本页中,您将了解 Jetpack Compose 提供的不同附带效应 API。

状态和效应用例

Compose 编程思想文档中所述,可组合项应该没有附带效应。如果您需要更改应用的状态(如管理状态文档中所述),您应该使用 Effect API,以便以可预测的方式执行这些附带效应

由于效应会在 Compose 中带来各种可能性,所以很容易过度使用。确保您在其中完成的工作与界面相关,并且不会破坏单向数据流,如管理状态文档中所述。

LaunchedEffect:在可组合项的作用域内运行挂起函数

如需从可组合项内安全调用挂起函数,请使用 LaunchedEffect 可组合项。当 LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消。如果使用不同的键重组 LaunchedEffect(请参阅下方的重启效应部分),系统将取消现有协程,并在新的协程中启动新的挂起函数。

例如,在 Scaffold 中显示 Snackbar 是通过 SnackbarHostState.showSnackbar 函数完成的,该函数为挂起函数。

@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 ->
        // ...
    }
}

在上面的代码中,如果状态包含错误,则会触发协程,如果没有错误,则将取消协程。由于 LaunchedEffect 调用点在 if 语句中,因此当该语句为 false 时,如果 LaunchedEffect 包含在组合中,则会被移除,因此,协程将被取消。

rememberCoroutineScope:获取组合感知作用域,以在可组合项外启动协程

由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。为了在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消,请使用 rememberCoroutineScope。 此外,如果您需要手动控制一个或多个协程的生命周期,请使用 rememberCoroutineScope,例如在用户事件发生时取消动画。

rememberCoroutineScope 是一个可组合函数,会返回一个 CoroutineScope,该 CoroutineScope 绑定到调用它的组合点。调用退出组合后,作用域将取消。

根据上面的示例,当用户点按 Button 时,您可以使用以下代码显示 Snackbar

@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")
            }
        }
    }
}

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 */
}

为创建与调用点的生命周期相匹配的效应,永不发生变化的常量(如 Unittrue)将作为参数传递。在以上代码中,使用 LaunchedEffect(true)。为了确保 onTimeout lambda 始终包含重组 LandingScreen 时使用的最新值,onTimeout 需使用 rememberUpdatedState 函数封装。效应中应使用代码中返回的 StatecurrentOnTimeout

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。如果 lifecycleOwner 发生变化,系统会通过 lifecycleOwner 处理并再次重启效应。

DisposableEffect 必须在其代码块中添加 onDispose 子句作为最终语句。否则,IDE 将显示构建时错误。

SideEffect:将 Compose 状态发布到非 Compose 代码

如需与非 Compose 管理的对象共享 Compose 状态,请使用 SideEffect 可组合项。使用 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
}

produceState:将非 Compose 状态转换为 Compose 状态

produceState 会启动一个协程,该协程将作用域限定为可将值推送到返回的 State 的组合。使用此协程将非 Compose 状态转换为 Compose 状态,例如将外部订阅驱动的状态(如 FlowLiveDataRxJava)引入组合。

该制作工具在 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)
        }
    }
}

derivedStateOf:将一个或多个状态对象转换为其他状态

在 Compose 中,每次观察到的状态对象或可组合输入出现变化时都会发生重组。状态对象或输入的变化频率可能高于界面实际需要的更新频率,从而导致不必要的重组。

当可组合项输入的变化频率超过您需要的重组频率时,就应该使用 derivedStateOf 函数。这种情况通常是指,某些内容(例如滚动位置)频繁变化,但可组合项只有在超过某个阈值时才需要对其做出响应。derivedStateOf 会创建一个新的 Compose 状态对象,您可以观察到该对象只会按照您的需要进行更新。这样,它的作用就与 Kotlin Flow distinctUntilChanged() 运算符类似。

正确用法

以下代码段展示了适合使用 derivedStateOf 的情形:

@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 都会跟着发生变化。当您滚动屏幕时,它的值会变为 012345 等。但是,只有当值大于 0 时才需要进行重组。这种更新频率的不匹配意味着,这种情形很适合使用 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 只需要以与 firstNamelastName 一样的频率进行更新。因此,不会发生过多的重组,没有必要使用 derivedStateOf

snapshotFlow:将 Compose 的 State 转换为 Flow

使用 snapshotFlowState<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 运算符的强大功能。

重启效应

Compose 中有一些效应(如 LaunchedEffectproduceStateDisposableEffect)会采用可变数量的参数和键来取消运行效应,并使用新的键启动一个新的效应。

这些 API 的典型形式是:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

由于此行为的细微差别,如果用于重启效应的参数不是适当的参数,可能会出现问题:

  • 如果重启效应次数不够,可能会导致应用出现错误。
  • 如果重启效应次数过多,效率可能不高。

一般来说,效应代码块中使用的可变和不可变变量应作为参数添加到效应可组合项中。除此之外,您还可以添加更多参数,以便强制重启效应。如果更改变量不应导致效应重启,则应将该变量封装在 rememberUpdatedState 中。如果由于变量封装在一个不含键的 remember 中使之没有发生变化,则无需将变量作为键传递给效应。

在上面显示的 DisposableEffect 代码中,效应将其块中使用的 lifecycleOwner 作为参数,因为对它们的任何更改都会导致效应重新开始。

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

无需使用 currentOnStartcurrentOnStop 作为 DisposableEffect 键,因为我们使用了 rememberUpdatedState,所以它们的值在组合中绝不会发生变化。如果您没有传递 lifecycleOwner 作为参数,并且它发生变化,那么 HomeScreen 会重组,但 DisposableEffect 不会被处理和重新开始。这会导致出现问题,因为此后会使用错误的 lifecycleOwner

使用常量作为键

您可以使用 true 等常量作为效应键,使其遵循调用点的生命周期。它实际上具有有效的用例,如上面所示的 LaunchedEffect 示例。但在这样做之前,请审慎考虑,并确保您确实需要这么做。