Compose 中的副作用

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

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

状态和效果用例

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

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

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

要执行可组合项生命周期内的工作并能够调用挂起函数,请使用LaunchedEffect 可组合项。当LaunchedEffect进入 Composition 时,它会使用作为参数传递的代码块启动一个协程。如果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是一个可组合函数,它返回一个绑定到其被调用的 Composition 点的CoroutineScope。当调用离开 Composition 时,该作用域将被取消。

根据前面的示例,您可以使用此代码在用户点击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:需要清理的效果

对于需要在键更改或可组合项离开 Composition 后进行*清理*的副作用,请使用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启动一个作用域为 Composition 的协程,该协程可以将值推送到返回的State中。使用它将非 Compose 状态转换为 Compose 状态,例如将外部订阅驱动的状态(如FlowLiveDataRxJava)引入 Composition。

produceState进入 Composition 时,将启动生产者,并在其离开 Composition 时取消。返回的State会合并;设置相同的值不会触发重新组合。

即使produceState创建了一个协程,它也可以用于观察非挂起的数据源。要删除对该源的订阅,请使用awaitDispose 函数。

以下示例显示了如何使用produceState从网络加载图像。loadNetworkImage可组合函数返回一个State,该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 可以利用 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 示例。但是,在执行此操作之前,请三思而后行,并确保这就是您需要的。