【Compose】绘制流程

本文介绍了Jetpack Compose的渲染流程
任何一种UI框架,应该都会维护一个需要绘制的节点树,在View中也会有一个View控件树的存在。
Slot Table结构
Compose Runtime 采用了一种特殊的数据结构,称为 Slot Table 。
Slot Table 与常用于文本编辑器的另一数据结构 Gap Buffer 相似,这是一个在连续空间中存储数据的类型, 底层采用数组实现 。区别于数组常用方式的是,它的剩余空间,称为 Gap,可根据需要移动到 Slot Table 中的任一区域,这让它在 数据插入与删除时更高效 。
以数据删除为例,如下图:

绘制阶段
Compose要显示界面,也有三个阶段:
- 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
- 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
- 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。
这些阶段通常会以相同的顺序执行,让数据能够 沿一个方向(从组合到布局,再到绘制)生成帧(也称为单向数据流) 。BoxWithConstraints 以及 LazyColumn 和 LazyRow 是值得注意的特例,其子级的组合取决于父级的布局阶段。
从概念上讲,每个帧都会经历这 3 个阶段;
但为了优化性能,Compose 会避免在所有这些阶段中重复执行根据相同输入计算出相同结果的工作。如果可以重复使用前面计算出的结果,Compose 会跳过对应的可组合函数;如果没有必要,Compose 界面不会对整个树进行重新布局或重新绘制。
Compose 只会执行更新界面所需的最低限度的工作。
之所以能够实现这种优化,是因为 Compose 会跟踪不同阶段中的状态读取。
组合
这一步是将各个LayoutNode上树的过程。
代码中的每个可组合函数都会映射到界面树中的单个布局节点。在更复杂的示例中,可组合项可以包含逻辑和控制流,并根据不同的状态生成不同的树。

布局
在布局阶段,Compose 会使用组合阶段生成的界面树作为输入。
在布局阶段,系统会使用以下三步算法遍历树:
- 测量子项:节点会测量其子项(如果有)。
- 确定自己的尺寸:节点根据这些测量结果确定自己的尺寸。
- 放置子项:每个子节点都相对于节点自身的位置进行放置。
在此阶段结束时,每个布局节点都具有:
- 分配的宽度和高度
- 应绘制该图形的 x、y 坐标

以上面的节点树为例,算法的工作原理如下:
- Row 会测量其子项 Image 和 Column。
- 系统会测量 Image。它没有任何子节点,因此它会自行确定自己的尺寸,并将尺寸报告回 Row。
- 接下来,系统会测量 Column。它会先测量自己的子项(两个 Text 可组合项)。
- 系统会测量第一个 Text。它没有任何子项,因此它会自行确定自己的尺寸,并将其尺寸报告回 Column。
- 测量第二个 Text。它没有任何子节点,因此它会自行确定自己的尺寸,并将其报告回 Column。
- Column 使用子测量结果来确定自己的大小。它使用子项的最大宽度和子项高度的总和。
- Column 会相对于自身放置其子项,将它们垂直放置在彼此下方。
- Row 使用子测量结果来确定自己的大小。它使用子项的最大高度和子项宽度的总和。然后放置其子项。
请注意,每个节点都只被访问了一次。Compose 运行时只需对界面树进行一次遍历即可测量和放置所有节点,从而提高性能。
当树中的节点数量增加时,遍历树所花费的时间会以线性方式增加。
相反,类比View的架构,如果每个节点被访问多次,则遍历时间会呈指数级增加。这就是为什么在View里面写嵌套结构,会大大影响界面的绘制速度。
绘制
使用上例,树内容会按如下方式绘制:
- Row 会绘制它可能具有的任何内容,例如背景颜色。
- Image 会自行绘制。
- Column 会自行绘制。
- 第一个和第二个 Text 分别绘制自身。
Compose 在 Android 上的实现最终依赖于 AndroidComposeView,且这是一个 ViewGroup ,那么按原生视图渲染的角度,看一下 AndroidComposeView 对 onDraw() 与 dispatchDraw() 的实现,即可看到 Compose 渲染的原理。
internal class AndroidComposeView(context: Context) :
ViewGroup(context), Owner, ViewRootForTest, PositionCalculator {
...
override fun onDraw(canvas: android.graphics.Canvas) {
}
...
override fun dispatchDraw(canvas: android.graphics.Canvas) {
...
measureAndLayout()
// we don't have to observe here because the root has a layer modifier
// that will observe all children. The AndroidComposeView has only the
// root, so it doesn't have to invalidate itself based on model changes.
canvasHolder.drawInto(canvas) { root.draw(this) }
...
}
...
}
CanvasHolder.drawInto() 将 android.graphics.Canvas 转化为 androidx.compose.ui.graphics.Canvas 实现传递至顶层 LayoutNode 对象 root 的 LayoutNode.draw() 函数中,实现视图树的渲染。
每个阶段的状态读取影响
组合
@Composable 函数或 lambda 代码块中的 状态读取会影响组合阶段,并且可能会影响后续阶段 。
当状态值发生更改时,Recomposer 会安排重新运行所有要读取相应状态值的可组合函数。
如果输入未更改,运行时可能会决定跳过部分或全部可组合函数。如需了解详情,请参阅如果输入未更改,则跳过。
根据组合结果,Compose 界面会运行布局和绘制阶段。如果内容保持不变,并且大小和布局也未更改,界面可能会跳过这些阶段。
布局
布局阶段包含两个步骤:测量和放置。
测量步骤会运行传递给 Layout 可组合项的测量 lambda、LayoutModifier 接口的 MeasureScope.measure 方法,等等。
放置步骤会运行 layout 函数的放置位置块、Modifier.offset { … } 的 lambda 块,等等。
每个步骤的状态读取都 会影响布局阶段,并且可能会影响绘制阶段 。当状态值发生更改时,Compose 界面会安排布局阶段。如果 大小或位置发生更改,界面还会运行绘制阶段 。
更确切地说,测量步骤和放置步骤分别具有单独的重启作用域,这意味着,放置步骤中的状态读取不会在此之前重新调用测量步骤。不过,这两个步骤通常是交织在一起的,因此在放置步骤中读取的状态可能会影响属于测量步骤的其他重启作用域。
绘制
绘制代码期间的状态读取会影响绘制阶段。
常见示例包括 Canvas()、Modifier.drawBehind 和 Modifier.drawWithContent。当状态值发生更改时,Compose 界面只会运行绘制阶段。