系列社会责任篇·第32篇。前几篇讲了性能和数据,今天聊点不一样的。有视障开发者留言:“鸿蒙系统本身的TalkBack很好用,但你们的电商Demo读屏时只念‘按钮’,我根本不知道是‘加入购物车’还是‘收藏’。能不能讲讲如何让应用更无障碍?” 这让我意识到,技术不仅有商业价值,更有社会价值。今天我们将电商Demo进行无障碍(Accessibility)改造,遵循WCAG 2.1 AA标准,让视障用户能通过听觉和触觉顺利购物。我们将解决组件语义缺失、焦点逻辑混乱、动态内容播报三大无障碍痛点。全程基于API23,含官方文档未提及的“无障碍最佳实践清单”。
一、前言:为什么无障碍不是“可选项”?
在很多开发者眼里,无障碍(Accessibility)是“锦上添花”,甚至是负担。但在鸿蒙生态中,这是必选项:
法规要求:《信息技术 互联网内容无障碍可访问性技术要求》等法规明确要求App应具备无障碍功能。
用户基数:中国有1700多万视障人士,他们是潜在的巨大用户群。
系统评分:华为应用市场对无障碍的支持程度有明确的检测和评分,直接影响搜索排名和上架审核。
鸿蒙优势:鸿蒙的无障碍套件(Accessibility Kit)提供了比Android更统一的API,且系统级的屏幕阅读器(TalkBack)体验极佳。
核心原则:无障碍不是给盲人专门开发一个版本,而是让同一套UI通过不同的感官(听觉、触觉)被感知。我们要做的,是给UI组件“贴标签”和“指路”。
二、核心概念辨析(无障碍三要素)
要让视障用户顺畅使用,必须理解以下三个核心概念:
语义(Semantics):组件是什么?(是按钮、图片还是文本框?)
焦点(Focus):用户当前在哪里?(焦点框在哪里?如何移动?)
播报(Announcement):组件当前的状态是什么?(已选中、已展开、加载中?)
错误做法 | 正确做法 |
|---|---|
用Image组件画按钮,不设置无障碍标签 | 用Button组件,或给Image设置 |
焦点顺序混乱,从上到下跳到左边 | 焦点顺序符合视觉流(左->右,上->下) |
动态加载商品后不通知用户 | 使用 |
三、代码实现:电商Demo的无障碍改造
3.1 基础语义标注(给组件“上户口”)
视障用户依赖读屏软件(如TalkBack)了解界面。如果组件没有语义,读屏软件就不知道该说什么。
修改GoodsItem.ets:
@Component struct GoodsItem { @Prop goods: GoodsBean @Prop isFavorite: boolean = false build() { Row() { // 商品图片:必须设置无障碍标签和角色 Image(this.goods.image) .width(100) .height(100) .borderRadius(8) .accessibilityGroup(true) // 标记为无障碍节点 .accessibilityLabel(`商品图片:${this.goods.name}`) // 关键!描述图片内容 .accessibilityRole(AccessibilityRole.IMAGE) // 声明角色为图片 Column() { // 商品名称:默认Text组件会被朗读,但需确保不被截断 Text(this.goods.name) .fontSize(16) .maxLines(2) .accessibilityGroup(true) .accessibilityLiveRegion(AccessibilityLiveRegion.POLITE) // 内容变化时礼貌播报 // 商品价格 Text(`¥${this.goods.price}`) .fontSize(18) .fontColor('#FF0000') .accessibilityLabel(`价格:${this.goods.price}元`) // 收藏按钮:状态很重要! Button() .width(40) .height(40) .backgroundColor(this.isFavorite ? '#FFD700' : '#EEEEEE') .onClick(() => this.toggleFavorite()) .accessibilityGroup(true) .accessibilityLabel(this.isFavorite ? '取消收藏' : '收藏') // 根据状态变化 .accessibilityRole(AccessibilityRole.BUTTON) .accessibilityState({ selected: this.isFavorite }) // 标记选中状态 } .layoutWeight(1) .margin({ left: 12 }) // 加入购物车按钮 Button('加购') .height(40) .backgroundColor('#0A59F7') .onClick(() => this.addToCart()) .accessibilityGroup(true) .accessibilityLabel('加入购物车') .accessibilityRole(AccessibilityRole.BUTTON) .accessibilityHint('双击将商品加入购物车') // 操作提示 } .padding(16) .borderRadius(12) .backgroundColor('#FFFFFF') // 给整个商品卡片设置一个大的无障碍标签 .accessibilityGroup(true) .accessibilityLabel(`商品:${this.goods.name},价格:${this.goods.price}元。双击查看详情`) .accessibilityRole(AccessibilityRole.BUTTON) // 让整个卡片可点击 } toggleFavorite(): void { /* ... */ } addToCart(): void { /* ... */ } }3.2 焦点导航优化(给手指“指路”)
默认的焦点导航是按组件树顺序走的,但有时我们需要自定义顺序,或者处理一些不可聚焦但重要的信息(如“包邮”标签)。
修改ProductDetailPage.ets:
@Entry @Component struct ProductDetailPage { @State description: string = '这是一款...' private scroller: Scroller = new Scroller() build() { Column() { // 顶部导航栏(焦点顺序1) Row() { Button('返回') .accessibilityLabel('返回上一页') .accessibilityOrder(1) // 显式指定焦点顺序 Text('商品详情') .fontSize(20) .layoutWeight(1) .textAlign(TextAlign.Center) .accessibilityOrder(2) } .padding(16) Scroll(this.scroller) { Column() { // 商品主图(焦点顺序3) Image($r('app.media.big_image')) .width('100%') .height(300) .accessibilityLabel('商品主图') .accessibilityOrder(3) // 价格区域(焦点顺序4) Row() { Text('¥5999') .fontSize(24) .fontColor('#FF0000') Text('包邮') .fontSize(12) .backgroundColor('#FFF3E0') .padding(4) .borderRadius(4) .margin({ left: 8 }) // 关键:让“包邮”这个视觉信息被读屏软件捕获 // 虽然它没有背景交互,但信息很重要 .accessibilityGroup(true) .accessibilityLabel('包邮') .accessibilityRole(AccessibilityRole.TEXT) } .margin(16) .accessibilityOrder(4) // 商品描述(焦点顺序5) Text(this.description) .fontSize(14) .margin(16) .accessibilityGroup(true) .accessibilityLabel(`商品描述:${this.description}`) .accessibilityOrder(5) // 长文本支持:允许读屏软件分页朗读 .accessibilityLongClickEnabled(true) // 底部操作栏(焦点顺序6-8) Row() { Button('客服') .accessibilityOrder(6) .accessibilityHint('联系在线客服') Button('收藏') .accessibilityOrder(7) .accessibilityHint('收藏此商品') Button('立即购买') .backgroundColor('#0A59F7') .accessibilityOrder(8) .accessibilityHint('立即购买此商品') } .padding(16) .backgroundColor('#FFFFFF') } } } .width('100%') .height('100%') // 开启无障碍滚动支持:当焦点移动到屏幕外时,自动滚动 .accessibilityScrollable(true, this.scroller) } }3.3 动态内容播报(给耳朵“报信”)
当用户点击“加入购物车”后,商品数量变化了,或者网络加载开始了,视障用户需要知道这些动态变化。我们不能指望他们去盯着屏幕看。
创建entry/src/main/ets/common/AccessibilityHelper.ets:
import { accessibility } from '@kit.AccessibilityKit' /** * 无障碍辅助工具类 */ export class AccessibilityHelper { /** * 主动播报消息(类似Toast,但专为无障碍设计) * @param text 要播报的文本 */ static announce(text: string): void { try { // 使用无障碍公告接口 accessibility.announce({ text: text, queueMode: accessibility.QueueMode.QUEUE_MODE_FLUSH // 打断当前播报,立即播报 }) } catch (err) { console.error('无障碍播报失败:', err) } } /** * 模拟点击无障碍节点(用于自动化测试或引导) * @param elementId 组件的ID */ static performClick(elementId: string): void { const controller = accessibility.getAccessibilityController() const node = controller.getNodeById(elementId) if (node) { node.performAction(accessibility.Action.CLICK) } } } // 在ViewModel或UI中使用 // 当用户点击加购时 async function addToCart(): Promise<void> { await CartAPI.add() // 关键:主动播报结果 AccessibilityHelper.announce('已加入购物车,当前共3件商品') // 更新UI... }四、踩坑记录(官方文档没写的无障碍细节)
accessibilityGroup(true)的滥用:如果一个Column设置了accessibilityGroup(true),它内部的子组件就不再单独接受焦点,而是作为一个整体。这在商品列表中很有用(整行作为一个焦点),但在表单中会造成灾难(用户无法单独操作每个输入框)。ImageView的
alt文本:网络图片加载失败时,读屏软件会读文件名(如0x123.png),毫无意义。必须设置accessibilityLabel,且在加载失败时更新标签为“图片加载失败”。自定义组件的焦点:自定义组件(如
@Component struct MyButton)默认是不可聚焦的。必须在根容器上设置accessibilityGroup(true)和accessibilityRole。accessibilityHint的必要性:Label告诉用户“是什么”,Hint告诉用户“干什么”。例如,一个箭头图标,Label是“返回箭头”,Hint应该是“双击返回上一页”。测试工具:不要只用眼看,要闭上眼睛用TalkBack操作一遍自己的App。你会发现很多逻辑问题,比如焦点被困在某个区域出不来,或者关键信息无法访问。