稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

降Compose十八掌之『履霜冰至』| Phases

这篇文章译自Jetpack Compose phases

就像大多数其他的UI工具集一样,Compose渲染一帧也要经过几个不同的阶段。就比如说Android view系统,主要有三个阶段:测量(measure),版面编排(layout)和绘制(drawing)。Compose也非常的类似,但有一个特别重要的额外的阶段起始时的组合(composition)阶段。

组合在我们前面的文章中有详细的描述,包括降Compose十八掌之『潜龙勿用』| Thinking in Compose降Compose十八掌之『鸿渐于陆』| State

每一帧的三个阶段

Compose有三个主要的阶段:

  1. 组合(Composition):要显示什么。Compose运行composable函数并创建UI的一个描述。
  2. 版面编排(Layout):在哪里显示。这个阶段包含两个步骤:测量(measurement)和放置(placement)。给布局树中的每个节点,在二维坐标中,布置元素的测量然后放置它们和它们的子元素。
  3. 绘制(Drawing):如何渲染。把UI元素绘制进一个Canvas,也就是设备的屏幕上。

three_phases

图1. Compose把数据转化为UI的三个阶段

这三个阶段的执行顺序是相同的,能够让数据从组合到版面编排再到绘制沿着一个方向流动,以生成一帧(也就是「单向数据流」)。需要特别注意的例外是BoxWithConstraintsLazyColumnLazyRow,它们子节点的组合依赖于父节点的排版阶段。

可以假定每一帧都会这三个阶段,但是基于性能的考量,在所有的阶段里,Compose会避免相同输入时计算结果相同的重复工作。如果一个composable函数能复用前一次的结果,Compose会跳过它的执行,并且如果没有必要,Compose UI并不会重新排版或者重新绘制整个UI树。Compose仅会做更新UI所必要的最少工作。因为Compose会在不同的阶段追踪状态读取,所以这种优化是可行的。

理解不同的阶段

这部分将详细的描述composables的三个渲染阶段是如何进行的。

组合(Composition)

在组合阶段,Compose运行时会执行composable函数然后输出一个能代表UI的树形结构。这个树形结构由包含着下一阶段所需要的信息的布局节点组成,如下面这个视频所示:

图2. 在组合阶段创建的代表着你的UI的树形结构。

一小段代码和其树形会如下所示:

ui_tree_vs_code

图3. UI树的一部分与其对应的代码。

在这些例子中,代码中的每个composable函数映射为一UI树中的一个布局节点。在更复杂的例子中,composables可包含逻辑和控制流程,在不同的状态下生成不同的树。

排版(Layout)

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

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

在排版阶段,树用下面三步算法进行遍历:

  1. 测量子节点:节点会测量其存在的子节点。
  2. 决定自身大小:基于前面的测量,一个节点能决定它自身的大小。
  3. 放置子节点:每个子节点以节点为参考进行放置。

在这个阶段的最后,每个布局节点都有:

  • 一个确定的宽度(width)和高度(height)
  • 一个绘制的位置坐标x,y

对于前一部分提到的UI树:

对于这颗树,算法是这样工作的:

  1. Row测量它的子节点:Image和Column。
  2. Image测量过后。因为它没有子节点,所它决定自己的大小并把其大小报告给Row。
  3. 接下来测量Column。它先测量它的两个子节点(两个Text函数)。
  4. 第一个Text被测量。它没有子节点,所以决定自己大小并告诉给Column
    1. 第二个Text被测量。它也没有子节点,所以决定自己大小后告诉给Column。
  5. Column使用子节点的测量结果决定自己的大小。它用子节点的最大宽度(作为宽度)和高度之和(作为高度)。
  6. Column相对于自己来放置子节点,把它们垂直地放在下面。
  7. Row使用子节点的测量结果来决定自身大小。它使用子节点的最大高度作为高度,子节点宽度之和作为宽度。然后旋转子节点。

注意每个节点仅访问一次。测量和放置所有节点时,Compose运行时仅需要访问一次UI树,这样做能提升性能。当树中的节点数量增加时,遍历所需要的时间仅线性增长。相反,如果每个节点访问多次,遍历时间将呈指数增长。

绘制(Drawing)

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

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

继续前面的例子,以如下方式绘制出树的内容:

  1. Row绘制它有的所有内容,如背景。
  2. Image绘制它自己。
  3. Column绘制它自己。
  4. 第一个和第二个Text各自绘制它们自己。

