章节 4:语义属性 — contentDescription、role、stateDescription

各位同学,上一章我们聊了语义树的基本结构。今天咱们深入一下——语义属性到底怎么用,才不让无障碍测试变成摆设?

说白了,语义属性就是给屏幕阅读器听的「旁白」。没有它,你的Button就是个普通矩形,用户摸到的时候只听到“嘟”一声,完全不知道能干嘛。

核心观点:语义属性是断言测试的「最小可测单元」。测试框架实际就是在检查这些属性对不对。
语义属性断言检查流程 被测Compose节点 contentDescription 文本/图标可访问性 role 控件类型 (Button/Checkbox等) stateDescription 动态状态 (选中/展开/禁用) assert(…).hasContentDescription() assert(…).hasRole(Role.Button) assert(…).hasStateDescription(“已选中”) 所有断言通过? 是 → 测试通过 否 → 测试失败 💡 说明 每个语义属性对应一个断言API。测试用例本质上是“属性值匹配” + “是否存在”的检查。

4.1 contentDescription — 最常用的“图片翻译官”

contentDescription,我个人习惯叫它「CD」,是这个属性里出现频率最高的。为啥?因为很多非文本控件(Image、Icon、FloatingActionButton)默认没有文字,全靠CD告诉读屏软件“这是个啥”。

// 一个纯图标按钮
IconButton(onClick = { /* 分享 */ }) {
    Icon(Icons.Default.Share, contentDescription = "分享")
}

// 断言测试
composeTestRule.onNodeWithContentDescription("分享").assertExists()

注意,不要永远写死字符串。我在项目中遇到过这样的坑:同事把contentDescription写成了“分享按钮”,结果product说按钮文字应该是“转发”,一改文案,测试挂了。

我的经验:把contentDescription抽成常量或string resource,测试里引用同一个资源ID。这样文案变更时,IDE和测试都能同步更新。

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.ButtonButton, IconButton, 自定义可点击元素hasRole(Role.Button)
Role.CheckboxCheckboxhasRole(Role.Checkbox)
Role.SwitchSwitchhasRole(Role.Switch)
Role.ImageImage, IconhasRole(Role.Image)
Role.TabTab里面的元素hasRole(Role.Tab)
避坑:千万不要给文本元素set role = Role.Button只是为了让它可点击。这会让读屏软件把段落当成按钮,用户体验极差。role要匹配真实的交互意图。

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测试的开发者都知道:语义树对了,无障碍就对了;语义树错了,所有断言都是自欺欺人。