Jetpack Compose 阶段

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

合成在我们的 Compose 文档中进行了描述,包括Compose 思维模型状态和 Jetpack Compose

框架的三个阶段

Compose 有三个主要阶段

  1. 合成:显示哪些UI。Compose 运行可组合函数并创建 UI 的描述。
  2. 布局:UI 在哪里放置。此阶段包括两个步骤:测量和放置。布局元素测量并放置自身以及任何子元素的二维坐标,用于布局树中的每个节点。
  3. 绘制如何渲染。UI 元素绘制到画布中,通常是设备屏幕。
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 树作为输入。布局节点的集合包含决定每个节点在二维空间中的大小和位置所需的所有信息。

图 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. Row 测量其子元素 ImageColumn
  2. Image 被测量。它没有任何子元素,因此它确定自身的大小并将大小报告回 Row
  3. 接下来测量 Column。它首先测量它自己的子元素(两个 Text 可组合项)。
  4. 第一个 Text 被测量。它没有任何子元素,因此它确定自身的大小并将大小报告回 Column
    1. 第二个 Text 被测量。它没有任何子元素,因此它确定自身的大小并将大小报告回 Column
  5. Column 使用子元素的测量结果来确定自身的大小。它使用最大子元素宽度和子元素高度的总和。
  6. Column 将其子元素相对于自身放置,使它们垂直地彼此下方。
  7. Row 使用子元素的测量结果来确定自身的大小。它使用最大子元素高度和子元素宽度的总和。然后它放置其子元素。

请注意,每个节点只访问了一次。Compose 运行时只需要遍历 UI 树一次即可测量和放置所有节点,从而提高性能。当树中的节点数量增加时,遍历它所花费的时间以线性方式增加。相反,如果每个节点被访问多次,则遍历时间呈指数级增长。

绘制

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

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

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

  1. Row 绘制它可能具有的任何内容,例如背景颜色。
  2. Image 绘制自身。
  3. 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 可组合项的 measure 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 阶段,即使我们**显示的内容**没有改变,只是**显示位置**发生了改变。我们可以优化我们的状态读取,只重新触发布局阶段。

偏移修饰符还有另一个版本可用: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 只需重新启动布局和绘制阶段。

此示例依赖于不同的偏移修饰符来优化结果代码,但总体思路是正确的:尝试将状态读取本地化到尽可能低的阶段,使 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() 对文本进行向下偏移。从 PxDp 的不自然转换已经表明代码存在一些问题。

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

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

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

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

最后,我们在文本上获得了所需的填充,但花费额外的帧将填充值传递回不同的阶段不是最佳做法,并且会导致生成具有重叠内容的帧。

此示例可能看起来很牵强,但请注意这种通用模式。

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

上面示例的解决方法是使用正确的布局原语。上面的示例可以使用简单的 Column() 实现,但您可能有一个更复杂的示例,需要自定义内容,这将需要编写自定义布局。有关更多信息,请参阅 自定义布局 指南。

这里的一般原则是为应相互测量和放置的多个 UI 元素提供单个真相来源。使用正确的布局原语或创建自定义布局意味着最小的共享父级充当真相来源,可以协调多个元素之间的关系。引入动态状态会破坏此原则。