可组合项的生命周期

在本页面中,您将了解可组合项的生命周期以及 Compose 如何决定可组合项是否需要重新组合。

生命周期概览

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

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

组合只能通过初始组合生成,并通过重新组合更新。修改组合的唯一方法是通过重新组合。

Diagram showing the lifecycle of a composable

图 1. 可组合项在组合中的生命周期。它进入组合,重新组合 0 次或多次,然后离开组合。

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

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

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

Diagram showing the hierarchical arrangement of the elements in the previous code snippet

图 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() { /* ... */ }

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

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

Diagram showing how the preceding code is recomposed if the showError flag is changed to true. The LoginError composable is added, but the other composables are not recomposed.

图 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 除了调用点之外,还使用执行顺序来保持实例在组合中的区分。如果将新电影添加到列表的底部,Compose 可以重用组合中已有的实例,因为它们在列表中的位置没有改变,因此这些实例的电影输入是相同的。

Diagram showing how the preceding code is recomposed if a new element is added to the bottom of the list. The other items in the list have not changed position, and are not recomposed.

图 4. 新元素添加到列表底部时 MoviesScreen 在组合中的表示。组合中的 MovieOverview 可组合项可以被重用。MovieOverview 中相同的颜色表示该可组合项尚未重新组合。

但是,如果电影列表因添加到列表的顶部中间、移除或重新排序项而发生更改,则会导致所有输入参数在列表中位置发生改变的 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)

        /* ... */
    }
}

Diagram showing how the preceding code is recomposed if a new element is added to the top of the list. Every other item in the list changes position and has to be recomposed.

图 5. 新元素添加到列表时 MoviesScreen 在组合中的表示。MovieOverview 可组合项无法重用,所有副作用都将重新开始。MovieOverview 中不同的颜色表示该可组合项已重新组合。

理想情况下,我们希望将 MovieOverview 实例的身份与传递给它的 movie 的身份关联起来。如果我们重新排序电影列表,理想情况下我们也会相应地重新排序组合树中的实例,而不是用不同的电影实例重新组合每个 MovieOverview 可组合项。Compose 提供了一种方法,让您可以告诉运行时您希望使用哪些值来标识树的给定部分:即 key 可组合项。

通过将代码块与对 key 可组合项的调用包装起来,并传入一个或多个值,这些值将组合起来用于在组合中标识该实例。key 的值不需要全局唯一,它只需要在调用点上可组合项的调用中是唯一的。因此,在此示例中,每部电影都需要有一个在所有电影中唯一的 key;如果它与应用中其他地方的某个可组合项共享该 key,那也无妨。

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

有了上述功能,即使列表上的元素发生变化,Compose 也能识别对 MovieOverview 的独立调用并可以重用它们。

Diagram showing how the preceding code is recomposed if a new element is added to the top of the list. Because the list items are identified by keys, Compose knows not to recompose them, even though their positions have changed.

图 6. 新元素添加到列表时 MoviesScreen 在组合中的表示。由于 MovieOverview 可组合项具有唯一的键,Compose 会识别哪些 MovieOverview 实例没有改变,并可以重用它们;它们的副作用将继续执行。

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

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

如果输入未更改则跳过

在重新组合期间,如果某些符合条件的可组合函数的输入自上次组合以来没有改变,则可以完全跳过它们的执行。

可组合函数符合跳过条件,除非

  • 该函数具有非 Unit 返回类型
  • 该函数已使用 @NonRestartableComposable@NonSkippableComposable 进行注解
  • 必需参数的类型不稳定

有一个实验性编译器模式,强跳过 (Strong Skipping),它放宽了最后一个要求。

为了使类型被视为稳定,它必须符合以下约定

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

有一些重要的常见类型符合此约定,Compose 编译器会将其视为稳定类型,即使它们没有通过使用 @Stable 注解显式标记为稳定。

  • 所有原始值类型:BooleanIntLongFloatChar 等。
  • 字符串
  • 所有函数类型(lambda 表达式)

所有这些类型都能够遵循稳定契约,因为它们是不可变的。由于不可变类型永不改变,因此它们无需通知组合更改,因此遵循此契约要容易得多。

一个值得注意的稳定但可变的类型是 Compose 的 MutableState 类型。如果一个值保存在 MutableState 中,则整个状态对象被认为是稳定的,因为 Compose 会收到对 State.value 属性的任何更改通知。

当作为参数传递给可组合项的所有类型都是稳定类型时,将根据可组合项在 UI 树中的位置比较参数值的相等性。如果所有值自上次调用以来未发生变化,则跳过重新组合。

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 会将其所有实现视为稳定。