深入理解 SemanticsModifierNode:打造无障碍应用的关键

从语义到语义树

SemanticsModifierNode 是用来提供 semantics tree 语义树的。

语义树是什么?其中语义又是什么?

别着急,往下看 🔻

什么是语义(Semantics)?

在编译原理中,语义指的是程序代码的实际含义和执行效果。而在 Jetpack Compose 中,语义的含义是:UI组件对于用户来说的实际意义和功能,尤其是对于使用辅助技术(如屏幕阅读器)的用户而言。

简单来说,语义描述了:🔍 这个组件是什么(按钮?文本?图片?),📊 它的状态是怎么样的(可用?禁用?选中?),🖐️ 用户怎么去操作它(点击?滑动?长按?)。

比如一个静音按钮,它的语义可能是:这是一个静音按钮,按钮当前是可以点击的,用户可以双击屏幕来激活它,使手机变为静音模式。

什么是语义树(Semantics Tree)?

既然知道了什么是语义,那语义树不就是描述了很多个组件的树形结构吗?

对,你可以这么想。

在 Compose 的组合过程中,会形成一个 UI 节点树,它负责渲染界面。语义树就是删除或合并了UI节点树中那些对用户交互没有实际意义的节点,最后得到的节点树。

语义树只包含了"有意义"的节点。有意义的节点就是可以供用户查看、操作的组件用户需要了解它状态的组件。比如:查看一张图片、查看一段文字、点击一个按钮、点击一个输入框、了解进度条的进度。

无意义的节点就是用于纯布局的组件装饰性的组件用户不可见、不可交互的组件 。比如,RowColumn 组件是用来布局的;有背景色的 Box() 组件;用于占位,提供间距的 Spacer() 组件,对用户来说看不到、点击无效果。

关键区别在于:用户是否会关注这个组件本身

比如这样的UI节点树:

scss 复制代码
UI节点树:
App
├── Column
│   ├── Row
│   │   ├── Image (用户头像)
│   │   ├── Spacer (空白占位)
│   │   └── Text (用户名)
│   └── Button (登录)
│       └── Text (登录)

生成的语义树是:

scss 复制代码
 语义树:
 App
 ├── Image (用户头像)
 ├── Text (用户名)
 └── Button (登录)

其中对用户没有实际交互意义的组件在语义树中会被移除。

语义树的作用与重要性

语义树的作用是:1. 用于系统的无障碍功能 2. 开发时进行测试

系统无障碍

无障碍功能(Accessibility)是指让所有人,包括残障用户,都能够顺畅地使用应用,旨在消除用户使用应用的障碍。

其中有一个非常重要的无障碍功能就是 TalkBack(屏幕阅读器),它可以为视力障碍的用户提供语音提示。

来演示一下:

它会用绿色框选中当前组件,并且语音播报组件的语义信息:"已开启TalkBack屏幕阅读,开关,点按两次即可切换"。

再解释一下就是你点击任意一个组件,它并不会触发点击逻辑,而是用绿框标记,并且说出这个组件的语义,然后在屏幕任意位置双击才可以触发点击。

你也可以自己试一试,体验一下效果,可以让你更好地理解语义树。

开发测试

语义树还可以给开发测试带来方便,我们可以快速定位到被我们设置了语义信息的UI节点(相当于拿到了这个组件)。

可以检查组件的状态,比如它是可见还是不可见,可用还是不可用;可以模拟用户与组件交互,对登录按钮进行点击,对输入文本到输入框。

实现无障碍

无障碍的实现可以被分为两点,第一组件要提供可被系统选中的逻辑,因为系统要给被选中的组件加上绿色框;第二组件需要提供可被系统获取的可读信息,这样系统才能获取到信息,将信息读出来给用户听到。

在 Compose 中要提供这两个东西,使用的是 SemanticsModifierNode,对应的修饰符函数是 Modifier.semantics()Modifier.clearAndSetSemantics()

Modifier.semantics()

那怎么使用呢?

先来看这个示例代码:

kotlin 复制代码
@Composable
fun SemanticsDemo(modifier: Modifier = Modifier) {
    Column {
        Text("曾经沧海难为水") // Text 组件已内置无障碍支持

        Box(Modifier
            .background(Color.Green)
            .width(120.dp)
            .height(80.dp))
        // 默认情况下,这个绿色方块没有语义信息
    }
}

当我们开启屏幕阅读后,它会自动选中文本组件,并语音播报:"曾经沧海难为水",这是因为 Compose 在内部对Text 组件做了无障碍的支持。

绿色方块就不行了,屏幕阅读对它没有任何效果。

添加语义信息

semantics() 修饰符是可以给绿色方块也加上这种效果的,比如:

