Jetpack Compose 阶段

像大多数其他 UI 工具包一样,Compose 通过几个不同的阶段渲染一个帧。如果我们看 Android View 系统,它有三个主要阶段:测量、布局和绘制。Compose 非常相似,但在开始时有一个重要的额外阶段,称为组合

组合在我们的 Compose 文档中有所描述,包括Compose 思想以及状态和 Jetpack Compose

帧的三个阶段

Compose 有三个主要阶段

  1. 组合显示什么 UI。Compose 运行可组合函数并创建 UI 的描述。
  2. 布局在何处放置 UI。此阶段包括两个步骤:测量和放置。布局元素测量并将其自身和任何子元素放置在 2D 坐标中,适用于布局树中的每个节点。
  3. 绘制如何渲染。UI 元素绘制到 Canvas 上,通常是设备屏幕。
An image of the three phases in which Compose transforms data into UI (in order, data, composition, layout, drawing, UI).
图 1. Compose 将数据转换为 UI 的三个阶段。

这些阶段的顺序通常是相同的,允许数据从组合到布局再到绘制单向流动以生成一个帧(也称为单向数据流)。BoxWithConstraintsLazyColumnLazyRow 是值得注意的例外,它们的子项组合取决于父项的布局阶段。

从概念上讲,这些阶段中的每一个都会在每个帧中发生;然而,为了优化性能,Compose 会避免重复执行在所有这些阶段中从相同输入计算出相同结果的工作。如果 Compose 可以重用先前结果,它会跳过运行可组合函数,并且 Compose UI 在不需要时不会重新布局或重新绘制整个树。Compose 只执行更新 UI 所需的最小工作量。这种优化之所以可能,是因为 Compose 会跟踪不同阶段中的状态读取。

了解阶段

本节更详细地描述了 Compose 的三个阶段如何为可组合项执行。

组合

在组合阶段,Compose 运行时执行可组合函数并输出一个表示您 UI 的树形结构。此 UI 树由布局节点组成,这些节点包含后续阶段所需的所有信息,如下图所示

图 2. 在组合阶段创建的表示您 UI 的树。

代码和 UI 树的一部分如下图所示

A code snippet with five composables and the resulting UI tree, with child nodes branching from their parent nodes.
图 3. UI 树的一部分及其对应的代码。

在这些示例中,代码中的每个可组合函数都映射到 UI 树中的一个布局节点。在更复杂的示例中,可组合项可以包含逻辑和控制流,并根据不同的状态生成不同的树。

布局

在布局阶段,Compose 使用组合阶段生成的 UI 树作为输入。布局节点的集合包含决定每个节点在 2D 空间中的大小和位置所需的所有信息。

图 4. 布局阶段 UI 树中每个布局节点的测量和放置。

在布局阶段,树通过以下三步算法进行遍历

  1. 测量子项:如果存在子项,节点会测量其子项。
  2. 决定自身大小:基于这些测量结果,节点决定自己的大小。
  3. 放置子项:每个子节点相对于节点自身的位置进行放置。

在此阶段结束时,每个布局节点都具有

  • 分配的宽度高度
  • 一个应绘制的 x, y 坐标

回想上一节中的 UI 树

A code snippet with five composables and the resulting UI tree, with child nodes branching from their parent nodes

对于此树,算法工作方式如下

  1. The Row 测量其子项 ImageColumn
  2. The Image 被测量。它没有子项,因此它决定自己的大小并向 Row 报告大小。
  3. The Column 接下来被测量。它首先测量自己的子项(两个 Text 可组合项)。
  4. 第一个 Text 被测量。它没有子项,因此它决定自己的大小并向 Column 报告大小。
    1. 第二个 Text 被测量。它没有子项,因此它决定自己的大小并向 Column 报告大小。
  5. The Column 使用子项测量结果来决定自己的大小。它使用子项的最大宽度和子项高度的总和。
  6. The Column 将其子项相对于自身放置,使它们垂直地一个接一个排列。
  7. The Row 使用子项测量结果来决定自己的大小。它使用子项的最大高度和子项宽度的总和。然后它放置其子项。

