页面看起来没有任何问题,读屏打开后却变成了另一套产品:顶部三个图标都被念成“按钮”,筛选完成没有提示,右下角的收藏按钮只有 32 vp,手指稍微偏一点就点不中。这不是视觉走查能发现的问题。

这次我把修复过程做成了一个小工程 A11yGate Lab。它不负责替代人工无障碍测试,而是把最容易反复出现的错误提前挡在构建阶段。审计编号固定为 a11y_20261001_14:页面共有 38 个可访问节点,标签覆盖 38/38,最小触控热区 48 vp,语义分组 6 处,动态播报 2/2,最终问题数 0,状态 PASS。17:16 的运行结果和构建报告使用同一份数据。

这篇不是逐项抄无障碍属性。真正要解决的是三个工程问题:组件改版后语义不能跟着丢;小图标视觉尺寸不变,但可点击范围必须合格;异步任务完成时,读屏用户要收到一次、且只收到一次通知。

一、审核反馈里的三个问题,其实来自同一个缺口

最初的商品筛选页已经通过常规功能测试。返回、清除条件和收藏都能点击,加载状态也能正确结束。但无障碍走查给了三个明确问题:图标按钮没有稳定名称;收藏按钮的命中区域不足;“筛选完成,共 26 个结果”只更新了视觉文本,没有触发动态播报。

这些问题不是开发者不知道某个属性,而是项目里没有统一的语义入口。有人直接给 Image 加 onClick,有人用 32×32 的 Button 包图标,还有人把请求状态和 Toast 混在一起。只修当前页面,下次换一套图标或复制一个列表项,同样的问题还会回来。

我先把页面的可访问信息拆成四类:控件名称回答“这是什么”,描述说明“执行后会怎样”,层级决定它是否进入可访问树,分组决定一组子节点该合并朗读还是逐个聚焦。触控尺寸则不属于朗读语义,但必须和组件封装一起处理,否则视觉与交互会继续分叉。

二、图标可以是 24 vp,点击热区不能跟着只有 24 vp

这段代码解决的是“图标按钮在不同页面被重复手写,标签、描述和热区很容易遗漏”。我把可点击图标收进 A11yIconButton,图标仍然是 24 vp,外层统一为 48×48 vp,并强制调用方提供资源化标签。

export interface A11yIconButtonOptions {
  icon: Resource
  label: ResourceStr
  description?: ResourceStr
  onClick: () => void
}

@Builder
export function A11yIconButton(options: A11yIconButtonOptions) {
  Button({ type: ButtonType.Circle }) {
    Image(options.icon)
      .width(24)
      .height(24)
      .accessibilityLevel('no')
  }
  .width(48)
  .height(48)
  .backgroundColor(Color.Transparent)
  .accessibilityText(options.label)
  .accessibilityDescription(options.description ?? '')
  .accessibilityLevel('yes')
  .onClick(options.onClick)
}

这里把内部 Image 设为不单独聚焦,是为了避免读屏先念图片、再念按钮造成重复;真正承担交互语义的是外层 Button。标签使用 ResourceStr,因此随语言资源变化,不在组件中写死中文。48 vp 是命中热区,不代表图标要放大,视觉稿仍然可以保留轻量尺寸。

这个 Builder 只负责基础动作。开关、单选、滑块等有状态控件不能一律套成普通按钮,它们需要向系统暴露当前选中值。正式项目还要为禁用态、加载态和重复点击加约束;如果组件离开页面前仍持有异步回调,业务层应取消任务,不能让已经不可见的按钮继续修改页面状态。

三、读屏顺序不是布局顺序的副产品

页面第二个问题来自商品卡片。视觉上先看到图片,再看到名称、价格、优惠标签和收藏图标;如果每个文本都进入可访问树,一张卡片要横跳六次才能离开。我的处理不是随便把整卡合并,而是按动作边界分组:商品信息作为一组朗读,收藏仍然保留独立按钮。

A11yGate Lab 最终有 6 个语义组,焦点顺序是 1→8。顶部返回和页面标题在前,筛选条件区随后,商品信息与收藏动作成对出现,底部结果摘要最后。装饰图标退出可访问树,但价格、折扣和售罄状态不会因为“减少节点”而被隐藏。