diff 复制代码
@Composable
fun SemanticsDemo(modifier: Modifier = Modifier) {
    Column {
        Text("曾经沧海难为水")

        Box(Modifier
            .background(Color.Green)
            .width(120.dp)
            .height(80.dp)
+            .semantics { 
+                contentDescription = "绿色方块" // 添加内容描述
+            }
        )
    }
}

这样当点击绿色方块时,系统能够框选它并读出它的信息------"绿色方块"。

semantics() 的大括号中不止可以设置 contentDescription 语义属性,比如还有 stateDescription (描述组件的状态)、progressBarRangeInfo (描述进度条信息)等,这些属性都是 SemanticsPropertyReceiver 接口的扩展属性,文件是 SemanticsProperties.kt。

不过常用的组件已经具备了无障碍能力,比如刚刚的 Text 组件,还有 Image 组件。

语义合并

再来看这段代码:

kotlin 复制代码
Button(onClick = {}) {
    Text("除却巫山不是云")
}

Button 组件、Text组件都具有无障碍的能力,能够被读,那么当我点击了这个按钮(范围内的任意位置),会发生什么?

语言播报:"除却巫山不是云,按钮,点按两次即可激活"。

为什么是这样?

因为按钮和文本在语义树中被合并了,这是语义树的重要特性------语义合并。在用户的角度上,这就是一个有着文本的按钮,肯定不会读成:这是一个按钮,按钮里有一个文本,文本是...

控制语义合并

知道了这个,你也可以做这种合并需求,大多数情况下我们是不用考虑的,因为许多可触摸的组件,都有合并, Modifier.clickable() 内部就完成了合并,如果你真有合并的需求,只需要设置 semantics()mergeDescendants 参数为true,就可以了,mergeDescendants 的意思是合并后代,这个参数默认是false。

这个 mergeDescendants 参数还有一个作用,防止自己被合并到外部组件中,换个角度来看,就是外部组件合并内部组件时,会跳过 mergeDescendants 参数设置为true的组件。

比如在上面的例子中,给 Text 组件设置这个参数为true:

kotlin 复制代码
Button(onClick = {}) {
    Text("除却巫山不是云", Modifier.semantics(mergeDescendants = true) { 
        
    })
}

这样 Text 组件和 Button 组件分开了,文本组件不会被合并到按钮中,并且只有文本被绿色框框起来了,语音也只读了"除却巫山不是云",并没有读"按钮,点按两次即可激活"。

这种合并的设置,往往添加在可交互的组件上面。

Modifier.clearAndSetSemantics()

它和 semantics() 的区别是:它不会和内部的组件合并,并把这些组件从语义树中清除。

简单来说:它会清除内部组件的所有语义信息,只保留自身设置的语义信息。

我们来对比一下,如果使用的是 semantics,并且 mergeDescendants 参数为 true:

kotlin 复制代码
Box(
    Modifier.background(Color.Red).width(150.dp).height(90.dp).semantics(mergeDescendants = true) {
        contentDescription = "一个红色块"
    }
) {
    Text("我很重要,你一定要读我啊")
}

TalkBack 会读出 "一个红色块,我很重要,你一定要读我啊"

换成使用 clearAndSetSemantics():

kotlin 复制代码
Box(
    Modifier.background(Color.Red).width(150.dp).height(90.dp).clearAndSetSemantics{
        contentDescription = "一个红色块"
    }
) {
    Text("我很重要,你一定要读我啊")
}

只会语音播报:"一个红色块"。

这个函数的使用场景:组件不需要内部组件语义,比如将多个组件作为一个整体对外提供语义。

总结

  • 语义(Semantics) 描述了UI组件对用户的实际意义和功能
  • 语义树(Semantics Tree) 是删除或合并了无意义节点后的UI树
  • 语义树的作用:支持无障碍功能、便于开发测试
相关推荐
天花板之恋8 小时前
Compose之图片加载显示
android jetpack
消失的旧时光-19431 天前
Kotlinx.serialization 使用讲解
android·数据结构·android jetpack
Tans51 天前
Androidx Fragment 源码阅读笔记(下)
android jetpack·源码阅读
Lei活在当下2 天前
【业务场景架构实战】2. 对聚合支付 SDK 的封装
架构·android jetpack
Tans54 天前
Androidx Fragment 源码阅读笔记(上)
android jetpack·源码阅读
alexhilton6 天前
runBlocking实践:哪里该使用,哪里不该用
android·kotlin·android jetpack
Tans58 天前
Androidx Lifecycle 源码阅读笔记
android·android jetpack·源码阅读
ljt27249606619 天前
Compose笔记(四十九)--SwipeToDismiss
android·笔记·android jetpack
4z3311 天前
Jetpack Compose重组优化:机制剖析与性能提升策略
性能优化·android jetpack
alexhilton12 天前
Android ViewModel数据加载:基于Flow架构的最佳实践
android·kotlin·android jetpack