19、自定义布局测试:LayoutNode测试、测量验证、放置验证
自定义布局,是Compose进阶路上绕不开的一道坎。说实话,我见过不少团队在自定义布局上栽跟头——要么测量结果不对,要么子组件放错了位置。更麻烦的是,这类问题往往在真机跑起来才能发现,调试成本极高。
所以这一章,我们来聊聊怎么给自定义布局写测试。核心就三件事:测LayoutNode、测测量过程、测放置结果。掌握了这些,你就能在代码提交前把布局问题揪出来。
为什么自定义布局需要专项测试?
你想想看,Compose自带的布局组件(Column、Row、Box)都是经过千锤百炼的,基本不会出问题。但你自己写的布局呢?
- 测量逻辑对不对?子组件有没有被正确约束?
- 放置位置准不准?有没有超出边界?
- 多子组件时,排列顺序是否符合预期?
我在项目中遇到过好几次,自定义布局在单一子组件时表现完美,但一旦子组件数量增多,测量结果就开始飘了。嗯,这种问题靠肉眼审查代码很难发现,必须靠测试来兜底。
自定义布局测试的核心思路
说白了,自定义布局的测试就是模拟Compose的布局流程:测量 → 放置。我们不需要真的把界面渲染出来,只需要验证布局节点(LayoutNode)在测量和放置阶段的行为是否正确。
核心测试对象:
- LayoutNode:布局树中的节点,代表一个可组合项
- 测量(Measure):根据约束计算子组件的大小
- 放置(Place):将子组件放到指定位置
下面这张图展示了自定义布局测试的完整流程:
LayoutNode测试:从节点层面验证
LayoutNode是Compose布局树的基本单元。测试自定义布局时,我们经常需要手动创建LayoutNode来模拟子组件。
我个人习惯用TestLayoutNode来构造测试节点。它允许我们直接控制子组件的测量行为,而不需要真的去写一个Composable。
@Test
fun testLayoutNodeCreation() {
// 创建一个测试用的LayoutNode
val node = TestLayoutNode().apply {
// 模拟子组件的测量结果
measurePolicy = MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { it.placeRelative(0, 0) }
}
}
}
// 验证节点初始状态
assertEquals(0, node.childrenCount)
assertNull(node.parent)
}
小技巧:测试LayoutNode时,重点关注measurePolicy和childrenCount这两个属性。前者决定了布局行为,后者反映了子组件树的结构。
测量验证:确保尺寸计算正确
测量是自定义布局最核心也最容易出错的地方。我见过最典型的bug是:布局在特定约束下返回了错误的尺寸,导致子组件被截断或留白过多。
测试测量逻辑时,我们需要模拟不同的Constraints,然后验证布局返回的IntSize是否正确。
@Test
fun testMeasureWithFixedConstraints() {
val node = TestLayoutNode().apply {
// 模拟一个固定宽高的子组件
child(TestLayoutNode().apply {
measurePolicy = MeasurePolicy { _, constraints ->
layout(100, 50) {
// 不放置任何内容,只验证测量
}
}
})
}
// 执行测量
val constraints = Constraints.fixed(200, 100)
val placeable = node.measure(constraints)
// 验证测量结果
assertEquals(200, placeable.width)
assertEquals(100, placeable.height)
}
这里有个坑要注意:测量结果不一定等于约束的最大值。比如你的自定义布局可能根据子组件数量动态调整高度,这时候就要验证具体的行为逻辑。
我曾经踩过的坑:测试时只用了固定约束,结果上线后用户在横屏模式下触发了不同的约束范围,布局直接崩了。所以建议至少测试三种约束场景:
- 固定约束(Constraints.fixed)
- 宽松约束(Constraints() 无限制)
- 有边界约束(Constraints(maxWidth=300, maxHeight=500))
放置验证:检查子组件位置
测量完了,接下来就是放置。放置验证的核心是:子组件是否被放到了正确的位置。
在Compose测试中,我们可以通过Placeable的position属性来获取放置后的坐标。
@Test
fun testPlacementPositions() {
val node = TestLayoutNode().apply {
// 添加两个子组件
repeat(2) { index ->
child(TestLayoutNode().apply {
measurePolicy = MeasurePolicy { _, constraints ->
layout(50, 50) {
// 放置到指定位置
placeRelative(index * 60, 0)
}
}
})
}
}
// 测量并放置
val placeable = node.measure(Constraints.fixed(200, 100))
placeable.placeRelative(0, 0)
// 验证子组件位置
val children = node.children
assertEquals(0, children[0].position.x) // 第一个子组件在x=0
assertEquals(60, children[1].position.x) // 第二个子组件在x=60
}
嗯,这里要注意placeRelative和place的区别。前者是相对布局方向(LTR/RTL)的放置,后者是绝对坐标。我个人建议统一用placeRelative,因为它能自动处理从右到左的布局场景。
实战:测试一个自定义流式布局
光说理论不够,我们来看一个完整的实战案例。假设我们写了一个简单的FlowLayout,它会把子组件从左到右排列,排不下就换行。
@Test
fun testFlowLayoutWrapping() {
// 创建流式布局节点
val flowNode = TestLayoutNode().apply {
measurePolicy = MeasurePolicy { measurables, constraints ->
var currentX = 0
var currentY = 0
var maxHeight = 0
val placeables = measurables.map { it.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
if (currentX + placeable.width > constraints.maxWidth) {
// 换行
currentX = 0
currentY += maxHeight
maxHeight = 0
}
placeable.placeRelative(currentX, currentY)
currentX += placeable.width
maxHeight = maxOf(maxHeight, placeable.height)
}
}
}
}
// 添加三个子组件,宽度分别为80、100、80
listOf(80, 100, 80).forEach { width ->
flowNode.child(TestLayoutNode().apply {
measurePolicy = MeasurePolicy { _, _ ->
layout(width, 50) { }
}
})
}
// 用200px宽的约束来测量
val placeable = flowNode.measure(Constraints.fixed(200, 200))
placeable.placeRelative(0, 0)
// 验证:前两个子组件在第一行,第三个换行
val children = flowNode.children
assertEquals(0, children[0].position.x) // 第一个在x=0
assertEquals(80, children[1].position.x) // 第二个在x=80
assertEquals(0, children[2].position.x) // 第三个换行,x=0
assertEquals(50, children[2].position.y) // 第三个在y=50(第二行)
}
测试要点总结:
| 测试维度 | 验证内容 | 常用断言 |
|---|---|---|
| LayoutNode | 节点结构、父子关系 | assertEquals, assertNull |
| 测量 | 尺寸、约束响应 | assertEquals(width), assertEquals(height) |
| 放置 | 坐标、边界、排列 | assertEquals(position.x), assertEquals(position.y) |
测试中的常见陷阱
做了这么多年测试,我总结出几个自定义布局测试的常见坑:
- 忘记调用placeRelative:测量后不放置,子组件的位置永远是(0,0)
- 约束不匹配:测试用的约束和实际场景不一致,导致测试通过但线上出问题
- 忽略子组件的测量顺序:Compose的测量顺序可能影响最终布局,尤其是依赖前一个子组件尺寸的场景
我的调试习惯:遇到布局测试失败时,先打印出所有子组件的position和size,一眼就能看出是测量错了还是放置错了。比盯着代码干想要快得多。
好了,自定义布局测试的核心内容就这些。说白了就是三步走:建节点、测测量、验放置。把这套流程跑熟了,你的自定义布局就能经得起各种约束条件的考验。