图6. UI树和它的渲染展示。

状态读取

当你在上面列出的一个阶段中读取状态的值时,Compose会自动追踪值被读取时它在做的事情。这种追踪允许Compose在状态发生变化时重新执行读取者,这是Compose中状态的可观测性的基础。

状态通常都是由mutableStateOf来创建的然后通过两种方式访问:直接读取其属性value或者通过Kotlin的属性委托。可以在文章降Compose十八掌之『鸿渐于陆』| State中了解更多的细节。在本文中,「状态读取」通指两种方法中的任意一种。

1
2
3
4
5
6
// 直接读取状态的value
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)
1
2
3
4
5
6
// 通过属性委托来读取
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

属性委托的背后,「getter」和「setter」函数用来访问和更新状态的value。这些getter和setter函数仅当你把属性当作一个值来引用时才会被调用,而不是委托被创建时,所以这就是上面两种方式是等价的原因。

当一个被读取状态发生变化时都会被重新执行的每一个代码块都是一个重启作用域(restart scope)。在不同的阶段,Compose会追踪状态值的变化然后重启作用域。

分阶段的状态读取

如上面所提及,Compose中有三个主要的阶段,在每个阶段中,Compose会追踪哪些状态被读取了。这让Compose能够仅通知针对UI中受影响的元素需要采取措施的特定阶段。

注意:状态实例被创建和存储的地方对阶段几乎无影响,只有状态被读取的时间和地点才有重要影响。

我们来仔细检查每一个阶段,然后描述一下在其中当状态被读取时所发生的事情。

阶段一:组合

在一个@Composable标注的函数里或者lambda代码块里读取状态会影响组合和后续阶段。当状态值发生变化,重组器(recomposer)会安排所有读取状态的composable函数的重新运行。注意如果函数的输入没有变化,运行时可能会跳过一些甚至所有的composable函数。想了解更多可以看文章降Compose十八掌之『损则有孚』| Lifecycle

取决于组合的结果,Compose UI在执行排版和绘制阶段,如果内容始终相同和大小以及布局未发生变化,它也许会跳过这些阶段。

1
2
3
4
5
6
7
var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // 当对象Modifier被构建时,状态`paddin`在组合阶段读取。
    // 状态`padding`的变化会触发重组
    modifier = Modifier.padding(padding)
)

阶段二:排版

排版阶段由两个步骤组成:测量和放置。测量步骤运行传递给composable函数的测量lambda,也即诸如接口LayoutModifier的MeasureScope.measure方法等的代码。放置步骤运行layout函数的放置代码块,也即诸如Modifier.offset {…}的代码块。

这些步骤中的状态读取影响排版编排和后续的绘制阶段。当状态值发生变化,Compose UI会安排排版阶段。如果大小和位置发生变化,它也会执行绘制阶段。

更准确的说,测量步骤和放置步骤有不同的重启作用域(restart scope),也就是说放置步骤中的状态读取不会重新触发它前面测量步骤。然而,这些步骤经常绞在一起,所以放置步骤中的状态读取可能会影响属于测量步骤中的其他重启作用域。

1
2
3
4
5
6
7
8
9
var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // 当offset被计算时,状态`offsetX`在排版阶段中的放置步骤读取
        // `offsetX`的变化会重启排版
        IntOffset(offsetX.roundToPx(), 0)
    }
)

阶段三:绘制

绘制过程中的状态读取影响绘制阶段。常见的例子包括Canvas(),Modifier.drawBehind和Modifer.drawWithContent函数。当状态值发生变化,Compose UI仅执行绘制阶段。

1
2
3
4
5
6
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // 当canvas被渲染时,状态`color`在绘制阶段读取
    // 状态`color`变化重启绘制
    drawRect(color)
}

phase_state_read_draw

优化状态读取

因为Compose进行本地化的状态读取追踪,我们可以通过在合适的阶段读取状态以最小化渲染工作量。

