章节 4:语义属性 — contentDescription、role、stateDescription
各位同学,上一章我们聊了语义树的基本结构。今天咱们深入一下——语义属性到底怎么用,才不让无障碍测试变成摆设?
说白了,语义属性就是给屏幕阅读器听的「旁白」。没有它,你的Button就是个普通矩形,用户摸到的时候只听到“嘟”一声,完全不知道能干嘛。
4.1 contentDescription — 最常用的“图片翻译官”
contentDescription,我个人习惯叫它「CD」,是这个属性里出现频率最高的。为啥?因为很多非文本控件(Image、Icon、FloatingActionButton)默认没有文字,全靠CD告诉读屏软件“这是个啥”。
// 一个纯图标按钮
IconButton(onClick = { /* 分享 */ }) {
Icon(Icons.Default.Share, contentDescription = "分享")
}
// 断言测试
composeTestRule.onNodeWithContentDescription("分享").assertExists()
注意,不要永远写死字符串。我在项目中遇到过这样的坑:同事把contentDescription写成了“分享按钮”,结果product说按钮文字应该是“转发”,一改文案,测试挂了。
4.2 role — 告诉读屏软件“我是哪种控件”
role解决的是“语义类型”问题。一个Text点击事件和一个Button点击事件,在语义树上完全不同。就算你给Text加了个clickable修饰符,它的role仍然是Text,不是Button。
为什么会这样?因为Compose默认根据组件类型推断role。但有时候你需要覆盖——比如自定义一个“卡片”当作按钮用。
Card(
modifier = Modifier
.clickable { /* 点击跳转 */ }
.semantics {
role = Role.Button // 显式指定为Button
}
) {
// ...
}
断言这样写:
composeTestRule.onNode(hasRole(Role.Button)).assertExists()
| 常用role | 对应组件 | 断言匹配器 |
|---|---|---|
| Role.Button | Button, IconButton, 自定义可点击元素 | hasRole(Role.Button) |
| Role.Checkbox | Checkbox | hasRole(Role.Checkbox) |
| Role.Switch | Switch | hasRole(Role.Switch) |
| Role.Image | Image, Icon | hasRole(Role.Image) |
| Role.Tab | Tab里面的元素 | hasRole(Role.Tab) |
4.3 stateDescription — 说清楚“当前状态”
stateDescription是我觉得最容易被忽略的属性。它描述的是控件的动态状态。比如一个Switch是“已开启”还是“已关闭”,一个ExpandableList是“已展开”还是“已折叠”。
var isChecked by remember { mutableStateOf(false) }
Switch(
checked = isChecked,
onCheckedChange = { isChecked = it },
modifier = Modifier.semantics {
stateDescription = if (isChecked) "已开启" else "已关闭"
}
)
你可能会想:Switch自己不是有checked状态吗?为什么还要手动设stateDescription?
嗯,这里要注意:Compose默认的Switch语义只带一个checked布尔值,但部分读屏软件需要更详细的文字描述。手动设置可以精确控制读屏输出的内容。
断言里怎么写?
composeTestRule
.onNodeWithContentDescription("通知开关")
.assert(hasStateDescription("已关闭"))
4.4 其它值得关注的语义属性
- selected — 用于Tab、BottomNavigationItem等,表示选中态。断言可用
isSelected()。 - focused — 当前焦点状态,常用于表单验证。断言
isFocused()。 - progressBarValue / progressBarRange — 进度条的值信息。断言用
hasProgressBarRangeInfo()。
composeTestRule.onRoot().printToLog()。它会打印出整棵树的语义属性,一眼就能看到contentDescription、role、stateDescription是否都正确。
4.5 综合案例:一个带状态切换的可访问图片按钮
咱们写个真实点的场景:一个收藏按钮,未收藏时是空心爱心图标,点击后变成实心,同时读屏要说出“收藏”/“已收藏”。
@Composable
fun FavoriteButton(isFavorite: Boolean, onToggle: () -> Unit) {
val (desc, state) = if (isFavorite) "已收藏" to "已收藏" else "未收藏" to ""
IconButton(
onClick = onToggle,
modifier = Modifier.semantics {
contentDescription = "收藏"
stateDescription = state
role = Role.Button
}
) {
Icon(
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null // 由父层语义提供,避免重复朗读
)
}
}
断言测试:
// 初始状态:未收藏
composeTestRule.onNode(hasContentDescription("收藏"))
.assert(hasStateDescription("")) // 没有额外状态描述
.assert(hasRole(Role.Button))
// 点击后
composeTestRule.onNode(hasContentDescription("收藏"))
.assert(hasStateDescription("已收藏"))
你看,三个属性配合起来,读屏就能完整说出“收藏按钮,已收藏”。这一切都映射到断言里。
小结
今天这三兄弟 — contentDescription、role、stateDescription — 覆盖了「这是什么」「它是哪种控件」「它当前怎么了」三个核心维度。我自己的项目里,90%的语义树断言都围绕着它们展开。
别小看这些属性。写过几年Compose测试的开发者都知道:语义树对了,无障碍就对了;语义树错了,所有断言都是自欺欺人。