Jetpack Compose Semantics Modifier

SemanticsModifier

SemanticsModifier 有什么用?

Semantics [sə'mæntɪks]: 语义学。

SemanticsModifier,语义修饰符... 什么玩意?老实说,对于国内大多数 Android 开发者,一开始都比较难理解语义修饰符,它能够为元素提供一些额外的信息,主要用于无障碍服务 Accessibility 和测试 Testing,而这两项技术在国内并不怎么受待见。

Compose 会在组合阶段执行 Composable 函数生成一颗 UI 树🌳,用来描述界面。

实际上除了这颗 UI 树,Compose 中还有另外一颗结构类似的树,叫做语义树,每个节点都是一个 SemanticsNode。语义树完全不参与绘制和渲染工作,因此是完全不可见的,它只为 Accessibility 和 Testing 服务。

  • Accessibility 需要根据语义树的节点内容进行发音:

    Android 系统中有一个无障碍服务功能叫 TalkBack,它可以帮助视力障碍人士通过触摸 + 语音反馈的方式来与手机进行交互。开启 TalkBack 后,手机会读出用户触摸到的界面元素信息,这些信息就是来自语义树,用户可以按照语音提示进一步操作。

  • Testing 需要根据语义树找到想要测试的节点来执行测试逻辑:

    将测试和无障碍服务放在一起讨论很容易让人感到奇怪,直觉上我们会认为它俩毫无关系。不过实际上它们是存在一些共同点的:

    1. 需要找到某个控件元素,在 Compose 中界面元素是没有 id 的,因此需要通过语义树来定位;
    2. 需要对控件元素进行操作,比如点击、滑动等等;
    3. 需要访问控件元素的 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 treemerged 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。

CheckboxRadioButtonSwitchSliderSurface 等 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 组件一样为我们的关注按钮发音呢?

我们可以使用公共语义属性 rolestateDescription 为元素设置分别设置语义角色和状态描述:

kotlin 复制代码
// 关注按钮
Button(
    modifier = Modifier
    ...
    .clearAndSetSemantics {
        role = Role.Switch // 📌 设置元素语义角色
        stateDescription = if (following) "已关注" else "未关注" + username // 📌 描述元素状态
        onClick(label = if (following) "取消关注" else "关注") { ... }
    }
) { /* content */ }

写到这里本文的篇幅已经够长了,我们不可能通过一篇文章就把 Semantics Modifier 方方面面全部都讲完,毕竟这是一个大话题,但关于它的一些核心知识,以及它在无障碍服务中的基本使用方式,相信你一定已经掌握了。恭喜你,又学会了一项新技能,能够为 app 带来更友好的无障碍体验。

正如文章一开始所讲,国内大多数开发者对无障碍服务与测试这两个话题并不感兴趣,如果你是少数派,可以参考下面的资料继续深入学习。


参考:

[官方文档] Compose 中的语义

[Youtube] The Semantics of Jetpack Compose

写给初学者的Jetpack Compose教程,Modifier by 郭霖

相关推荐
数据猎手小k1 小时前
AndroidLab:一个系统化的Android代理框架,包含操作环境和可复现的基准测试,支持大型语言模型和多模态模型。
android·人工智能·机器学习·语言模型
你的小101 小时前
JavaWeb项目-----博客系统
android
风和先行2 小时前
adb 命令查看设备存储占用情况
android·adb
AaVictory.3 小时前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
似霰4 小时前
安卓智能指针sp、wp、RefBase浅析
android·c++·binder
大风起兮云飞扬丶4 小时前
Android——网络请求
android
干一行,爱一行4 小时前
android camera data -> surface 显示
android
断墨先生4 小时前
uniapp—android原生插件开发(3Android真机调试)
android·uni-app
无极程序员6 小时前
PHP常量
android·ide·android studio
萌面小侠Plus7 小时前
Android笔记(三十三):封装设备性能级别判断工具——低端机还是高端机
android·性能优化·kotlin·工具类·低端机