Compose 中的语义

一个 组合 描述了您应用程序的 UI,并且是通过运行可组合项生成的。组合是一个树形结构,由描述您的 UI 的可组合项组成。

除了组合之外,还有一个并行的树,称为 *语义树*。这棵树以一种可供 无障碍 服务和 测试 框架理解的替代方式来描述您的 UI。无障碍服务使用该树向有特殊需求的用户描述应用程序。测试框架使用该树与您的应用程序交互并对其进行断言。语义树不包含有关如何 *绘制* 可组合项的信息,但它包含有关可组合项的 **语义 *含义*** 的信息。

A typical UI hierarchy and its semantics tree
图 1. 典型的 UI 层次结构及其语义树。

如果您的应用程序由 Compose 基础库和 Material 库中的可组合项和修饰符组成,则语义树会自动为您填充和生成。但是,**当您添加自定义的低级可组合项时,您必须手动提供其语义**。也可能存在您的树无法正确或完整地表示屏幕上元素的含义的情况,在这种情况下,您可以调整该树。

例如,考虑这个自定义日历可组合项

A custom calendar composable with selectable day elements
图 2. 带有可选择日期元素的自定义日历可组合项。

在这个示例中,整个日历是作为一个单个低级可组合项实现的,使用 Layout 可组合项并直接绘制到 Canvas。如果您不做任何其他操作,无障碍服务将无法获得有关可组合项内容和用户在日历中选择的足够信息。例如,如果用户点击包含 17 的日期,无障碍框架只会收到有关整个日历控件的描述信息。在这种情况下,TalkBack 无障碍服务会宣布“日历”或,稍微好一点,“四月日历”,用户就会感到困惑,不知道选择了哪一天。为了使这个可组合项更易于访问,您需要手动添加语义信息。

语义属性

UI 树中所有具有语义意义的节点在语义树中都有一个并行节点。语义树中的节点包含那些传达相应可组合项含义的属性。例如,Text 可组合项包含一个语义属性 text,因为这是该可组合项的 *含义*。一个 Icon 包含一个 contentDescription 属性(如果由开发人员设置),它以文本形式传达 Icon 的含义。构建在 Compose 基础库 之上的可组合项和修饰符会为您设置相关的属性。您可以选择使用 semanticsclearAndSetSemantics 修饰符来自己设置或覆盖这些属性。例如,向节点添加 自定义无障碍操作,为可切换元素提供替代的 状态描述,或指示某个文本可组合项应被视为 标题

要可视化语义树,请使用 布局检查器 工具或使用 printToLog() 方法在测试中。这会将当前语义树打印到 Logcat 中。

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun MyTest() {
        // Start the app
        composeTestRule.setContent {
            MyTheme {
                Text("Hello world!")
            }
        }
        // Log the full semantics tree
        composeTestRule.onRoot().printToLog("MY TAG")
    }
}

此测试的输出将是

    Printing with useUnmergedTree = 'false'
    Node #1 at (l=0.0, t=63.0, r=221.0, b=120.0)px
     |-Node #2 at (l=0.0, t=63.0, r=221.0, b=120.0)px
       Text = '[Hello world!]'
       Actions = [GetTextLayoutResult]

考虑语义属性如何传达可组合项的含义。考虑一个 Switch。这是它在用户看来是什么样子

图 3. 处于“打开”和“关闭”状态的 Switch。

要描述此元素的 *含义*,您可以说以下内容:*“这是一个 Switch,它是一个可切换的元素,处于‘打开’状态。您可以点击它与之交互。”*。

这正是语义属性的用途。此 Switch 元素的语义节点包含以下属性,如布局检查器所示

Layout Inspector showing the Semantics properties of a Switch composable
图 4. 显示 Switch 可组合项的语义属性的布局检查器。

The Role 表示元素类型。The StateDescription 描述了如何引用“打开”状态。默认情况下,这是一个“打开”一词的本地化版本,但可以根据上下文使其更具体(例如,“已启用”)。The ToggleableState 是 Switch 的当前状态。The OnClick 属性引用了用于与该元素交互的方法。有关语义属性的完整列表,请查看 SemanticsProperties 对象。有关可能的无障碍操作的完整列表,请查看 SemanticsActions 对象。

跟踪应用程序中每个可组合项的语义属性会解锁许多强大的可能性。一些例子

  • Talkback 使用这些属性朗读屏幕上显示的内容,并允许用户平滑地与之交互。对于 Switch 可组合项,Talkback 可能会说:“打开;Switch;双击切换”。用户可以双击屏幕将 Switch 切换为关闭状态。
  • 测试框架使用这些属性来查找节点、与之交互并进行断言。Switch 的示例测试可能是
    val mySwitch = SemanticsMatcher.expectValue(
        SemanticsProperties.Role, Role.Switch
    )
    composeTestRule.onNode(mySwitch)
        .performClick()
        .assertIsOff()