我们来看一下下面的例子。这里有一个Image,使用了offset modifier来作为最终布局位置的偏移,实现一个用户滑动时的平行视觉差的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // 这不是一个最优的实现方式
        Modifier.offset(
            with(LocalDensity.current) {
                // 在组合中读取状态firstVisibleItemScrollOffset
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

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

这个代码能行,但性能并不是最优的。上面的代码读取状态firstVisibleItemScrollOffset然后把它传给函数Modifier.offset.offset(androidx.compose.ui.unit.Dp,androidx.compose.ui.unit.Dp))。当用户滑动时firstVisibleItemScrollOffset的值会变化。我们知道,Compose会追踪任何状态读取以便它能重新执行进行读取的代码,也即例子中函数Box中的内容。

这是一个在组合阶段读取状态的例子。这也并不是一无事处,因为这是重组的基础,让数据变化刷新UI。

但这个例子不是最优做法,因为每次滚动都会导致整个composable函数被重新运行,也会重新测量,重新排版,然后最终重新绘制。尽管要显示的内容并没有真正变化,仅是要显示的位置在变化,但我们每个滚动都会触发Compose的所有阶段。我们可以优化状态读取以仅触发排版阶段。

有offset modifier另外一个版本:Modifier.offset(offset: Density.()-> IntOffset).offset(kotlin.Function1))。这个函数接收一个lambda作为参数,lambda代码块的返回结果作为最终的偏移量。我们来改一下前面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // 在排版阶段读取状态firstVisibleItemScrollOffset
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

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

为何这样就性能更优呢?我们提供给modifier的lambda代码块仅在排版阶段调用(具体来说是在排版阶段中的放置步骤),也就是说在组合阶段状态firstVisibleItemScrollOffset不用再被读取了。因为Compose追踪状态什么时候被读取,这次改动意味着如果状态firstVisibleItemScrollOffset值发生变化,Compose仅会重启排版和绘制阶段。

注意: 你也许很好奇接收一个lambda作为参数与接收一个普通值参数相比是否有额外的开销。确实有。然而,在这个场景中,限制状态读取到排版阶段带来的收益要超过参数的开销。在滑动中firstVisibleItemScrollOffset的值每一帧都会发生变化,把状态读取延迟到排版阶段,能避免很多次重组。

虽然这个例子靠有不同的offset modifiers可以用来优化最终代码,但思路是通用的:尽可能把状态读取限制到最少的阶段中,让Compose做最少量的渲染工作。

当然了,在组合阶段也常常绝对有必要读取状态。尽管如此,通过过滤状态变化,还是有可以最小化重组发生的场景。想要了解更多的这方面信息,可以读文章降Compose十八掌之『龙战于野』| Side Effects

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

早些时候我们提到过Compose的阶段总会以相同的顺序被调用,并且在一帧中是没有办法往回走的。然而,不同帧之间组合循环依然会发生。看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // 不要这样做
                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会在屏幕上跳跃。

让我们一帧帧的检查来看发生了什么:

在第1帧的组合阶段,imageHeightPx值为0,文字拿到的是Modifier.padding(top = 0)。然后,到了排版阶段,modifier的onSizeChanged回调会被调用。这时imageHeightPx会被更新成为图片的实际高度。Compose为下一帧安排重组。在绘制阶段,文字使用padding 0来渲染,因为这时状态的值还没有被更新。

然后Compose会启动因imageHeightPx的值变化而安排的第2帧渲染。状态是在Box内容代码块中读取,并且是在组合阶段调用的。这回,给到Text的padding是真实的图片的高度。在排版阶段,代码再次修改了imageHeightPx的值,但因为值没有发生变化,所以不会安排重组。

最终,我们得到的text的期望的padding,但是耗费额外的一帧来传递padding值到不同的阶段并不是最优的做法,这会导致带有重叠内容的一帧。

这个例子也许显得有点做作,但要小心这种通用的模式:

  • Modifier.onSizeChanged,onGloballyPositioned,或者一些其他的排版操作
  • 更新一些状态
  • 把状态当作排版modifier(padding(), height()或者类似的)的输入
  • 潜在的重复

上面示例的修复办法是使用合适的排版原语。上面例子可以用一个普通的Column()来实现,但你也许会有需要一些定制的更复杂的场景,这些场景可能需要写一些定制化的布局。可以看定制布局文档以了解更多。

这里通用的原则是对于需要相互之间测量和旋转的多个UI元素要保持单一数据来源。使用合适的排版原语或者创建一个定制化的布局就意味着最少化的共享父节点可以当作可以协调多个元素之间关联的单一数据源。而引入动态的状态会打破这一原则。

Comments