19、自定义布局测试:LayoutNode测试、测量验证、放置验证

自定义布局,是Compose进阶路上绕不开的一道坎。说实话,我见过不少团队在自定义布局上栽跟头——要么测量结果不对,要么子组件放错了位置。更麻烦的是,这类问题往往在真机跑起来才能发现,调试成本极高。

所以这一章,我们来聊聊怎么给自定义布局写测试。核心就三件事:测LayoutNode、测测量过程、测放置结果。掌握了这些,你就能在代码提交前把布局问题揪出来。

为什么自定义布局需要专项测试?

你想想看,Compose自带的布局组件(Column、Row、Box)都是经过千锤百炼的,基本不会出问题。但你自己写的布局呢?

  • 测量逻辑对不对?子组件有没有被正确约束?
  • 放置位置准不准?有没有超出边界?
  • 多子组件时,排列顺序是否符合预期?

我在项目中遇到过好几次,自定义布局在单一子组件时表现完美,但一旦子组件数量增多,测量结果就开始飘了。嗯,这种问题靠肉眼审查代码很难发现,必须靠测试来兜底。

自定义布局测试的核心思路

说白了,自定义布局的测试就是模拟Compose的布局流程:测量 → 放置。我们不需要真的把界面渲染出来,只需要验证布局节点(LayoutNode)在测量和放置阶段的行为是否正确。

核心测试对象:

  • LayoutNode:布局树中的节点,代表一个可组合项
  • 测量(Measure):根据约束计算子组件的大小
  • 放置(Place):将子组件放到指定位置

下面这张图展示了自定义布局测试的完整流程:

自定义布局测试流程 创建LayoutNode 模拟子组件节点 执行测量 传入约束,获取尺寸 验证测量结果 宽高是否符合预期 执行放置 设置子组件位置 验证放置结果 坐标、边界是否正确 断言验证 使用JUnit断言 关键测试点 约束范围 | 子组件尺寸 | 放置坐标 | 边界溢出 | 排列顺序

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时,重点关注measurePolicychildrenCount这两个属性。前者决定了布局行为,后者反映了子组件树的结构。

测量验证:确保尺寸计算正确

测量是自定义布局最核心也最容易出错的地方。我见过最典型的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测试中,我们可以通过Placeableposition属性来获取放置后的坐标。

@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
}

嗯,这里要注意placeRelativeplace的区别。前者是相对布局方向(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的测量顺序可能影响最终布局,尤其是依赖前一个子组件尺寸的场景

我的调试习惯:遇到布局测试失败时,先打印出所有子组件的positionsize,一眼就能看出是测量错了还是放置错了。比盯着代码干想要快得多。

好了,自定义布局测试的核心内容就这些。说白了就是三步走:建节点、测测量、验放置。把这套流程跑熟了,你的自定义布局就能经得起各种约束条件的考验。


公众号:蓝海资料掘金营,微信deep3321