Compose 中的语义

组合将描述应用的界面,并通过运行可组合项来生成。组合是描述界面的可组合项的树结构。

组合旁边存在一个名为语义数的并行树。此树以无障碍服务测试框架可以理解的替代方式描述您的界面。无障碍服务使用该树向有特定需求的用户描述应用。测试框架使用该树与您的应用进行交互并作出相关声明。语义树不包含有关如何绘制可组合项的信息,而是包含关于可组合项的语义含义的信息。

图 1. 典型的界面层次结构及其语义树。

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

例如,请考虑该自定义日历可组合项:

图 2. 具有可选日期元素的自定义日历可组合项。

在此示例中,整个日历实现为单个低级可组合项,使用 Layout 可组合项并直接绘制为 Canvas。如果您不执行任何其他操作,无障碍服务将无法接收到足够的有关可组合项内容以及用户在日历中所做选择的信息。例如,如果用户点击包含 17 的日期,则无障碍服务框架只会接收整个日历控件的说明信息。在这种情况下,TalkBack 无障碍服务只会读出“日历”,稍微好点儿的话,或许会读出“四月日历”,而用户可能会好奇究竟选中了哪一天。如需使此可组合项更没有障碍,您需要手动添加语义信息。

语义属性

具有一定语义含义的界面树中的所有节点在语义树中都有一个并行节点。语义树中的节点包含这些属性,这些属性传达了对应可组合项的含义。例如,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,它是可切换元素,目前处于‘开启’状态。您可以点击它以进行交互。”

这正是语义属性的用途。此 Layout 元素的语义节点包含以下属性,由布局检查器直观呈现:

图 4. 显示 Switch 可组合项的语义属性的布局检查器。

Role 表示我们查看的是哪种类型的元素。StateDescription 描述应如何引用“开启”状态。默认情况下,这只是“On”一词的本地化版本,但可以根据上下文使用更具体的内容(例如,“启用”)。ToggleableState 是 Switch 的当前状态。OnClick 属性引用了用于与此元素进行交互的方法。如需查看语义属性的完整列表,请查看 SemanticsProperties 对象。如需查看可能的无障碍操作的完整列表,请查看 SemanticsActions 对象。

跟踪应用中每个可组合项的语义属性可以释放出诸多强大的可能性。请参见以下示例:

  • Talkback 使用一些属性朗读屏幕上显示的内容,并让用户顺畅地与屏幕进行交互。对于我们的 Switch,它可能会说:“开启 Switch;点按两次即可切换”。用户可以点按两次屏幕切换为关闭 Switch。
  • 测试框架使用这些属性来查找节点、与节点进行交互并做出声明。Switch 的测试示例可以是:
    val mySwitch = SemanticsMatcher.expectValue(
        SemanticsProperties.Role, Role.Switch
    )
    composeTestRule.onNode(mySwitch)
        .performClick()
        .assertIsOff()

合并和未合并的语义树

如前文所述,界面树中的每个可组合项都可能设置了零个或多个语义属性。如果可组合项未设置语义属性,那么它不会包含在语义树中。这样一来,语义树便仅包含实际包含语义含义的节点。然而,有时为了传达屏幕上所显示内容的正确含义,合并某些节点树并将它们视为一个树也十分有用。这样,我们就可以对一组节点进行整体推断,而不是单独处理每个后代节点。一般来讲,该树中的每个节点都代表使用无障碍服务时可聚焦的一个元素。

Button 就属于这种可组合项。我们希望将 Button 作为单个元素进行推断,即使它可能包含多个子节点:

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

在语义树中,合并了 Button 后代节点的属性,并将 Button 作为树中的单个叶节点呈现:

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

基础库和 Material Compose 库中的几个修饰符和可组合项已设置此属性。例如,clickabletoggleable 修饰符会自动合并其后代节点。此外,ListItem 可组合项也会合并其后代节点。

检查树

在讨论语义树时,我们实际上讨论的是两个不同的树。有一个合并的语义树,它会在 mergeDescendants 设置为 true 时合并后代节点。此外,还有一个未合并的语义树,它不会应用合并,但会保持每个节点不变。无障碍服务使用未合并的树并应用自己的合并算法,还会考虑 mergeDescendants 属性。默认情况下,测试框架使用合并的树。

您可以使用 printToLog() 方法检查这两个树。默认情况下,与前面的示例一样,系统会记录合并的树。如需改为输出未合并的树,请将 onRoot() 匹配器的 useUnmergedTree 参数设置为 true

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

借助布局检查器,您可以通过在视图过滤器中选择首选的树,同时显示合并和未合并的语义树:

图 5. 布局检查器视图选项,允许同时显示合并和未合并的语义树。

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

默认情况下,测试框架中的匹配器会使用合并的语义树。因此,您可以通过匹配 Button 中显示的文字与之进行交互:

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

您可以通过将匹配器的 useUnmergedTree 参数设置为 true 来替换此行为,就像我们之前使用 onRoot 匹配器所做的那样。

合并行为

当可组合项指示应该合并其后代节点时,这种合并究竟是如何发生的?

每个语义属性都有定义的合并策略。例如,ContentDescription 属性会将所有 ContentDescription 后代值添加到列表中。您可以通过检查语义属性在 SemanticsProperties.kt 中的 mergePolicy 实现来检查语义属性的合并策略。属性可以选择始终选择父项或子项值,将值合并到列表或字符串中,完全不允许合并并抛出异常,也可以选择任何其他自定义合并策略。

需要注意的是,自身已设置 mergeDescendants = true 的后代节点不包括在合并中。下面我们来看一个示例:

图 6. 包含图片、一些文字和书签图标的列表项。

这里有一个可点击的列表项。当用户按某个行时,应用会导航到文章详情页面,用户可以在该页面中阅读文章。 列表项内有一个按钮,可用于为这篇文章添加书签。在本例中,我们有一个嵌套的可点击元素,因此该按钮将在合并的树中单独显示。行中的其余内容已合并:

图 7. 合并的树在 Row 节点内的列表中包含许多文字。未合并的树包含每个 Text 可组合项的单独节点。

调整语义树

如前所述,您可以替换或清除某些语义属性,或更改树的合并行为。在您创建自己的自定义组件时,尤为如此。如果没有设置正确的属性和合并行为,则应用可能无法访问,并且测试的行为可能与预期有所不同。如需详细了解有关您应该调整语义树的一些常见用例,请参阅无障碍功能文档。如果您想详细了解测试,请参阅测试指南