我用“只听不看”的方式重新走了一次流程:从标题进入筛选,修改价格区间,提交,再浏览第一张商品卡。焦点每次移动都能回答当前位置和下一步动作,没有跳到屏幕外的缓存节点。列表滚动复用后,我又返回上一项,确认朗读内容已经随新的 assetId 更新,而不是残留上一张卡片的商品名。这类复用错误在静态截图里完全看不出来。

这里最容易犯的错,是把父容器设为可访问后仍让所有子节点可聚焦。读屏会同时拿到父级拼接文本和子级文本,用户听到重复内容。另一个极端是使用隐藏后代的设置把整个自定义组件吞掉,视觉上的按钮仍可点击,读屏却再也找不到。分组不是节点越少越好,而是一次朗读是否表达一个完整、可操作的意思。

四、把容易量化的问题放进 Hvigor 门禁

人工测试能判断文案是否自然,却不适合每次提交都数热区。下面这段脚本解决的是“组件重构后,缺标签和小热区又悄悄混进主分支”。A11yRuleScanner 扫描 ArkTS 源码的语法树,定位带点击行为的组件,并检查无障碍标签与尺寸链。

export function scanA11yRules(files: SourceFile[]): A11yIssue[] {
  const issues: A11yIssue[] = []
  for (const file of files) {
    walkArkTs(file, node => {
      if (!hasModifier(node, 'onClick')) return

      if (!hasModifier(node, 'accessibilityText')) {
        issues.push(issue(file, node, 'A11Y_LABEL_MISSING'))
      }
      const size = resolveTouchSize(node)
      if (size.width < 48 || size.height < 48) {
        issues.push(issue(file, node, 'A11Y_TOUCH_TARGET', `${size.width}x${size.height}vp`))
      }
      if (hasDecorativeChild(node) && !decorativeChildIsHidden(node)) {
        issues.push(issue(file, node, 'A11Y_DUPLICATE_NODE'))
      }
    })
  }
  return issues
}

扫描器不靠简单字符串搜索。链式属性可能换行,尺寸也可能来自常量;语法树至少能保证节点和修饰符属于同一个组件。resolveTouchSize 只解析项目允许的常量表达式,无法确定的动态值会报 NEEDS_REVIEW,不会武断地判定通过。

它接入 Hvigor 的预提交任务后,最初找出了 3 个问题:两个图标缺少标签,一个热区只有 32×32 vp。修复后报告变成 nodes=38、labels=38/38、minTouch=48vp、groups=6、issues=0,构建状态才允许进入 PASS。这类门禁要控制误报:仅凭源码无法判断文字是否准确,也无法证明真实焦点顺序,所以脚本结果必须和模拟器走查配合。

报告采用稳定的规则编号和源码位置,CI 只对本次新增问题失败,历史问题进入明确的基线清单。基线不是免责清单,它记录负责人和到期时间;否则团队为了先通过构建而批量忽略告警,门禁很快就会失去信用。规则升级时先在只报告模式运行一轮,确认误报可控,再切成阻断模式。

DevEco Studio 中,左侧不是通用示例目录,而是 components / accessibility / audit / model 四条链路;中间打开 A11yIconButton.ets,能看到 48 vp 热区和 accessibilityText;右侧运行 A11yGate Lab;底部 HiLog 打印 audit=a11y_20261001_14、labels=38/38、announcements=2/2、issues=0 和 focusOrder=1->8。代码、运行页面和门禁报告使用同一组标识,避免出现截图好看但无法复现的问题。

五、动态播报要去重,也要跟页面生命周期绑定

静态标签修好以后,筛选请求仍有一个隐蔽问题:加载结束只改变了结果数量。视觉用户能看见“26 个结果”,读屏焦点却还停在筛选按钮上,没有任何反馈。如果每次状态刷新都播报,又会在列表分页或重复渲染时连续打断用户。

这段代码解决的是“异步结果完成后只播报一次,旧请求回调不抢新请求的话筒”。页面为每次查询生成递增 token,播报器用 key 去重,并通过适配层发送 Accessibility Kit 事件。

export class AccessibilityAnnouncer {
  private lastKey: string = ''
  constructor(private readonly bridge: AccessibilityEventBridge) {}

