Compose 中的副作用

副作用是指在可组合函数作用域之外发生的应用程序状态更改。由于可组合项的生命周期和属性,例如不可预测的重新组合、以不同顺序执行可组合项的重新组合或可能被丢弃的重新组合,因此可组合项理想情况下应该是无副作用的

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

状态和效果用例

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

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

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

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

例如,这是一个使用可配置延迟脉冲 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)
    }
}

在上面的代码中,动画使用挂起函数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 重新组合的最新值,需要使用rememberUpdatedState函数包装onTimeout。应在效果中使用返回的State,即代码中的currentOnTimeout

DisposableEffect:需要清理的效果

对于在键更改或可组合项离开组合后需要清理的副作用,请使用DisposableEffect。如果DisposableEffect键更改,则可组合项需要*处理*(对其当前效果进行清理),然后通过再次调用效果来重置。

例如,您可能希望通过使用Lifecycle事件来发送分析事件,方法是使用LifecycleObserver。要在 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 中,每当观察到的状态对象或可组合输入发生变化时,就会发生重组。状态对象或输入可能比 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 转换为 Flows

使用 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,它们的值在 Composition 中永远不会改变。如果您不将 lifecycleOwner 作为参数传递并且它发生更改,则 HomeScreen 会重组,但 DisposableEffect 不会被释放和重新启动。这会导致问题,因为从那时起将使用错误的 lifecycleOwner

常量作为键

您可以使用像 true 这样的常量作为效果键,使其**遵循调用站点的生命周期**。它有一些有效的用例,例如上面显示的 LaunchedEffect 示例。但是,在这样做之前,请三思而后行,确保这就是您需要的。