Compose 中的副作用

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

然而,有时副作用是必要的,例如,在特定状态条件下触发一次性事件(如显示 Snackbar 或导航到另一个屏幕)。这些操作应从了解可组合项生命周期的受控环境中调用。在本页面中,您将了解 Jetpack Compose 提供的各种副作用 API。

状态和效应用例

Compose 思维模式文档中所述,可组合项应该没有副作用。当您需要更改应用的状态时(如状态管理文档所述),您应该使用效应 API,以便这些副作用以可预测的方式执行

由于效应在 Compose 中提供了多种可能性,它们很容易被过度使用。确保您在其中执行的工作与 UI 相关,并且不会破坏状态管理文档中解释的单向数据流

LaunchedEffect: 在可组合项范围内运行 suspend 函数

要在可组合项的整个生命周期内执行工作并能够调用 suspend 函数,请使用 LaunchedEffect 可组合项。当 LaunchedEffect 进入组合时,它会启动一个协程,其中包含作为参数传递的代码块。如果 LaunchedEffect 离开组合,协程将被取消。如果 LaunchedEffect 使用不同的键进行重组(请参阅下面的重新启动效应部分),现有协程将被取消,新的 suspend 函数将在新的协程中启动。

例如,以下是一个动画,它以可配置的延迟脉冲 alpha 值

// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
    while (isActive) {
        delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

在上面的代码中,动画使用 suspend 函数 delay 来等待设定的时间。然后,它使用 animateTo 依次将 alpha 值动画到零,然后再返回。这将在可组合项的整个生命周期内重复。

rememberCoroutineScope: 获取一个组合感知的范围,以在可组合项之外启动协程

由于 LaunchedEffect 是一个可组合函数,它只能在其他可组合函数内部使用。为了在可组合项之外启动协程,但使其范围限定在离开组合时自动取消,请使用 rememberCoroutineScope。此外,当您需要手动控制一个或多个协程的生命周期时,例如在用户事件发生时取消动画,请使用 rememberCoroutineScope

rememberCoroutineScope 是一个可组合函数,它返回一个绑定到调用它的组合点的 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 函数包装。返回的 State(在代码中为 currentOnTimeout)应该在效应中使用。

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 会创建一个协程,但它也可以用于观察非 suspend 数据源。要取消订阅该源,请使用 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 中,每当观察到的状态对象或可组合输入发生更改时,都会发生重组。状态对象或输入的变化频率可能高于 UI 实际需要更新的频率,从而导致不必要的重组。

当可组合项的输入变化频率高于您需要重组的频率时,您应该使用 derivedStateOf 函数。这通常发生在某个事物频繁变化(例如滚动位置),但可组合项仅在它越过某个阈值时才需要对其做出反应时。derivedStateOf 会创建一个新的 Compose 状态对象,您可以观察它,并且它只会按您需要的频率进行更新。通过这种方式,它的作用类似于 Kotlin Flows 的 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 示例。然而,在这样做之前,请三思并确保这正是您所需要的。