  async announceOnce(key: string, text: string): Promise<void> {
    if (key === this.lastKey) return
    this.lastKey = key
    await this.bridge.announce(text)
  }

  reset(): void {
    this.lastKey = ''
  }
}

private async runFilter(): Promise<void> {
  const token = ++this.queryToken
  this.loading = true
  const result = await this.repository.query(this.filters)
  if (token !== this.queryToken || !this.isPageActive) return

  this.items = result.items
  this.loading = false
  await this.announcer.announceOnce(
    `filter_${token}_completed`,
    `筛选完成,共 ${result.total} 个结果`
  )
}

适配层存在的意义,是隔离不同 API 版本下事件参数和调用方式,页面不直接拼平台事件对象。数据变化顺序也很重要:先把 items 和 loading 更新为最终值,再发完成播报;否则用户听到完成后立即触摸列表,拿到的仍可能是旧节点。

播报文本也不能照搬视觉 Toast。Toast 可以写“成功”,但读屏用户不知道什么成功;这里必须包含动作和结果数量。失败时则保留可执行建议,例如“网络不可用,筛选未更新,可重试”,而不是只读错误码。对于高频进度不逐百分比播报,只在开始、关键节点和完成时通知,避免每秒打断一次当前焦点内容。

页面进入后台时把 isPageActive 设为 false,并取消能取消的查询;重新进入后只有用户新触发的任务才允许播报。reset() 只在页面会话真正结束时调用,不能跟每次重绘绑定,否则同一个完成事件会再次发出。正式项目还要处理网络重试、空结果和错误信息,错误播报不能和成功播报共用一个模糊 key。

六、17:16 的结果页不只展示一个绿色通过

我没有把手机页做成“PASS”大字海报,因为通过本身缺少诊断价值。页面同时展示节点总数、标签覆盖、最小热区、语义分组、动态播报和剩余问题,下面还保留焦点顺序 1→8。任何一个数字回退,都能快速定位是组件封装、扫描规则还是页面结构发生了变化。

17:16 的纯屏幕截图里,审计任务仍是 a11y_20261001_14:38 个节点,标签 38/38,最小触控热区 48 vp,分组 6,动态播报 2/2,问题 0,结果 PASS。红色批注只指出“朗读标签完整”和“最小热区 48 vp”,没有遮住页面实际数据。

通过以后我又做了三次反向验证。去掉收藏按钮标签,Hvigor 任务立即报告 A11Y_LABEL_MISSING;把外层尺寸改回 32 vp,报告变成 A11Y_TOUCH_TARGET: 32x32vp;连续触发同一完成 key,HiLog 只出现一次 announcement。门禁不是把数字写死成漂亮结果,而是能在故意破坏代码时可靠失败。

七、能自动检查的是底线,真正可用仍要靠人听

静态规则适合检查“有没有”和“够不够大”,不能回答“读起来是否符合人的预期”。同一个“更多”放在十张商品卡片上,技术上有标签,使用上仍然含糊;应该包含上下文,例如“更多,星空台灯”。多语言资源也要在目标语言下实际朗读,不能只确认 key 存在。

还有几类边界留给专项测试:系统字体放大后是否遮挡但仍可聚焦;折叠屏窗口变化后焦点是否跳回顶部;自绘 Canvas 或 XComponent 是否补充了可访问节点;弹窗关闭后焦点是否回到触发按钮;列表复用时语义是否跟着数据更新。电视、平板和手机的操作方式不同,48 vp 也不是所有设备形态的唯一答案。

我还把审计数据限定为构建产物的一部分,而不是上传真实用户的朗读内容。门禁需要节点数量、规则编号和源码位置,不需要采集用户点击过什么商品。开发日志在 release 构建中关闭详细文本,只保留匿名计数;这既减少隐私风险,也避免把资源文案和业务数据混进长期日志。

最终我把 A11yIconButton、语义分组约定、AccessibilityAnnouncer 和 Hvigor 扫描任务一起提交。只交一个脚本会漏掉运行时行为,只改几个属性又挡不住回归。现在 PASS 代表一条完整链路:资源化标签进入组件、触控热区达到底线、动态状态被正确播报、静态问题在构建前被发现,最后再由真实读屏走查确认体验。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