请注意,每个节点只被访问了一次。Compose 运行时只需要一次遍历 UI 树即可测量和放置所有节点,这提高了性能。当树中的节点数量增加时,遍历时间呈线性增长。相比之下,如果每个节点被访问多次,遍历时间将呈指数级增长。

绘制

在绘制阶段,树会再次从上到下遍历,每个节点依次在屏幕上绘制自身。

图 5. 绘制阶段在屏幕上绘制像素。

使用前面的示例,树内容按以下方式绘制

  1. The Row 绘制它可能有的任何内容,例如背景颜色。
  2. The Image 绘制自身。
  3. The Column 绘制自身。
  4. 第一个和第二个 Text 分别绘制自身。

图 6. UI 树及其绘制表示。

状态读取

当您在上面列出的某个阶段读取快照状态的值时,Compose 会自动跟踪读取该值时正在执行的操作。此跟踪功能允许 Compose 在状态值更改时重新执行读取器,这是 Compose 中状态可观察性的基础。

状态通常使用 mutableStateOf() 创建,然后通过两种方式之一访问:直接访问 value 属性,或者使用 Kotlin 属性委托。您可以在可组合项中的状态中了解更多信息。就本指南而言,“状态读取”指代上述两种等效访问方法中的任何一种。

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

属性委托的底层,“getter”和“setter”函数用于访问和更新 State 的 value。这些 getter 和 setter 函数仅在您将属性作为值引用时调用,而不是在创建时调用,这就是为什么上述两种方式是等效的。

当读取状态更改时可以重新执行的每个代码块都是一个重新启动范围。Compose 会跟踪不同阶段中的状态值更改和重新启动范围。

分阶段状态读取

如上所述,Compose 有三个主要阶段,Compose 会跟踪在每个阶段中读取了哪些状态。这允许 Compose 仅通知需要为 UI 的每个受影响元素执行工作的特定阶段。

让我们逐一了解每个阶段,并描述在其中读取状态值时会发生什么。

阶段 1:组合

@Composable 函数或 lambda 块中读取状态会影响组合以及可能随后的阶段。当状态值更改时,重新组合器会安排重新运行所有读取该状态值的可组合函数。请注意,如果输入未更改,运行时可能会决定跳过部分或全部可组合函数。有关更多信息,请参阅输入未更改时的跳过

根据组合的结果,Compose UI 会运行布局和绘制阶段。如果内容保持不变且大小和布局不会更改,它可能会跳过这些阶段。

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

阶段 2:布局

布局阶段包括两个步骤:测量放置。测量步骤运行传递给 Layout 可组合项的测量 lambda,LayoutModifier 接口的 MeasureScope.measure 方法等。放置步骤运行 layout 函数的放置块,Modifier.offset { … } 的 lambda 块等。

在这些步骤中的每个步骤中读取状态都会影响布局以及可能随后的绘制阶段。当状态值更改时,Compose UI 会安排布局阶段。如果大小或位置已更改,它还会运行绘制阶段。

更精确地说,测量步骤和放置步骤有单独的重新启动范围,这意味着放置步骤中的状态读取不会重新调用之前的测量步骤。然而,这两个步骤通常是交织在一起的,因此放置步骤中的状态读取可能会影响属于测量步骤的其他重新启动范围。

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

阶段 3:绘制

绘制代码中的状态读取会影响绘制阶段。常见示例包括 Canvas()Modifier.drawBehindModifier.drawWithContent。当状态值更改时,Compose UI 只运行绘制阶段。

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

优化状态读取

由于 Compose 执行局部状态读取跟踪,我们可以通过在适当的阶段读取每个状态来最大限度地减少执行的工作量。

让我们看一个示例。这里我们有一个 Image(),它使用 offset 修饰符来偏移其最终布局位置,从而在用户滚动时产生视差效果。

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

这段代码有效,但性能不佳。按照编写方式,代码读取 firstVisibleItemScrollOffset 状态的值并将其传递给 Modifier.offset(offset: Dp) 函数。当用户滚动时,firstVisibleItemScrollOffset 值将发生变化。我们知道,Compose 会跟踪任何状态读取,以便它可以重新启动(重新调用)读取代码,在我们的示例中,它是 Box 的内容。

