【GDE 分享】一文看懂 Jetpack Compose 布局流程
前言 - 从 Compose 生命周期说起
Composition/组合: Composable 源码经过运行后生成 LayoutNode 的节点树,这棵树被称为 Composition。 Layout/布局: 对节点树深度遍历测量子节点的尺寸,并将其在父容器内摆放到合适的位置。 Drawing/绘制: 基于布局后拿到的尺寸和位置信息,绘制上屏。
我们与 Android 经典视图系统的生命周期 (Measure,Layout,Drawing) 做一个对比: 组合是 Compose 的特有阶段,是其能够通过函数调用实现声明式 UI 的核心,想要深入理解 Compose 第一课就是理解这个过程。
绘制阶段与传统视图大同小异,都是通过 Android Cavas API,底层调用 skia 实现。
本文讨论的重点是布局阶段。Compose 的 Layout 把 Measure 也囊括了进来,相对于 Android View 有相似性,但也有其独有的特点和优势,接下来我们进入正题。
Compose 布局过程三步走
Measure children: 深度遍历子节点,并测量它们的尺寸
Decide own size: 根据收集到的子节点尺寸,决定当前节点自己的尺寸
Place children: 将子节点摆放到合理的相对位置
上面代码描述了一个卡片的布局,下面以这个布局的节点树为例,看一下布局流程。
Step1: 从 Row 开始发起测量,遵循三步走第一步,深度遍历测量其子节点 Image 和 Column
Step2&3: Image 发起测量,因为没有子节点需要测量了,所以只需要计算自己的尺寸,也因为没有子节点需要摆放,空实现完成 place 即可
Step4: Column 发起测量,因其有子节点,继续深度遍历
Step5&6: 测量 Text,因为一个叶子节点,立即完成自己的 Size 和 Place 阶段
Step7&8: 测量另一个 Text,同上
Step9: Column 拿到两个子 Text 返回的 Size 后,计算出自己的 Size,不难猜到其计算逻辑应该是 width = maxOf(child1.w, child2.w),height = sumOf(child1.h, child2.h)。设置自己的 width 和 height 后,对两个子 Text 进行 Place,垂直线性摆放。
所有的 Composable 最终都会调用一个公共 Layout Composable 方法,这里面创建 LayoutNode 存储在 Composition 节点树。
以 Column 的实现为例,可以看到调用 Layout 时,传入了三个参数:
@Composableinline fun Column(modifier: Modifier = Modifier,verticalArrangement: Arrangement.Vertical = Arrangement.Top,horizontalAlignment: Alignment.Horizontal = Alignment.Start,content: @Composable ColumnScope.() -> Unit) {val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)Layout(content = { ColumnScopeInstance.content() },measurePolicy = measurePolicy,modifier = modifier)}
content: 在这里定义子 Composable,组合过后形成当前节点的子节点
measurePolicy: 这是定义了布局的三步走核心逻辑
modifier: 修饰符链,参与到布局或者绘制阶段
measurePolicy 和 modifier 会存储在当前 LayoutNode 上,等待 measure 的开始参与其中。下面重点分析 MeasurePolicy 了解三步走如何实现。
MeasurePolicy - 测量策略
fun interface MeasurePolicy {fun MeasureScope.measure(measurables: List<Measurable>,constraints: Constraints): MeasureResult}
measurables: 等待测量的对象,其实就是当前节点的子节点 constraints: 测量约束。节点需要基于当前的 Constaints 进行测量,它规定了节点尺寸的上限和下限,如下:
class Constraints {val minWidth: Intval maxWidth: Intval minHeight: Intval maxHeight: Int...}
Constraints - 测量约束
父节点通过 Constraints 约束子节点的测量。Constraints 非常重要,我们常说 Compose 不怕布局嵌套正是得益于它。反观 Android 原生视图,由于测量阶段的约束不明确,子 View 需要再次请求父 View 给出清楚的 View.MeasureSpec,导致出现多次绘制。
举几个例子理解一下 Constraints 如何设置:
对于页面的根节点, Activity 的 Window 的长宽就是其 Constraints 的最大长宽。如果是一个垂直可滚动容器的节点,那么它的 Constraints 的 height 应该是 Infinity,因为它可以跨多个屏幕存在。
此外, Modifier 的装饰能力本质也是通过修改 Constraints 完成的。例如 fillMaxWidth 要求被修饰的节点填充整个父容器,所以 Modifier 会在布局阶段将 minHeight/minWidth 对齐 max 组值。关于 Modifier 参与布局的流程,稍后介绍。
三步走实现 - Kotlin 语法优势的体现
measurePolicy = { // this: MeasureScope// Step1:Measure each childrenval placeables = measurables.map { measurable ->measurable.measure(constraints)}// Step2: Deciee own sizeval height = placeables.sumOf { it.height }val width = placeables.maxOf { it.width }layout(width, height) { //this: Placeable.PlacementScope// Step3: Place children by changing the offset of y co-ordvar yPosition = 0placeables.forEach { placeable ->// Position item on the screenplaceable.placeRelative(x = 0, y = yPosition)// Record the y co-ord placed up toyPosition += placeable.height}}}
每个 measuable 提供了参与测量的 measure 方法,此处会传入 Constraints,返回的 placeable 中已经存储了测量后的 widht 和 height,等待 place
基于各个 placeable 的 w 和 h 计算当前节点的 Size,并通过 layout 方法设置。layout 方法内会真正的创建 LayoutNode
layout 方法的末参是一个 lambda,这里是第三步摆放子节点的逻辑,通过设置 y 轴的偏移量实现纵向布局,非常简单
特别值得一提的是,通过 meause 一个方法就完成三步走,布局逻辑相对传统的 View 系统更加高效,回想传统自定义 View 你需要分别实现 onMeasure,onLayout,onDraw 等,逻辑分散,可读性差。
measure 方法的返回值是 MeasureResult 类型,layout 方法也返回此类型,所以保证了尾部一定是调用 layout 完成三步走
Measuable#measure 调用后返回 Placeable 类型,然后才能调用 Placeable#place,这保证了 place 和 measure 的先后关系
Measuable#measure 只能在 MeasureScope 中调用,Placeable#place 只能在 Placeable.PlacementScope 中调用,这确保了 place 需要在 layout 的 lambda 中调用
通过各种返回值类型、作用域类型的约束,大家可以写出安全又一气呵成的代码,这种 API 设计理念值得推崇。
Modifier Node
接下来介绍一下 Modifier 如何参与布局的。
Modifier 在组合之后也会成为 Node 存储在节点树上,Modifier 的调用链生成一条单向继承的子节点树,而被修饰的 Composable 会成为这条树枝的叶子结点。
比如上面例子中,Image 最终成为 clip->size 的子节点。实际上 Image 内部有一些内置的 Modifier,所以全部展开后 Image 所在的树枝上有一连串 ModifierNode。
挂在节点树上的 ModifierNode 可以参与到深度遍历的绘制流程中,在 Image 之前对 Constraints 做出调整,完成对末端 Image 的装饰。
以 Padding 修饰符为例,看一下源码:
//组合中调用 paddiung 会fun Modifier.padding(start: Dp = 0.dp,top: Dp = 0.dp,end: Dp = 0.dp,bottom: Dp = 0.dp) = this then PaddingElement(start = start,top = top,end = end,bottom = bottom)//Element 存储到链上,创建 PaddingNodeprivate class PaddingElement(...) : ModifierNodeElement<PaddingNode>()//PaddingNode 定义 measure 逻辑private class PaddingNode(overide fun MeasureScope.measure(measurable: Measurable, // 注意不是listconstraints: Constraints): MeasureResult {...}) : LayoutModifierNode, Modifier.Node()
组合阶段,Modifier#then 创建 Element 加入 Modifier chain 中。Element 是无状态的,重组中会重新生成,Element 会在组合中创建有状态的 ModifierNode。ModifierNode 有状态,重组中仅当状态发生变化时被更新,否则不会重新生成。Modifier Node 是 Compose 1.5 引入的新优化,目的就是通过存储 Modifier 状态参与比较,提升重组性能。
ModifierNode 按照参与的阶段不同,分为 LayoutModifierNode 和 DrawModifierNode。对于前者,布局逻辑就是现在 LayoutModifierNode#measure 中,和 MeasurePolicy#measure 的功能一样,唯一的区别是接受单个 measurable 参数而不是 List。因为我们知道了 ModifierNode 是单向继承,所以只会有一个后续子节点。如果把 LayoutNode 的 measure 看做是自定义 ViewGroup 需要针对多个子 View 布局,那么 LayoutModifierNode 的 measure 更像是自定义 View,只对自身负责。
Modifier.layout {}
除了自定义一个 Modifier 来改变当前节点的布局,还有一个简单的方法就是使用 Modifier.layout {} 方法。
fun Modifier.layout(measure: MeasureScope.(Measurable, Constraints) -> MeasureResult)
Box(Modifier.background(Color.Gray).layout { measurable, constraints ->// an example modifier that adds 50 pixels of vertical paddingval padding = 50val placeable = measurable.measure(constraints.offset(vertical = -padding))layout(placeable.width, placeable.height + padding) {placeable.placeRelative(0, padding)}}){ ... }
Modifier 布局流程
val childConstraints = Constraints (minWidth = outerConstraints.maxWidth,maxWidth = outerConstraints.maxWidth,minHeight = outerConstraints.maxHeight,maxHeight = outerConstraints.maxHeight,)
val childConstraints = Constraints (minWidth = 0,maxWidth = outerConstraints.maxWidth,minHeight = 0,maxHeight = outerConstraints.maxHeight,)
val childConstraints = Constraints (minWidth = 50,maxWidth = 50,minHeight = 50,maxHeight = 50,)
叶子节点测量完后,再自底向上进行第二三步,整个流程不做赘述了,只提一点: wrapContentSize 从语义上是应该跟随子节点的大小,即 5050,为什么实际尺寸设置了 200300 呢?
因为其父节点 fillMaxSize 传入的 Constraints 是 200300,rwapContentSize 必须填满这个空间,而由于它有一个默认参数 align = Alignment.Center,所以才能出现 5050 矩形块居中的效果。
Intrinsic Measurements - 固有特性测量
中文将其翻译成 "固有特性",很多人不理解 "固有" 到底指什么?所以放在本文最后讨论一下。
Compose 要求布局过程中每个节点只被测量一次,测量总耗时只与节点数正相关,与层级无关,所以 Comopse UI 不怕嵌套过深,而传统 Android 视图系统中,某个 View 存在多次测量的情况,随着层级变多测量次数会指数级增长,所以传图视图下我们需要通过优化 View 的层级提升性能。
Compose 为了保证 "每个节点只测量一次" 的原则,甚至增加了编译期检查:
val constraints1 = ...val constraints2 = ...val placeable1 = measurable.measure(constraints1val placeable2 = measurable.measure(constraints2)
@Composablefun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {Row(modifier = modifier) {Text(modifier = Modifier.weight(1f).padding(start = 4.dp).wrapContentWidth(Alignment.Start),text = text1)Divider(color = Color.Black,modifier = Modifier.fillMaxHeight().width(1.dp))Text(modifier = Modifier.weight(1f).padding(end = 4.dp).wrapContentWidth(Alignment.End),text = text2)}}
fun interface MeasurePolicy {fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List<IntrinsicMeasurable>,height: Int): Intfun IntrinsicMeasureScope.minIntrinsicHeightfun IntrinsicMeasureScope.maxIntrinsicWidthfun IntrinsicMeasureScope.maxIntrinsicHeight}
Row(modifier = modifier.height(IntrinsicSize.Min)) {...}看到这里相信大家理解 "固有" 的含义了,其本质代表 "不依赖 Constraints" 就可以获取的值,基于这些值更新 Constraints,后续测量只有一次也能正确约束。
谷歌开发者特别招募活动进行中
诚邀热爱技术的你加入
通过多种形式 (文章/视频/coding 等) 创作与 Google 技术相关的讲解分享、实践案例或活动感受等内容,以及分享您应用 AI 技术的故事经历与成果。我们将为您提供平台和资源,助力您在分享中提升技能。更有惊喜权益等您领取,快来报名参与吧!