一、先讲一个真实的故事
我见过一个团队,花了两周精心打磨了一个电商应用。UI漂亮,动画丝滑,性能优秀。上线前,他们做了一次无障碍测试。
测试员是一位视障用户,他用TalkBack打开了这个应用。首页加载出来,他滑动手指,TalkBack读出来的第一句话是:
“按钮。按钮。按钮。按钮。”
四个按钮,没有任何标签。视障用户完全不知道这四个按钮是干什么的。他只能一个一个双击去试,靠“猜”来导航。测试员说了一句话,让整个团队沉默了:
“你们的应用,对我来说,等于不存在。”
这不是个例。全球有超过10亿人存在某种形式的残疾。视力障碍用户依赖TalkBack,运动障碍用户可能用开关控制或语音控制,认知障碍用户需要清晰的层次结构。
更现实地说,可访问性不只是道德问题,也是法律问题。很多国家和地区都有法规要求数字产品必须满足无障碍标准。忽视可访问性,可能意味着法律风险、用户流失、以及应用商店的差评。
好消息是,Compose在可访问性方面做得相当不错。它的声明式模型和语义树设计,让构建可访问的界面比传统View体系容易得多。一项2026年的研究甚至发现,用Jetpack Compose构建的界面持续比其他布局方案产生更好的可访问性表现。
这一课,我们就来把Compose的可访问性彻底讲清楚。
二、好消息:Compose默认做了很多事
2.1 基础组件自带语义
当你使用Text、Button、Checkbox、Switch这些基础组件时,Compose自动为它们设置了正确的语义属性。
| 组件 | 自动语义 |
|---|---|
Text | 文本内容 |
Button | 按钮角色 + 点击操作 |
Checkbox | 复选框角色 + 选中状态 + 切换操作 |
Switch | 开关角色 + 切换状态 + 切换操作 |
Slider | 滑块角色 + 当前值 + 调节操作 |
IconButton | 按钮角色 + 点击操作 |
用Modifier.clickable添加点击交互时,它自带点击操作语义。用Modifier.toggleable添加切换交互时,它自带切换状态和角色语义。
这意味着,如果你只用标准组件构建界面,大部分可访问性工作Compose已经帮你做了。
2.2 但默认行为有边界
默认行为只覆盖“标准用法”。一旦你开始做以下事情,就需要手动处理可访问性:
- 自定义可组合项(用
Box + clickable模拟按钮) - 图标按钮(
Icon需要contentDescription) - 复杂的组合组件(多个子元素需要被当作一个整体朗读)
- 不直观的交互(用
IconButton做收藏,需要告诉用户“收藏”而不是“图标”) - 自定义手势(滑动删除、长按菜单等)
在这些场景下,Compose需要你显式地提供语义信息。
三、语义树:可访问性的基石
3.1 什么是语义树
Compose在渲染UI的同时,会构建一棵语义树。这棵树描述了UI的含义,而不是它的视觉结构。每个节点包含:
- Text:文字内容
- ContentDescription:内容描述
- Role:角色(按钮、复选框、图像等)
- StateDescription:状态描述(如“开启”、“已选中”)
- Actions:可执行的操作(点击、滚动等)
- CustomActions:开发者定义的自定义操作
无障碍服务(如TalkBack)读取的就是这棵语义树,而不是视觉树。所以,你给语义树提供了什么信息,视障用户就“听”到什么。
3.2 一个例子:Switch的语义
考虑一个Switch组件。用户视觉上看到的是一个开关,但语义树里包含的信息是:
- Role:
Role.Switch,告诉无障碍服务这是一个开关 - StateDescription:描述当前状态(“开启”或“关闭”)
- ToggleableState:当前切换状态
- OnClick:点击交互方法
TalkBack读到这个节点时,会播报:“开启;开关;点按两次即可切换”。用户双击屏幕即可切换状态。
这就是语义的力量:你不需要告诉TalkBack“怎么读”,你只需要提供正确的语义属性,TalkBack自己决定怎么呈现。
3.3 语义树的合并
默认情况下,Compose会合并子节点的语义到父节点。比如一个Button内部的Text,它的文字会被合并到Button节点上。所以TalkBack读的是“提交”,而不是分别读“按钮”和“提交”。
这种合并是合理的,因为用户希望把按钮当作一个整体来交互,而不是分别操作里面的文字和背景。
四、用语义属性描述组件
4.1 使用Modifier.semantics
Box(modifier=Modifier.size(48.dp).background(Color.Blue,CircleShape).clickable{addToFavorites()}.semantics{role=Role.Button contentDescription="添加到收藏"})这个Box视觉上是一个蓝色圆形,但语义上它是一个按钮,描述是“添加到收藏”。TalkBack会读:“添加到收藏;按钮;点按两次即可执行”。
4.2 Role:告诉无障碍服务这是什么
Role属性尤其重要,因为它提供了无障碍服务解读组件所需的上下文。官方文档明确指出,通过设置正确的role,可以确保屏幕阅读器把组件播报为交互式元素,而不是静态图片。
| Role值 | 用途 |
|---|---|
Role.Button | 按钮 |
Role.Checkbox | 复选框 |
Role.Switch | 开关 |
Role.RadioButton | 单选按钮 |
Role.Image | 图像 |
Role.Tab | 标签页 |
Role.DropdownList | 下拉列表 |
4.3 contentDescription:给非文本元素一个“名字”
Icon、Image这类没有文字的元素,必须通过contentDescription告诉无障碍服务它的含义:
Icon(imageVector=Icons.Default.Delete,contentDescription="删除这篇文章")如果图标纯粹是装饰性的,传null:
Icon(imageVector=Icons.Default.Star,contentDescription=null// 装饰性图标,不需要朗读)4.4 stateDescription:描述状态
当组件的状态需要被朗读时,用stateDescription:
Text(text=if(isOnline)"在线"else"离线",modifier=Modifier.semantics{stateDescription=if(isOnline)"在线"else"离线"})五、合并与清除:控制语义的粒度
5.1 合并:把一组元素当作一个整体
当一个组件由多个子元素组成,但逻辑上应该被当作一个整体时,用mergeDescendants = true:
Row(modifier=Modifier.semantics(mergeDescendants=true){}){Image(painter=painterResource(R.drawable.avatar),contentDescription=null// 装饰性)Column{Text("张三")Text("Android开发者")}}无障碍服务会一次聚焦整个容器,并合并子元素的内容。TalkBack读:“张三,Android开发者”。
重要提示:当clickable修饰符应用在父组件上时,Compose会自动合并其下的所有子元素。所以如果你有一个可点击的列表项,它的子元素会自动被合并。
5.2 清除:移除不必要的语义
有时候,某些元素不应该出现在语义树中。用clearAndSetSemantics清除并设置新的语义:
Box(modifier=Modifier.clickable{}.clearAndSetSemantics{contentDescription="自定义卡片"role=Role.Button}){// 内部复杂的子元素,语义被清除Text("标题")Text("副标题")Icon(Icons.Default.Star,contentDescription=null)}整个Box被当作一个节点,内部子元素的语义被清除。TalkBack只会读:“自定义卡片;按钮”。
什么时候用清除:当你把一个复杂组件封装成一个语义单元时,内部的细节不应该被单独朗读。
5.3 合并与清除的选择
| 场景 | 方案 |
|---|---|
| 卡片有标题+副标题,点击整体跳转 | mergeDescendants = true |
| 卡片内部有独立按钮(收藏、分享) | 不合并,让按钮独立聚焦 |
| 自定义复杂组件,内部细节不需要朗读 | clearAndSetSemantics |
| 图标是装饰性的 | contentDescription = null |
六、控制遍历顺序
6.1 默认遍历顺序的问题
Compose按照代码中的声明顺序来确定无障碍服务的遍历顺序。大多数情况下这没问题。但有时候,视觉布局和逻辑顺序不一致。
比如一个底部导航栏,视觉上在屏幕底部,但逻辑上应该最后被朗读。如果代码里导航栏写在前面,TalkBack会先读导航栏,再读主要内容,体验很怪。
6.2 isTraversalGroup和traversalIndex
Compose提供了两个语义属性来调整遍历顺序:
isTraversalGroup:把一组元素标记为一个遍历组。无障碍服务会把这个组当作一个整体来遍历。
traversalIndex:调整组内元素的遍历顺序。值越小越先被遍历。
Column{// 主要内容,应该先被朗读Column(modifier=Modifier.semantics{isTraversalGroup=truetraversalIndex=0f}){Text("主要内容")}// 底部导航,应该最后被朗读Column(modifier=Modifier.semantics{isTraversalGroup=truetraversalIndex=1f}){Text("导航栏")}}traversalIndex = -1f可以强制顶部栏最先被遍历,traversalIndex = 1f让底部栏最后被遍历。
七、自定义无障碍操作
7.1 什么时候需要自定义操作
有些交互无法通过简单的点击表达。比如一个列表项,点击是打开文章,但还需要“添加到书签”的操作。如果只能通过长按来触发,视障用户可能不知道有这个功能。
7.2 CustomAccessibilityAction
用customActions添加自定义无障碍操作:
Row(modifier=Modifier.clickable{openArticle()}.semantics{customActions=listOf(CustomAccessibilityAction(label="添加到书签",action={addToBookmarks()true}),CustomAccessibilityAction(label="分享",action={shareArticle()true}))}){Text("文章标题")}TalkBack用户聚焦到这个节点时,会听到“文章标题;按钮”,然后可以通过TalkBack的菜单访问“添加到书签”和“分享”这两个自定义操作。
CustomAccessibilityAction的label是操作的描述,action是执行操作的lambda,返回Boolean表示是否成功处理。
八、支持可缩放内容
8.1 字体缩放
Android系统允许用户调整字体大小。Compose默认支持字体缩放——sp单位会跟随系统设置缩放。
不要用dp来设置字体大小,因为dp不跟随字体缩放设置。用sp:
Text("标题",fontSize=24.sp)// 正确:跟随字体缩放Text("标题",fontSize=24.dp)// 错误:不跟随字体缩放8.2 界面元素缩放
用户不仅需要放大文字,有时还需要放大整个界面元素。Compose提供了Modifier.transformable来支持手势缩放。
对于可访问性,缩放范围通常建议在0.75倍到3.5倍之间。
varscalebyremember{mutableFloatStateOf(1f)}Box(modifier=Modifier.graphicsLayer{scaleX=scale scaleY=scale}.transformable(state=rememberTransformableState{zoomChange,_,_->scale=(scale*zoomChange).coerceIn(0.75f,3.5f)})){// 内容}九、测试与调试可访问性
9.1 启用可访问性检查
从Compose 1.8.0开始,可以在测试中启用自动可访问性检查:
// 添加依赖androidTestImplementation("androidx.compose.ui:ui-test-junit4-accessibility")// 启用检查composeTestRule.enableAccessibilityChecks()// 执行操作时,检查会自动运行composeTestRule.onNodeWithTag("button").performClick()// 也可以手动触发检查composeTestRule.onNodeWithTag("container").tryPerformAccessibilityChecks()这些检查会检测常见问题:缺失的contentDescription、过小的触摸目标、不正确的角色等。
注意:可访问性检查需要API 34+,目前不支持Robolectric。
9.2 用Layout Inspector检查语义
Android Studio的Layout Inspector可以查看Compose的语义树。运行App → Tools → Layout Inspector → 选择Compose节点 → 查看Semantics面板。你能看到每个节点的语义属性:Text、ContentDescription、Role、Actions等。
9.3 TalkBack手动测试
自动化检查不能覆盖所有情况。TalkBack手动测试是无可替代的。开启TalkBack(设置 → 无障碍 → TalkBack),然后用手指滑动遍历界面,听TalkBack读了什么。
常见问题:
- 读出来是“按钮”,但不知道是哪个按钮(缺少contentDescription)
- 重要的图标完全不读(缺少contentDescription)
- 一个简单的操作要滑动很多次才能完成(语义粒度过细)
- 复杂的卡片一次性读了一大段,用户无法分别操作(语义粒度过粗)
9.4 TreeDebug
TalkBack开发者设置中有一个TreeDebug选项,可以打印语义树的调试信息。这对于理解TalkBack“看到”了什么非常有用。
9.5 printToLog
测试中可以用printToLog打印语义树:
composeTestRule.onRoot().printToLog("SemanticsTree")十、Compose Multiplatform的可访问性
10.1 iOS可访问性
Compose Multiplatform在iOS上支持VoiceOver屏幕阅读器。语义属性会自动映射到iOS原生可访问性属性。比如testTag会映射到accessibilityIdentifier。
iOS上的可访问性支持在1.8.0版本中已经稳定,支持VoiceOver、AssistiveTouch和完整键盘访问。
10.2 桌面可访问性
桌面端的可访问性支持情况:
- macOS:完全支持
- Windows:通过Java Access Bridge支持,但默认是禁用的
- Linux:不支持
10.3 Web可访问性
Web端的可访问性支持还在早期阶段。Compose Web渲染的DOM结构对基于命中测试的可访问性工具可能不可见。VoiceOver通过顺序遍历DOM可以工作,但其他工具可能有问题。
十一、常见陷阱速查
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
| 图标没有contentDescription | 视障用户不知道图标是什么 | 加上描述,装饰性图标传null |
| 触摸目标太小 | 难以点击 | 用IconButton保证至少48dp |
| 自定义组件没有Role | TalkBack不知道这是什么 | 加.semantics { role = Role.Button } |
| 合并不足 | 卡片里每个元素都要单独聚焦 | mergeDescendants = true |
| 合并过度 | 用户无法分别操作内部按钮 | 不合并,让按钮独立聚焦 |
| 用dp设置字体大小 | 不跟随系统字体缩放 | 用sp |
| 忘记处理键盘导航 | 键盘用户无法操作 | 确保交互元素可聚焦 |
| 遍历顺序混乱 | TalkBack朗读顺序不自然 | 用isTraversalGroup+traversalIndex |
| 自定义手势没有替代操作 | 视障用户无法触发 | 用CustomAccessibilityAction |
| 只依赖自动化检查 | 漏掉体验问题 | TalkBack手动测试 |
十二、综合实战:让待办事项应用可访问
12.1 待办事项列表项
@ComposablefunTodoItem(todo:Todo,onToggle:()->Unit,onDelete:()->Unit){Row(modifier=Modifier.fillMaxWidth().semantics(mergeDescendants=true){}.clickable(onClick=onToggle).padding(16.dp),verticalAlignment=Alignment.CenterVertically){Checkbox(checked=todo.isDone,onCheckedChange={onToggle()},modifier=Modifier.semantics{contentDescription=if(todo.isDone){"已完成:${todo.text}"}else{"未完成:${todo.text}"}})Text(text=todo.text,modifier=Modifier.weight(1f).padding(horizontal=12.dp),textDecoration=if(todo.isDone){TextDecoration.LineThrough}else{TextDecoration.None})IconButton(onClick=onDelete,modifier=Modifier.semantics{customActions=listOf(CustomAccessibilityAction(label="删除${todo.text}",action={onDelete();true}))}){Icon(Icons.Default.Delete,contentDescription="删除")}}}做了什么:
mergeDescendants = true:把整个列表项当作一个整体朗读,TalkBack读“未完成:买牛奶;复选框;点按两次即可切换”。- Checkbox的
contentDescription:明确告诉用户状态和内容。 - IconButton的
customActions:提供删除操作,视障用户可以通过TalkBack菜单访问。
12.2 遍历顺序调整
@ComposablefunTodoScreen(){Column{// 主要内容区域,应该先被遍历Column(modifier=Modifier.weight(1f).semantics{isTraversalGroup=truetraversalIndex=0f}){TodoList()}// 底部添加按钮,应该最后被遍历Row(modifier=Modifier.fillMaxWidth().padding(16.dp).semantics{isTraversalGroup=truetraversalIndex=1f}){OutlinedTextField(value=inputText,onValueChange={},modifier=Modifier.weight(1f).semantics{contentDescription="输入新的待办事项"})Button(onClick={}){Text("添加")}}}}十三、可访问性的思维模型
最后,用一张“思维模型”来总结这一课:
第一层:默认行为。标准组件自带语义,但自定义组件和图标需要手动处理。
第二层:语义树。无障碍服务读取的是语义树,不是视觉树。你提供什么语义,用户就“听”到什么。
第三层:核心属性。Role告诉无障碍服务组件类型,contentDescription给非文本元素一个名字,stateDescription描述状态。
第四层:粒度控制。mergeDescendants把一组元素当作整体,clearAndSetSemantics清除不必要的语义。
第五层:遍历与操作。isTraversalGroup和traversalIndex控制遍历顺序,CustomAccessibilityAction提供自定义操作。
第六层:测试。自动化检查 + Layout Inspector + TalkBack手动测试,三者缺一不可。
贯穿始终的原则:可访问性不是“额外功能”,而是“基本质量”。一个对所有人都友好的App,才是真正专业的App。
十四、小结与下一课预告
这一课我们搞定了Compose的可访问性。关键点回顾:
- Compose默认行为:标准组件自带语义,但自定义组件和图标需要手动处理。
- 语义树:无障碍服务读取的是语义树,不是视觉树。语义属性包括Text、ContentDescription、Role、StateDescription、Actions、CustomActions。
- Role:告诉无障碍服务组件是什么类型(按钮、复选框、图像等)。
- contentDescription:给非文本元素一个“名字”。装饰性元素用
null。 - 合并与清除:用
mergeDescendants = true把一组元素当作整体,用clearAndSetSemantics清除不必要的语义。 - 遍历顺序:用
isTraversalGroup和traversalIndex调整无障碍服务的遍历顺序。 - 自定义操作:用
CustomAccessibilityAction为复杂交互提供无障碍入口。 - 可缩放内容:字体用
sp,界面元素支持手势缩放(0.75x到3.5x)。 - 测试与调试:
enableAccessibilityChecks()自动检查,Layout Inspector查看语义树,TalkBack手动测试无可替代。 - CMP可访问性:iOS支持VoiceOver,桌面macOS完全支持,Web还在早期阶段。
- 常见陷阱:图标缺contentDescription、触摸目标小于48dp、自定义组件缺Role、过度合并、用dp设字体。
可访问性不是“额外功能”,而是“基本质量”。一个对所有人都友好的App,才是真正专业的App。
下一课,我们会讲国际化与本地化。一个App要走向世界,必须支持多种语言、多种地区格式、多种书写方向。Compose提供了stringResource、LayoutDirection、DateTimeFormatter等能力,让国际化变得简单。这是让应用从“中文应用”变成“全球应用”的关键一课。
课后练习建议:打开你手机上的TalkBack,用你自己写的App走一遍。闭上眼睛,只用耳朵听,看看能不能完成核心操作。你会发现很多平时注意不到的问题。然后按这一课的方法修复它们。