生命周期和附带效应

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

可组合项的生命周期

正如管理状态文档中所述,一个组合将描述应用的界面,并通过运行可组合项来生成。组合是描述界面的可组合项的树结构。

当 Jetpack Compose 首次运行可组合项时,在初始组合期间,它将跟踪您为了描述组合中的界面而调用的可组合项。然后,当应用的状态发生变化时,Jetpack Compose 会安排重组。重组是指 Jetpack Compose 重新执行可能因状态更改而更改的可组合项,然后更新组合以反映所有更改。

组合只能通过初始组合生成且只能通过重组进行更新。重组是修改组合的唯一方式。

展示可组合项生命周期的示意图

图 1. 组合中可组合项的生命周期。进入组合,执行 0 次或多次重组,然后退出组合。

重组通常由对 State<T> 对象的更改触发。Compose 会跟踪这些操作,并运行组合中读取该特定 State<T> 的所有可组合项以及这些操作调用的无法跳过的所有可组合项。

如果某一可组合项多次被调用,在组合中将放置多个实例。每次调用在组合中都有自己的生命周期。

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

展示上一个代码段中元素的分层排列的示意图

图 2. 组合中 MyComposable 的表示。如果某一可组合项多次被调用,在组合中将放置多个实例。如果某一元素具有不同颜色,则表明它是一个独立实例。

组合中可组合项的剖析

组合中可组合项的实例由其调用点进行标识。Compose 编译器将每个调用点都视为不同的调用点。从多个调用站点调用可组合项会在组合中创建多个可组合项实例。

在重组期间,可组合项调用的可组合项与上个组合期间调用的可组合项不同,Compose 将确定调用或未调用的可组合项,对于在两次组合中均调用的可组合项,如果其输入未更改,Compose 将避免重组这些可组合项

保留身份对于将附带效应与可组合项相关联十分重要,这样它们才能成功完成,而不是每次重组时都重新启动。

请参考以下示例:

@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

@Composable
fun LoginInput() { /* ... */ }

在上面的代码段中,LoginScreen 将有条件地调用 LoginError 可组合项,并始终调用 LoginInput 可组合项。每个调用都有唯一的调用点和源位置,编译器将使用它们对调用进行唯一识别。

展示 showError 标志更改为 true 时如何重组上述代码的示意图。添加了 LoginError 可组合项,但其他可组合项不会被重组。

图 3. 出现状态更改和重组时,组合中 LoginScreen 的表示。颜色相同,表示尚未重组。

即使 LoginInput 从第一次被调用变为第二次被调用,LoginInput 实例仍将在不同重组中保留下来。此外,由于 LoginInput 不包含任何在重组过程中更改过的参数,因此 Compose 将跳过对 LoginInput 的调用。

添加额外信息以促进智能重组

多次调用同一可组合项也会多次将其添加到组合中。如果从同一个调用点多次调用某个可组合项,Compose 就无法唯一标识对该可组合项的每次调用,因此除了调用点之外,还会使用执行顺序来区分实例。这种行为有时是必需的,但在某些情况下会导致发生意外行为。

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // MovieOverview composables are placed in Composition given its
            // index position in the for loop
            MovieOverview(movie)
        }
    }
}

在上面的示例中,Compose 除了使用调用点之外,还使用执行顺序来区分组合中的实例。如果列表底部新增了一个 movie,Compose 可以重复使用组合中既有的实例,因为这些实例在列表中的位置没有发生变化,因此这些实例的 movie 输入是相同的。

展示将新元素添加到列表底部后上述代码重组方式的示意图。列表中的其他项并未改变位置,也不会重组。

图 4. 列表底部新增元素时,组合中 MoviesScreen 的表示。可以重复使用组合中的 MovieOverview 可组合项。MovieOverview 中的相同颜色表示该可组合项尚未重组。

但是,如果因在列表顶部或中间新增内容,移除项目或对项目进行重新排序而导致 movies 列表发生改变,将导致输入参数在列表中的位置已更改的所有 MovieOverview 调用发生重组。例如,如果 MovieOverview 使用附带效应提取影片图像,这一点将非常重要。如果在使用附带效应的过程中发生重组,系统就会取消重组并重新开始。

