Compose 中的附带效应

可组合项应该没有附带效应。 但是,如果在对应用状态进行转变时需要使用可组合项,应从能感知可组合项生命周期的受控环境中调用这些可组合项。在本页中,您将了解 Jetpack Compose 提供的不同附带效应 API。

状态和效应用例

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

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

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

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

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

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(scaffoldState.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.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}

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

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

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

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

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

@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler
                    // to show a snackbar
                    scope.launch {
                        scaffoldState.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 键发生变化,可组合项需要处理(执行清理操作)其当前效应,并通过再次调用效应进行重置。

例如,需要注册 OnBackPressedCallback 才能监听在 OnBackPressedDispatcher 上按下的返回按钮。 如需在 Compose 中监听这些事件,请根据需要使用 DisposableEffect 注册和取消注册回调。

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {

    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for
        // a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

在上面的代码中,效应将记住的 backCallback 添加到 backDispatcher。如果 backDispatcher 发生变化,系统会处理并再次重启效应。

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

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

如需与非 Compose 管理的对象共享 Compose 状态,请使用 SideEffect 可组合项,因为每次成功重组时都会调用该可组合项。

以之前的 BackHandler 代码为例,如需传达是否应启用回调,请使用 SideEffect 更新其值。

@Composable
fun BackHandler(
    backDispatcher: OnBackPressedDispatcher,
    enabled: Boolean = true, // Whether back events should be intercepted or not
    onBack: () -> Unit
) {
    /* ... */
    val backCallback = remember { /* ... */ }

    // On every successful composition, update the callback with the `enabled` value
    // to tell `backCallback` whether back events should be intercepted or not
    SideEffect {
        backCallback.isEnabled = enabled
    }

    /* Rest of the code */
}

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

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

该制作工具在 produceState 进入组合时启动,在其退出组合时取消。返回的 State 冲突;设置相同的值不会触发重组。

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

以下示例展示了如何使用 produceState 从网络加载图像。loadNetworkImage 可组合函数会返回可以在其他可组合项中使用的 State

@Composable
fun loadNetworkImage(
    url: String,
    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 keys.
    return produceState(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:将一个或多个状态对象转换为其他状态

如果某个状态是从其他状态对象计算或派生得出的,请使用 derivedStateOf。使用此函数可确保仅当计算中使用的状态之一发生变化时才会进行计算。

以下示例展示了基本的“待办事项”列表,其中具有用户定义的高优先级关键字的任务将首先显示:

@Composable
fun TodoList(
    highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")
) {
    val todoTasks = remember { mutableStateListOf<String>() }

    // Calculate high priority tasks only when the todoTasks or
    // highPriorityKeywords change, not on every recomposition
    val highPriorityTasks by remember(todoTasks, highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter { it.containsWord(highPriorityKeywords) }
        }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

在以上代码中,derivedStateOf 保证每当 todoTaskshighPriorityKeywords 发生变化时,系统都会执行 highPriorityTasks 计算,并相应地更新界面。由于执行过滤以计算 highPriorityTasks 的成本很高,因此应仅在任何列表更改时执行,而不是在每次重组时执行。

此外,更新 derivedStateOf 生成的状态不会导致可组合项在声明它的位置重组,Compose 仅对其返回状态为已读的可组合项(在本例中,指 LazyColumn 中的可组合项)进行重组。

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 代码中,效应将其块中使用的 backDispatcher 作为参数,因为对它们的任何更改都会导致效应重启。

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
    /* ... */
    val backCallback = remember { /* ... */ }

    DisposableEffect(backDispatcher) {
        backDispatcher.addCallback(backCallback)
        onDispose {
            backCallback.remove()
        }
    }
}

无需使用 backCallback 作为 DisposableEffect 键,因为它的值在组合中绝不会发生变化;它封装在不含键的 remember 中。如果未将 backDispatcher 作为参数传递,并且该代码发生变化,那么 BackHandler 将重组,但 DisposableEffect 不会进行处理和重启。这将导致问题,因为此后会使用错误的 backDispatcher

使用常量作为键

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