Jetpack Compose 的阶段

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

与 Compose 相关的文档(包括Compose 编程思想以及状态和 Jetpack Compose)对“组合”阶段进行了说明。

帧的 3 个阶段

Compose 有 3 个主要阶段:

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

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

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

了解各个阶段

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

组合

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

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

代码和界面树的一部分如下所示:

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

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

布局

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

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

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

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

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

  • 分配的宽度高度
  • 应绘制该图形的 x、y 坐标

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

包含五个可组合项的代码段和生成的界面树,其中子节点从父节点分支

对于此树,算法的工作原理如下:

  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. 界面树及其绘制表示法。

状态读取

当您在上述任一阶段中读取快照状态值时,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”函数来访问和更新状态的 value。只有当您将相应属性作为值引用时,系统才会调用这些 getter 和 setter 函数(而不会在创建属性时调用),因此上述两种方法是等效的。

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

分阶段状态读取

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

接下来,我们将介绍每个阶段,并说明在各阶段中读取状态值时分别会发生什么情况。

第 1 阶段:组合

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

根据组合结果,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 块,等等。

每个步骤的状态读取都会影响布局阶段,并且可能会影响绘制阶段。当状态值发生更改时,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。当状态值发生更改时,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 状态的值,并将其传递给 Modifier.offset(offset: Dp) 函数。当用户滚动时,firstVisibleItemScrollOffset 值会发生更改。如我们所知,Compose 会跟踪所有状态读取,以便重启(重新调用)读取代码(在我们的示例中,就是 Box 的内容)。

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

不过,在本例中,这并不是最理想的选择,因为每个滚动事件都会导致系统重新评估整个可组合项内容;还会导致系统进行测量、布局,最后再进行绘制。即使要显示的内容未发生更改,并且只有显示位置发生更改,我们也会在每次滚动时触发 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() 以将其下移。从 Px 转换回 Dp 的过程很不自然,这就说明代码存在一些问题。

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

接下来,我们要逐一检查每个帧,看看发生了什么:

在第一帧的组合阶段,imageHeightPx 的值为 0,并且文本是按照 Modifier.padding(top = 0) 提供的。接着,布局阶段紧随其后,并且系统调用 onSizeChanged 修饰符的回调。此时,imageHeightPx 更新为了图片的实际高度。Compose 为下一帧安排重组。在绘制阶段,由于值发生的更改尚未得到反映,因此,系统渲染文本时将内边距设为了 0。

然后,Compose 启动根据 imageHeightPx 的值发生的更改安排的第二帧。系统在 Box 内容块中读取状态,并在组合阶段调用该状态。此时,系统提供文本时会采用与图片高度相匹配的内边距。在布局阶段,代码确实会再次设置 imageHeightPx 的值,但不会安排重组,因为该值会保持不变。

最终,我们会在文本上实现所需的内边距,但这并不是最理想的情况,因为我们还要使用一个额外的帧将内边距值传递回其他阶段,而这会导致产生一个内容重叠的帧。

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

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

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

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