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 属性:节点的「身份证」

节点上绑定的属性决定了这个组件在语义层面「是什么」、「有什么用」。常用的语义属性有这些:

属性名类型说明
roleSemanticsRole组件角色,如 Button、Image、TextField
textString组件的文本内容
contentDescriptionString无障碍描述,常用于图片
stateDescriptionString状态描述,如「已选中」「展开」
onClick(() -> Unit)?点击动作
isSelectedBoolean是否被选中
isEnabledBoolean是否可用

举个例子,一个带图标的按钮:

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 作用:语义树到底干嘛用

语义树最直接的应用场景有三个:

  1. 无障碍(Accessibility):TalkBack、Switch Access 等工具读取语义树,让有障碍的用户也能操作 App。
  2. 自动化测试(UI Testing):Compose 的 SemanticsMatcher 和 onNode() 全部基于语义树查找节点。你写的 onNodeWithText("确定"),背后就是在语义树上遍历找 text 属性值为「确定」的节点。
  3. 调试与断言:利用语义树可以判断某个组件是否存在、是否可点击、是否选中。这在写类似「确认弹窗出现后,点击取消按钮」的测试用例时非常有用。

2.5 核心知识图谱

下面这张图梳理了本章的核心概念和关系:

语义树核心概念 语义树 (Semantics Tree) 节点 (Node) 属性 (Attributes) 作用 (用途) 每个UI元素对应一个节点 父子层级 = UI嵌套结构 每个节点有唯一 ID role / text / contentDescription onClick / isSelected / isEnabled 属性合并:子→父自动传递 无障碍(TalkBack 等) 自动化测试(onNode + Matcher) 调试与断言(printToLog 等) 📌 核心规律 属性合并使父节点自动具有子节点的语义,但 mergeDescendants = false 会阻断合并

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 测试的基础。节点、属性、合并机制这三样拿捏住,你再写断言就不会稀里糊涂地失败。