HarmonyOS 7 ArkUI Accessibility + Hvigor:无障碍语义、触控热区与动态播报的提审门禁【鸿蒙心迹】
页面看起来没有任何问题,读屏打开后却变成了另一套产品:顶部三个图标都被念成“按钮”,筛选完成没有提示,右下角的收藏按钮只有 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 代表一条完整链路:资源化标签进入组件、触控热区达到底线、动态状态被正确播报、静态问题在构建前被发现,最后再由真实读屏走查确认体验。
参考资料:
更多推荐



所有评论(0)