@Composable
fun MovieOverview(movie: Movie) {
    Column {
        // Side effect explained later in the docs. If MovieOverview
        // recomposes, while fetching the image is in progress,
        // it is cancelled and restarted.
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)

        /* ... */
    }
}

展示将新元素添加到列表顶部后上述代码重组方式的示意图。列表中的所有其他项都会更改位置,并且需要重组。

图 5. 向列表中添加新元素后,组合中 MoviesScreen 的表示。MovieOverview 可组合项不能重复使用,并且所有附带效应都将重启。MovieOverview 中不同的颜色表示该可组合项已重组。

理想情况下,我们认为 MovieOverview 实例的身份与传递到该实例的 movie 的身份相关联。如果我们对影片列表进行重新排序,理想情况下,我们会以类似方式在组合树中对实例进行重新排序,而不是将每个 MovieOverview 可组合项与不同影片实例进行重组。您可以使用 Compose 来告诉运行时,您要使用哪些值来标识树的给定部分:key 可组合项。

通过调用带有一个或多个传入值的键可组合项来封装代码块,这些值将被组合以用于在组合中标识该实例。key 的值不必是全局唯一的,只需要在调用点处调用可组合项的作用域内确保其唯一性即可。在此示例中,每个 movie 都需要一个在 movies 的作用域内具有唯一性的 key;movie 也可以与应用中其他位置的其他可组合项共享该 key

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // Unique ID for this movie
                MovieOverview(movie)
            }
        }
    }
}

使用上述代码后,即使列表中的元素发生变化,Compose 也能识别 MovieOverview 的各个调用,还可以重复使用这些调用。

展示将新元素添加到列表顶部后上述代码重组方式的示意图。由于列表项是通过键进行标识的,因此 Compose 知道,即使它们的位置发生变化,也不会对它们进行重组。

图 6. 向列表中添加新元素后,组合中 MoviesScreen 的表示。由于 MovieOverview 可组合项具有唯一键,因此 Compose 会识别未更改的 MovieOverview 实例,并且可重复使用它们;它们的附带效应将继续执行。

一些可组合项提供对 key 可组合项的内置支持。例如,LazyColumn 接受在 items DSL 中指定自定义 key

@Composable
fun MoviesScreen(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

如果输入未更改,则跳过

如果组合中已有可组合项,当所有输入都处于稳定状态且没有变化时,可以跳过重组。

稳定类型必须符合以下协定:

  • 对于相同的两个实例,其 equals 的结果将始终相同。
  • 如果类型的某个公共属性发生变化,组合将收到通知。
  • 所有公共属性类型也都是稳定。

有这样一些归入此协定的重要通用类型,即使未使用 @Stable 注解来显式标记为稳定的类型,Compose 编译器也会将其视为稳定的类型。

  • 所有基元值类型:BooleanIntLongFloatChar 等。
  • 字符串
  • 所有函数类型 (lambda)

所有这些类型都可以遵循稳定协定,因为它们是不可变的。由于不可变类型绝不会发生变化,它们就永远不必通知组合更改方面的信息,因此遵循该协定就容易得多。

Compose 的 MutableState 类型是一种众所周知稳定但可变的类型。如果 MutableState 中保留了值,状态对象整体会被视为稳定对象,因为 State.value 属性如有任何更改,Compose 就会收到通知。

当作为参数传递到可组合项的所有类型都很稳定时,系统会根据可组合项在界面树中的位置来比较参数值,以确保相等性。如果所有值自上次调用后未发生变化,则会跳过重组。

Compose 仅在可以证明稳定的情况下才会认为类型是稳定的。例如,接口通常被视为不稳定类型,并且具有可变公共属性的类型(实现可能不可变)的类型也被视为不稳定类型。

如果 Compose 无法推断类型是否稳定,但您想强制 Compose 将其视为稳定类型,请使用 @Stable 注解对其进行标记。

// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}

在上面的代码段中,由于 UiState 是接口,因此 Compose 通常认为此类型不稳定。通过添加 @Stable 注解,您可以告知 Compose 此类型稳定,让 Compose 优先选择智能重组。这也意味着,如果将该接口用作参数类型,Compose 会将其所有实现视为稳定。

状态和效应用例

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` 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` 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 示例。但在这样做之前,请审慎考虑,并确保您确实需要这么做。