SemanticsModifier
SemanticsModifier 有什么用?
Semantics [sə'mæntɪks]: 语义学。
SemanticsModifier,语义修饰符... 什么玩意?老实说,对于国内大多数 Android 开发者,一开始都比较难理解语义修饰符,它能够为元素提供一些额外的信息,主要用于无障碍服务 Accessibility 和测试 Testing,而这两项技术在国内并不怎么受待见。
Compose 会在组合阶段执行 Composable 函数生成一颗 UI 树🌳,用来描述界面。
实际上除了这颗 UI 树,Compose 中还有另外一颗结构类似的树,叫做语义树,每个节点都是一个 SemanticsNode。语义树完全不参与绘制和渲染工作,因此是完全不可见的,它只为 Accessibility 和 Testing 服务。
-
Accessibility 需要根据语义树的节点内容进行发音:
Android 系统中有一个无障碍服务功能叫 TalkBack,它可以帮助视力障碍人士通过触摸 + 语音反馈的方式来与手机进行交互。开启 TalkBack 后,手机会读出用户触摸到的界面元素信息,这些信息就是来自语义树,用户可以按照语音提示进一步操作。
-
Testing 需要根据语义树找到想要测试的节点来执行测试逻辑:
将测试和无障碍服务放在一起讨论很容易让人感到奇怪,直觉上我们会认为它俩毫无关系。不过实际上它们是存在一些共同点的:
- 需要找到某个控件元素,在 Compose 中界面元素是没有 id 的,因此需要通过语义树来定位;
- 需要对控件元素进行操作,比如点击、滑动等等;
- 需要访问控件元素的 Metadata,比如文本内容、描述信息、启用状态等等。
语义树的作用我们大概了解清楚了...那么,语义树是怎么生成的呢?答案很明显,就是靠 SemanticsModifier。
好消息是,绝大部分情况下,我们都在使用一些标准 Composable 函数来编写界面,其中的大多数都建构在 Compose foundation 库之上,这时是不需要专门为了语义树去写 SemanticsModifier 的,组件内部已经帮我们处理好了这些工作。
kotlin
Column {
Text(text = "Hello World!")
Button(onClick = { }) { }
Switch(...)
Image(
painter = painterResource(id = R.drawable.compose),
contentDescription = "Compose logo" // contentDescription 就是 Image 组件的语义
)
}
semantics() 修饰符 & 语义属性
而当我们使用低级 API 来绘制界面时,就需要使用 semantics()
修饰符来为组件提供相应的语义了:
kotlin
Box(
Modifier
.background(Blue)
.size(150.dp)
.semantics { contentDescription = "a blue box" }
)
semantics()
修饰符允许向当前组件添加键值对形式的语义信息,这些键值对也被称作"语义属性",例如 text 是 Text 组件的一个语义属性 ,值就是 Text 组件的文本内容; contentDescription 是 Image 组件的一个语义属性(如果不设置为 null),值就是所设置的描述信息。这些属性传达了组件的含义,语义树上的节点 SemanticsNode 就是根据这些属性来构建的。
kotlin
fun Modifier.semantics(
mergeDescendants: Boolean = false,
properties: (SemanticsPropertyReceiver.() -> Unit) // 📌
): Modifier
semantics()
的函数类型参数 properties
接收者类型为 SemanticsPropertyReceiver:
kotlin
/**
* SemanticsPropertyReceiver is the scope provided by semantics {} blocks, letting you set
* key/value pairs primarily via extension functions.
*/
interface SemanticsPropertyReceiver {
operator fun <T> set(key: SemanticsPropertyKey<T>, value: T) // 📌
}
var SemanticsPropertyReceiver.contentDescription: String
get() = throwSemanticsGetNotSupported()
set(value) {
set(SemanticsProperties.ContentDescription, listOf(value)) // 📌
}
...
SemanticsPropertyReceiver 是 semantics { } 修饰符提供的作用域,能让开发者通过现有的扩展函数/属性来方便地设置各种键值对,它们是一些公共语义属性,比如前面刚用到的 contentDescription
扩展属性,只是在背后帮我们调用了 SemanticsPropertyReceiver 的 set() 方法而已。
合并和未合并的语义树
semantics()
修饰符有一个可选参数 mergeDescendants: Boolean = false
,这又是什么呢?
kotlin
fun Modifier.semantics(
mergeDescendants: Boolean = false, // 📌
properties: (SemanticsPropertyReceiver.() -> Unit)
): Modifier
有些情况下,我们需要对语义树中的节点进行合并,比如:
kotlin
Button(onClick = { /*TODO*/ }) {
Text("Like")
}
以上是 Button 组件的基本用法,在 Button 中套了一个 Text,由于它们是两个独立元素,在语义树中 Text 是 Button 的子节点,TalkBack 会为两个 SemanticsNode 单独发音,这显然不符合预期效果,想要 TalkBaack 把 Button 与 Text 视为一个整体发音,就要使用 mergeDescendants
参数对语义树中的节点进行合并:
kotlin
Button(
onClick = { /*TODO*/ }
modifie = Modifier.semantics(mergeDescendants = true) {},
) {
Text("Like")
}
将 mergeDescendants
设置为 true 后,所属语义节点会和其下所有子节点进行语义合并(除了那些 mergeDescendants = true
的子节点),不过对于上面这个例子,我们是没必要手动合并的,因为 Button 的 onClick
参数在背后替我们调用了 clickable()
修饰符,如果一个组件使用了 clickable()
/ toggleable
修饰符,内部会默认进行语义属性合并。
再来看一个例子, 下面有一个可点击的文章列表项。用户可以点击整行打开文章详情页,也可以点击右侧按钮收藏文章。
因为 Row 是可点击的(clickable),所以它会与语义树中的所有子节点合并,但不包括收藏按钮,因为收藏按钮也是可点击元素:
语义属性合并规则
还有一个问题,前面已经看过 SemanticsPropertyReceiver 接口源码了,它的 set()
方法是一个泛型方法,也就是说语义属性可以是任意类型,那么肯定不存在一套通用的语义属性合并规则,具体来说到底是怎么个合并法呢?还是以 contentDescription 为例,我们看一下公共语义属性 contentDescription 所使用的键(SemanticsPropertyKey),关键在于 mergePolicy
,这个参数为每个语义属性指定了特定的合并规则。
kotlin
// SemanticsProperties.kt
var SemanticsPropertyReceiver.contentDescription: String
get() = throwSemanticsGetNotSupported()
set(value) {
set(SemanticsProperties.ContentDescription, listOf(value)) // 📍
}
object SemanticsProperties {
val ContentDescription = SemanticsPropertyKey<List<String>>(
name = "ContentDescription",
// 👇
mergePolicy = { parentValue, childValue ->
// contentDescription 语义属性的类型是 List<String>,
// 合并时会简单地将所有子节点的值添加到父节点的值中
parentValue?.toMutableList()?.also { it.addAll(childValue) } ?: childValue
}
)
}
文章一开始提到只有一颗语义树,其实并不严谨,准确地说有两颗语义树:一颗是在 UI 树的基础上,剔除了那些没有设置语义属性的元素后,生成的未合并的语义树;第二颗则是经过合并的语义树。
理解了 unmerged semantics tree 和 merged semantics tree 后,我们顺便看看 clearAndSetSemantics()
修饰符,与 semantics()
修饰符不同,它会在设置语义信息之前,先清除所有子节点的语义信息(不会清除布局节点自身的语义信息)。具体来说,对于合并语义树,会将所有子节点提供的语义信息擦除;对于未合并语义树,会将所有子节点打上标记 [SemanticsConfiguration.isClearingSemantics]
,并不会实际擦除语义信息。
检查树
要查看语义树中各个节点的语义信息,有两种方式:
1.使用 Android Studio 的布局检查器 Layout Inspector,直接查看每个元素的语义信息
2.编写插桩测试,打印语义树
kotlin
class MyComposeTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun MyTest() {
// Start the app
composeTestRule.setContent {
MaterialTheme {
Button(onClick = { /*TODO*/ }) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(text = "Like")
}
}
}
// Log the full semantics tree
composeTestRule.onRoot(useUnmergedTree = true).printToLog(tag = "TAG")
}
}
/*
TAG : printToLog:
TAG : Printing with useUnmergedTree = 'true'
TAG : Node #1 at (l=0.0, t=136.0, r=293.0, b=268.0)px <--这个是根语义节点
TAG : |-Node #2 at (l=0.0, t=147.0, r=293.0, b=257.0)px
TAG : Focused = 'false'
TAG : Role = 'Button'
TAG : Actions = [OnClick, RequestFocus]
TAG : MergeDescendants = 'true'
TAG : |-Node #6 at (l=154.0, t=176.0, r=227.0, b=228.0)px
TAG : Text = '[Like]'
TAG : Actions = [GetTextLayoutResult]
* /
无障碍服务中的 SemanticsModifier
关于 SemanticsModifier 的理论知识,我们了解得已经足够多了,下面来看一些无障碍服务中 SemanticsModifier 的常见用例。
1.最小触摸目标尺寸
对于无障碍服务来说,首要考虑的是最小触摸尺寸,如果尺寸太小,视障用户摸不着元素控件,那么语义信息再充足也没用。Material Design 无障碍设计规范指出,能被用户点击或触摸、与用户有互动行为的所有元素,最小尺寸应该为 48 dp。
当 Checkbox 、RadioButton 、Switch 、Slider 和 Surface 等 Material 组件被设置为可接收用户操作时,内部会自动帮我们添加额外的内边距。
如果是其他组件,尺寸设置为非常小而且设置了 clickable 修饰符,虽然内部不会帮我们添加额外的内边距,但实际上它会把元素可点击范围扩大到元素边界外,所以为了避免和邻近的元素的可点击范围重叠,最好不要把可点击元素的大小设置成小于 48 dp。
kotlin
@Composable
private fun SmallBox() {
var clicked by remember { mutableStateOf(false) }
Box(
Modifier
.size(100.dp)
.background(if (clicked) Color.DarkGray else Color.LightGray)
) {
Box(
Modifier
.align(Alignment.Center)
.clickable { clicked = !clicked }
.background(Color.Black)
.size(1.dp)
)
}
}
由下图可以看到,虽然 Box 只有 1 px 大小,但它边界外的区域同样可以触发点击
如果想把一个可点击元素的大小设置成尽可能小,那么最好使用 sizeIn()
修饰符设置最小尺寸:
kotlin
Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
2.设置点击标签
如果有一个文字按钮如下,Text 就是这个按钮点击动作的具体描述,因为语义合并,TalkBack 发音时会读出"确认,按钮,点按两次即可激活",对点击动作的描述是精准的。
kotlin
Button(onClick = { /* TODO */}) {
Text(text = "确认")
}
可如果有这么一个文章列表项(点击打开文章详情页),TalkBack 只会读出文章名+作者+"点按即可激活"。"点按即可激活"对于点击动作的描述是模糊的,如果能自定义"激活"这个词就好了:"点按两次即可(____)"
其实只要设置点击标签 onClickLabel
就可以做到:
kotlin
// 方式1:
Modifier.clickable(onClickLabel = "打开文章") { ... }
// 方式2:
Modifier.semantics {
onClick(label = "打开文章", action = { ... })
}
3.描述视觉元素
使用 Image 或 Icon 组件时,Android 框架不知道组件的具体含义,需要我们手动传递视觉元素的文字性说明,也就是 contentDescription
。当然如果视觉元素仅起到修饰作用,可以将 contentDescription
设置为 null。
kotlin
IconButton(onClick = { /* TODO */}) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.label_share)
)
}
4.添加自定义操作
依然以上面的文章列表项为例,但这次在尾部添加一个"稍后再读"按钮:
kotlin
@Composable
fun ArticleItem(...) {
Row {
...
IconButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Default.BookmarkAdd, contentDescription = "添加到稍后再读")
}
}
}
这样写在我们看来并没有任何问题,但是呢,对于一个视障人士来说,他可能用了 app 好几天,每次都恰恰好没摸中"稍后阅读"按钮...或者更绝的一种情况是,这个"稍后再读"的操作没有按钮,而是通过左滑、长按等操作触发,那视障人士怎么办呢?
这时候就得添加无障碍自定义操作了,我们可以这么写:
kotlin
@Composable
fun ArticleItem(...) {
Row(
modifier = modifier
.clickable(onClickLabel = "打开文章") { /* TODO */ }
.semantics {
// 添加自定义无障碍操作
customActions = listOf(
CustomAccessibilityAction("添加到稍后再读", { /* TODO */ })
)
}
...
) {
...
}
}
添加自定义无障碍操作后,视障用户可以三指点击打开 TalkBack 菜单,进而选择触发无障碍操作。
5.设置语义角色,描述元素状态
假设我们有一个个人卡片组件,如下:
虽然关注按钮是一个 Button,但站在语义的角度看,它的角色更像一个 Switch,拥有" 已关注"和"未关注"两种状态。非常像 Switch 组件对吧:
怎么让 TalkBack 像 Switch 组件一样为我们的关注按钮发音呢?
我们可以使用公共语义属性 role 和 stateDescription 为元素设置分别设置语义角色和状态描述:
kotlin
// 关注按钮
Button(
modifier = Modifier
...
.clearAndSetSemantics {
role = Role.Switch // 📌 设置元素语义角色
stateDescription = if (following) "已关注" else "未关注" + username // 📌 描述元素状态
onClick(label = if (following) "取消关注" else "关注") { ... }
}
) { /* content */ }
写到这里本文的篇幅已经够长了,我们不可能通过一篇文章就把 Semantics Modifier 方方面面全部都讲完,毕竟这是一个大话题,但关于它的一些核心知识,以及它在无障碍服务中的基本使用方式,相信你一定已经掌握了。恭喜你,又学会了一项新技能,能够为 app 带来更友好的无障碍体验。
正如文章一开始所讲,国内大多数开发者对无障碍服务与测试这两个话题并不感兴趣,如果你是少数派,可以参考下面的资料继续深入学习。
参考: