Jetpack Compose 的阶段

与大多数其他界面工具包一样,Compose 会通过几个不同的“阶段”来渲染帧。例如,Android View 系统有 3 个主要阶段:测量、布局和绘制。Compose 和它非常相似,但开头多了一个叫做“组合”的重要阶段。

Compose 文档中的“Compose 编程思想”“状态和 Jetpack Compose”对“组合”阶段进行了说明。

帧的 3 个阶段

Compose 有 3 个主要阶段:

  1. 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
  2. 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
  3. 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。
Compose 将数据转换为界面的三个阶段(按顺序:数据、组合、布局、绘制、界面)。
图 1. Compose 将数据转换为界面的三个阶段。

这些阶段通常会以相同的顺序执行,让数据能够沿一个方向(从组合到布局,再到绘制)生成帧(也称为单向数据流)。BoxWithConstraintsLazyColumnLazyRow 是值得注意的特例,其子级的组合取决于父级的布局阶段。

从概念上讲,每个帧都会经历这 3 个阶段;不过,为了优化性能,Compose 会避免在所有这些阶段中重复执行根据相同输入计算出相同结果的工作。如果可以重复使用前面计算出的结果,Compose 会跳过对应的可组合函数;如果没有必要,Compose 界面不会对整个树进行重新布局或重新绘制。Compose 只会执行更新界面所需的最低限度的工作。之所以能够实现这种优化,是因为 Compose 会跟踪不同阶段中的状态读取。

了解阶段

本部分更详细地介绍了如何针对可组合项执行这三个 Compose 阶段。

组合

在组合阶段,Compose 运行时会执行可组合函数,并输出表示界面的树结构。此界面树由包含后续阶段所需的所有信息的布局节点组成,如以下视频所示:

图 2. 在组合阶段创建的表示界面的树。

代码和界面树的某个子部分如下所示:

包含 5 个可组合项的代码段以及生成的界面树,其中子节点从父节点分支出来。
图 3. 包含相应代码的界面树子部分。

在这些示例中,代码中的每个可组合函数都对应于界面树中的单个布局节点。在更复杂的示例中,可组合函数可以包含逻辑和控制流,并根据不同的状态生成不同的树。

布局

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

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

在布局阶段,系统会使用以下三步算法遍历树:

  1. 测量子项:节点会测量其子项(如果有)。
  2. 确定自己的尺寸:节点根据这些测量结果确定自己的尺寸。
  3. 放置子节点:每个子节点都相对于节点自身的位置放置。

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

  • 分配的宽度高度
  • 应绘制到的 x、y 坐标

回顾一下上一部分中的界面树:

包含 5 个可组合项的代码段以及生成的界面树,其中子节点从父节点分支出来

对于此树,算法的运作方式如下:

  1. Row 测量其子级 ImageColumn
  2. 测量 Image。它没有任何子节点,因此会自行确定尺寸,并将尺寸报告给 Row
  3. 接下来,系统会衡量 Column。它首先测量自己的子项(两个 Text 可组合项)。
  4. 首先测量的是 Text。它没有任何子节点,因此会自行确定尺寸,并将尺寸报告给 Column
    1. 测量第二个 Text。它没有任何子节点,因此会自行决定尺寸并将其报告给 Column
  5. Column 使用子测量结果来确定自己的大小。它使用最大子宽度和子高度之和。
  6. Column 将其子级放置在相对于自身的位置,并以垂直方式将它们放置在彼此下方。
  7. Row 使用子测量结果来确定自己的大小。它使用最大子项高度及其子项宽度的总和。然后放置其子项。

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

绘制

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

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

使用上一个示例,树内容将按以下方式绘制:

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

图 6. 界面树及其绘制表示形式。

状态读取

当您在上述任一阶段中读取 snapshot statevalue 时,Compose 会自动跟踪在读取 value 时正在执行的操作。通过这项跟踪,Compose 能够在状态的 value 发生变化时重新执行读取程序;此外,我们也是以这项跟踪为基础在 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”函数来访问和更新状态的 value。只有当您将相应属性作为值引用时,系统才会调用这些 getter 和 setter 函数(而不会在创建属性时调用),因此上述两种方法是等效的。