合并和未合并的语义树

如前所述,UI 树中的每个可组合项可能设置了零个或多个语义属性。当可组合项未设置任何语义属性时,它不会包含在语义树中。这样,语义树只包含实际包含语义意义的节点。但是,有时为了传达屏幕上显示内容的正确含义,将某些节点子树合并并将其视为一个整体也很有用。这样,您可以将一组节点作为一个整体来理解,而不是分别处理每个子节点。作为经验法则,这棵树中的每个节点都表示在使用无障碍服务时的可聚焦元素。

这种可组合项的一个例子是 Button。您可以将按钮视为一个单一元素,即使它可能包含多个子节点

Button(onClick = { /*TODO*/ }) {
    Icon(
        imageVector = Icons.Filled.Favorite,
        contentDescription = null
    )
    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    Text("Like")
}

在语义树中,按钮的子节点的属性被合并,按钮在树中被呈现为一个单一的叶节点

Merged single leaf semantics representation
图 5. 合并的单个叶语义表示。

可组合项和修饰符可以通过调用 Modifier.semantics (mergeDescendants = true) {} 来指示它们希望合并其子节点的语义属性。将此属性设置为 true 表示应该合并语义属性。在 Button 示例中,Button 可组合项在内部使用 clickable 修饰符,该修饰符包含此 semantics 修饰符。因此,按钮的子节点被合并。阅读无障碍文档,了解您应该在何时 更改可组合项中的合并行为

Foundation 和 Material Compose 库中的多个修饰符和可组合项都设置了此属性。例如,clickabletoggleable 修饰符会自动合并其子节点。此外,ListItem 可组合项将合并其子节点。

检查树木

语义树实际上是两棵不同的树。有一棵 *合并的* 语义树,它在 mergeDescendants 设置为 true 时合并子节点。还有一棵 *未合并的* 语义树,它不应用合并,而是保持每个节点完整。无障碍服务使用未合并的树并应用自己的合并算法,同时考虑 mergeDescendants 属性。测试框架默认使用合并的树。

您可以使用 printToLog() 方法检查这两棵树。默认情况下,以及在前面的示例中,会记录合并的树。要改为打印未合并的树,请将 onRoot() 匹配器的 useUnmergedTree 参数设置为 true

composeTestRule.onRoot(useUnmergedTree = true).printToLog("MY TAG")

布局检查器允许您显示合并的语义树和未合并的语义树,方法是在视图过滤器中选择首选的树

Layout Inspector view options, allowing both the display of the merged and the unmerged Semantics tree
图 6. 布局检查器视图选项,允许显示合并的语义树和未合并的语义树。

对于树中的每个节点,布局检查器都会在属性面板中显示合并的语义和在该节点上设置的语义

Semantics properties merged and set
图 7. 合并和设置的语义属性。

默认情况下,测试框架中的匹配器使用合并的语义树。这就是为什么您可以通过匹配显示在其中的文本来与 Button 交互的原因

composeTestRule.onNodeWithText("Like").performClick()

通过将匹配器的 useUnmergedTree 参数设置为 true 来覆盖此行为,就像 onRoot 匹配器一样。

合并行为

当一个可组合项指示其子节点应该被合并时,这个合并究竟是如何发生的?

每个语义属性都有一个定义的合并策略。例如,ContentDescription 属性将所有子级 ContentDescription 值添加到一个列表中。通过检查 SemanticsProperties.kt 中的 mergePolicy 实现来检查语义属性的合并策略。属性可以采用父级或子级值、将值合并到列表或字符串中、完全不允许合并并改为抛出异常,或任何其他自定义合并策略。

需要注意的是,本身设置了 mergeDescendants = true 的子节点不会包含在合并中。看一个例子

List item with image, some text, and a bookmark icon
图 8. 带有图像、一些文本和书签图标的列表项。

这是一个可点击的列表项。当用户按下该行时,应用程序会导航到文章详情页面,用户可以在该页面上阅读文章。在列表项中,有一个按钮可以为文章添加书签,它形成一个嵌套的可点击元素,因此按钮在合并的树中单独显示。该行中的其余内容被合并了

The merged tree contains multiple texts in a list inside the Row node. The unmerged tree contains separate nodes for each Text composable.
图 9. 合并的树在 Row 节点内包含一个列表中的多个文本。未合并的树为每个 Text 可组合项包含单独的节点。

调整语义树

如前所述,您可以覆盖或清除某些语义属性,或更改树的合并行为。这在您创建自己的自定义组件时尤其重要。如果没有设置正确的属性和合并行为,您的应用程序可能无法访问,并且测试的行为可能与您预期不同。要了解有关您应该调整语义树的一些常见用例的更多信息,请阅读 无障碍文档。如果您想了解有关测试的更多信息,请查看 测试指南

其他资源