2. 语义树基础概念:节点、属性、作用
咱们直接切入正题。语义树,说白了就是 Compose 在运行过程中,帮我们维护的一颗「组件信息树」。你每写一个 Text、每放一个 Button,它们都会在树上挂一个节点。每个节点上都绑着一堆属性——比如这个组件叫什么、有没有点击事件、文本内容是什么。
我刚开始接触 Compose 时,总觉得这玩意儿跟 DOM 树差不多,后来踩了几个坑才发现,两者完全不是一个东西。语义树不关心布局,只关心「可访问性」和「自动化测试」需要的信息。你想想看,屏幕上有个按钮,屏幕阅读器读出来的是「确认按钮」,背后依赖的就是语义树里这个节点的role和text属性。
2.1 节点:树的基石
每个语义节点对应一个 UI 元素。节点之间是父子关系,跟你在 Compose 里写的嵌套结构一致。例如:
// Composable 代码
Column {
Text("用户名")
TextField(value = "", onValueChange = { })
Button(onClick = { }) {
Text("登录")
}
}
这段代码在语义树上就变成三个节点:Text、TextField、Button,它们的父节点是 Column。每个节点都有一个唯一的 ID(由 Compose 自动生成),以及一系列语义属性。
我在项目中遇到过一个问题:某个自定义组件无法被屏幕阅读器识别,排查半天发现是忘记给组件添加 semantics { } 修饰符。没有语义节点,辅助功能就看不见它。所以,自定义组件一定要主动声明语义。
2.2 属性:节点的「身份证」
节点上绑定的属性决定了这个组件在语义层面「是什么」、「有什么用」。常用的语义属性有这些:
| 属性名 | 类型 | 说明 |
|---|---|---|
role | SemanticsRole | 组件角色,如 Button、Image、TextField |
text | String | 组件的文本内容 |
contentDescription | String | 无障碍描述,常用于图片 |
stateDescription | String | 状态描述,如「已选中」「展开」 |
onClick | (() -> Unit)? | 点击动作 |
isSelected | Boolean | 是否被选中 |
isEnabled | Boolean | 是否可用 |
举个例子,一个带图标的按钮:
IconButton(onClick = { }) {
Icon(Icons.Default.Search, contentDescription = "搜索")
}
它的语义节点角色是 Button,但没有文本。contentDescription 被存到了 text 属性里吗?不一定 —— contentDescription 和 text 是两个独立属性,屏幕阅读器会优先读 text,如果没有,再读 contentDescription。我建议你在编写测试断言时,直接通过 SemanticsMatcher 匹配具体的属性键,不要依赖默认的合并行为。
我曾经因为依赖了默认属性合并逻辑,写出来的测试在 Android 12 上全红。后来发现系统版本不同,语义属性的合并顺序有差异。自此之后,所有关键断言我都明确指定属性路径。
2.3 属性合并:父子节点的「继承」机制
语义树有个非常容易踩坑的特性——属性合并。默认情况下,子节点的属性会向父节点合并。什么意思?比如你有个 Button,里面包了一个 Text("确定"),那么 Button 节点的 text 属性会自动继承子 Text 的文本。
看这个例子:
Button(onClick = { }) {
Row {
Icon(Icons.Default.Check, contentDescription = null)
Text("保存")
}
}
语义树上,Button 节点会合并到 text = "保存",而 Icon 没有 contentDescription,所以它不会贡献任何文本。但如果你给 Row 显式加了 semantics { } 块,合并行为就可能被阻断——这就是为什么有些组件在测试中找不到文本。
核心结论:要理解属性合并的规则,最好的办法是在测试中打印语义树。Compose 提供了 printToLog() 扩展函数,直接看树形结构,比猜靠谱一万倍。
2.4 作用:语义树到底干嘛用
语义树最直接的应用场景有三个:
- 无障碍(Accessibility):TalkBack、Switch Access 等工具读取语义树,让有障碍的用户也能操作 App。
- 自动化测试(UI Testing):Compose 的
SemanticsMatcher和onNode()全部基于语义树查找节点。你写的onNodeWithText("确定"),背后就是在语义树上遍历找text属性值为「确定」的节点。 - 调试与断言:利用语义树可以判断某个组件是否存在、是否可点击、是否选中。这在写类似「确认弹窗出现后,点击取消按钮」的测试用例时非常有用。
2.5 核心知识图谱
下面这张图梳理了本章的核心概念和关系:
2.6 一个小实验:打印语义树
说了这么多,不如动手看一下。在测试代码里加上这行,就能把当前的语义树打到 Logcat 里:
composeTestRule.onRoot().printToLog("SEMANTICS_TREE")
输出大概是这个样子(我简化了格式):
Node #1
Role: Row
Children:
Node #2
Role: Text
Text: "用户名"
Node #3
Role: TextField
Text: ""
Node #4
Role: Button
Text: "登录"
Actions: [OnClick]
看到没?每个节点都有角色、文本、动作。这就是写断言时的「藏宝图」。你断言某个节点是否存在,本质上就是在语义树上做一次深度优先搜索。
2.7 属性合并的具体影响
我再给你举个我实际遇到过的例子。有个测试用例一直报错:
composeTestRule.onNodeWithText("提交").performClick()
明明界面上有「提交」按钮,但就是找不到。打印语义树发现,按钮内部用了 Row 包裹了一个 Icon 和一个 Text,但 Row 上被手动加了 semantics { mergeDescendants = false }。这一加,子 Text 的文本就不再合并到 Button 上了。Button 节点的 text 属性是空的,自然匹配不到。
解决方案?要么去掉 mergeDescendants = false,要么直接匹配 Text 节点:
// 直接找 Text 节点(如果它是独立的)
composeTestRule.onNodeWithText("提交").performClick()
但注意,如果 Text 节点本身也没有语义属性,就得通过 onNode(hasText("提交")) 这种组合匹配器来找了。
一句话总结:理解语义树 = 理解 Compose 测试的基础。节点、属性、合并机制这三样拿捏住,你再写断言就不会稀里糊涂地失败。