☰
【Jetpack Compose娓娓道来】 第20课:可访问性(Accessibility)——让每个人都能用你的App
2026/9/26 4:05:51 网站建设 项目流程

一、先讲一个真实的故事

我见过一个团队,花了两周精心打磨了一个电商应用。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
自定义组件没有RoleTalkBack不知道这是什么加.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走一遍。闭上眼睛,只用耳朵听,看看能不能完成核心操作。你会发现很多平时注意不到的问题。然后按这一课的方法修复它们。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询