每个可以在读取状态发生更改时重新执行的代码块都是一个重启作用域。在不同阶段内,Compose 会跟踪状态 value 的更改和重启作用域。

分阶段状态读取

如前所述,Compose 有 3 个主要阶段,并且 Compose 会跟踪在每个阶段中读取到的状态。这样一来,Compose 只需向需要对界面的每个受影响的元素执行工作的特定阶段发送通知即可。

以下部分介绍了每个阶段,并说明在各阶段中读取状态值时分别会发生什么情况。

第 1 阶段:组合

@Composable 函数或 lambda 代码块中的状态读取会影响组合阶段,并且可能会影响后续阶段。当状态的 value 发生更改时,recomposer 会安排重新运行所有要读取相应状态的 value 的可组合函数。请注意,如果输入未更改,运行时可能会决定跳过部分或全部可组合函数。如需了解详情,请参阅如果输入未更改,则跳过

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

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 块以及类似函数。

每个步骤的状态读取都会影响布局阶段,并且可能会影响绘制阶段。当状态的 value 发生更改时,Compose 界面会安排布局阶段。如果大小或位置发生更改,界面还会运行绘制阶段。

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。当状态的 value 发生变化时,Compose 界面只会运行绘制阶段。

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() 会使用偏移修饰符来偏移自己的最终布局位置,从而在用户滚动屏幕时产生视差效果。

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 状态的 value,并将其传递给 Modifier.offset(offset: Dp) 函数。当用户滚动时,firstVisibleItemScrollOffsetvalue 会发生变化。如您所知,Compose 会跟踪所有状态读取,以便重启(重新调用)读取代码(在本例中,就是 Box 的内容)。

这是在组合阶段中读取状态的示例。这不一定是坏事;事实上,这是重组的基础,这样一来,数据更改才能发出新的界面。

要点:此示例不是最佳选择,因为每个滚动事件都会导致系统重新评估、测量、布局,最后再绘制整个可组合项内容。即使显示的内容未发生更改,只有其位置发生更改,您也会在每次滚动时触发 Compose 阶段。您可以优化状态读取,以便仅重新触发布局阶段。

使用 lambda 进行偏移

我们还可以使用另一个版本的偏移修饰符: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 会跟踪读取状态的时间,因此,这项更改意味着如果 firstVisibleItemScrollOffsetvalue 发生更改,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 的过程很不自然,这就说明代码存在一些问题。

此示例的问题在于,代码没有在单个帧中达到“最终”布局。该代码依赖发生多个帧,它会执行不必要的工作,并导致界面在用户屏幕上跳动。

第一帧构图

在第一帧的组合阶段,imageHeightPx 最初为 0。因此,该代码会提供带有 Modifier.padding(top = 0) 的文本。后续的布局阶段会调用 onSizeChanged 修饰符的回调,该回调会将 imageHeightPx 更新为图片的实际高度。然后,Compose 会为下一帧安排重组。不过,在当前的绘制阶段,由于更新后的 imageHeightPx 值尚未得到反映,因此系统渲染文本时将内边距设为了 0

第二帧构成

Compose 会根据 imageHeightPx 的值发生的更改启动第二帧。在此帧的组合阶段,系统会在 Box 内容块中读取状态。现在,系统提供文本时会采用与图片高度完全匹配的内边距。在布局阶段,imageHeightPx 会再次设置;不过,由于值保持一致,因此不会安排进一步的重组。

图表:显示了重组循环,其中布局阶段的大小变化会触发重组,然后导致布局再次发生。

该示例可能显得有些刻意,但请注意以下通用模式:

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

若要修复以上示例,您可以使用适当的布局基元。上述示例可以使用 Column() 来实现,但您可能会遇到需要进行自定义的更复杂的示例,为此,您需要编写自定义布局。如需了解详情,请参阅自定义布局指南。

一般原则是,对于应该以彼此相对的方式进行测量和放置的多个界面元素,我们要提供单一的可信来源。使用适当的布局基元或创建自定义布局意味着将最小的共享父级用作可以协调多个元素之间关系的可信来源。引入动态状态就违反了这一原则。