这是在组合阶段读取状态的一个示例。这并不一定是坏事,实际上是重新组合的基础,允许数据更改发出新的 UI。

然而,在此示例中,它并不是最优的,因为每次滚动事件都会导致整个可组合内容被重新评估,然后进行测量、布局并最终绘制。我们在每次滚动时都触发 Compose 阶段,尽管我们显示的内容没有改变,只是显示的位置改变了。我们可以优化状态读取,使其仅重新触发布局阶段。

还有另一个版本的 offset 修饰符可用:Modifier.offset(offset: Density.() -> IntOffset)

此版本接受一个 lambda 参数,其中 lambda 块返回生成的偏移量。让我们更新代码以使用它

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

那么为什么这会更具性能呢?我们提供给修饰符的 lambda 块是在布局阶段(具体来说,是在布局阶段的放置步骤中)调用的,这意味着我们的 firstVisibleItemScrollOffset 状态不再在组合期间读取。由于 Compose 会跟踪状态何时被读取,因此此更改意味着如果 firstVisibleItemScrollOffset 值发生变化,Compose 只需重新启动布局和绘制阶段。

此示例依赖于不同的 offset 修饰符来优化生成的代码,但其一般思想是正确的:尝试将状态读取本地化到尽可能低的阶段,使 Compose 能够执行最少的工作量。

当然,在组合阶段读取状态通常是绝对必要的。即便如此,在某些情况下,我们可以通过过滤状态更改来最大限度地减少重新组合的数量。有关此内容的更多信息,请参阅derivedStateOf:将一个或多个状态对象转换为另一个状态

重新组合循环(循环阶段依赖)

前面我们提到,Compose 的阶段总是按相同的顺序调用,并且在同一个帧中无法向后回溯。然而,这并不能阻止应用在不同帧之间进入组合循环。请考虑以下示例

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

这里我们(很糟糕地)实现了一个垂直列,顶部是图片,然后是下面的文本。我们使用 Modifier.onSizeChanged() 来获取图片的解析大小,然后对文本使用 Modifier.padding() 来向下移动它。从 Px 不自然地转换回 Dp 已经表明代码存在一些问题。

此示例的问题在于我们无法在单个帧内达到“最终”布局。代码依赖于多个帧的发生,这会执行不必要的工作,并导致 UI 在屏幕上跳动,影响用户体验。

让我们逐步了解每个帧以查看发生了什么

在第一个帧的组合阶段,imageHeightPx 的值为 0,文本使用 Modifier.padding(top = 0)。然后,布局阶段随之而来,并调用 onSizeChanged 修饰符的回调。此时,imageHeightPx 会更新为图片的实际高度。Compose 会为下一帧安排重新组合。在绘制阶段,文本以 0 的内边距渲染,因为值更改尚未反映出来。

然后 Compose 启动由 imageHeightPx 值更改所安排的第二个帧。状态在 Box 内容块中读取,并在组合阶段调用。此时,文本获得与图片高度匹配的内边距。在布局阶段,代码确实再次设置了 imageHeightPx 的值,但由于值保持不变,因此没有安排重新组合。

最终,我们在文本上获得了所需的内边距,但花费额外的帧将内边距值传回不同阶段是不优的,并且会导致生成一个内容重叠的帧。

这个示例可能看起来做作,但请注意这种通用模式

  • Modifier.onSizeChanged()onGloballyPositioned() 或其他一些布局操作
  • 更新某些状态
  • 将该状态用作布局修饰符(padding()height() 或类似)的输入
  • 可能重复

上述示例的修复方法是使用适当的布局基元。上述示例可以使用简单的 Column() 实现,但您可能有更复杂的示例,需要自定义内容,这将需要编写自定义布局。有关更多信息,请参阅自定义布局指南。

这里的一般原则是,对于应该相互测量和放置的多个 UI 元素,拥有一个单一的真相来源。使用适当的布局基元或创建自定义布局意味着最小的共享父级作为真相来源,可以协调多个元素之间的关系。引入动态状态会打破这一原则。