深入理解 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树
  • 语义树的作用:支持无障碍功能、便于开发测试
相关推荐
_一条咸鱼_15 小时前
揭秘 Android View 测量原理:从源码到实战深度剖析
android·面试·android jetpack
_一条咸鱼_15 小时前
深度剖析:Android View 动画原理大揭秘
android·面试·android jetpack
用户71887350336801 天前
Android适配最新SplashScreen方案
android·android jetpack
_一条咸鱼_2 天前
深度剖析:Android SurfaceView 使用原理大揭秘
android·面试·android jetpack
_一条咸鱼_2 天前
深度揭秘!Android HorizontalScrollView 使用原理全解析
android·面试·android jetpack
_一条咸鱼_2 天前
揭秘 Android RippleDrawable:深入解析使用原理
android·面试·android jetpack
_一条咸鱼_2 天前
深入剖析:Android Snackbar 使用原理的源码级探秘
android·面试·android jetpack
_一条咸鱼_2 天前
揭秘 Android FloatingActionButton:从入门到源码深度剖析
android·面试·android jetpack
_一条咸鱼_2 天前
深度剖析 Android SmartRefreshLayout:原理、源码与实战
android·面试·android jetpack