HarmonyOS 7 ArkUI:无障碍语义漂移与触控目标门禁
一、审核失败不是“少写了一个标签”
这次的起点是一份很短的测试反馈:读屏进入订单列表后,三个“更多”按钮读出来完全一样;把订单改成已退款,按钮视觉已经变成“查看退款”,读屏却仍然播报“申请退款”;右上角筛选图标的触控区域只有 32 vp。单看任何一项都不难修,麻烦在于它们都不是稳定复现的静态缺陷,而是随着列表复用、状态切换和弹窗关闭不断漂移。
项目叫 A11yReleaseGate,页面是 AccessibilityAuditPage。我们没有再靠人工逐页点,而是做了一个上架前门禁任务 A11Y-1712:扫描 24 个页面、486 个可交互节点,把无标签、重复标签、小触控目标和焦点环路收敛为一份可追踪报告。第一次跑出来的数据不算好看:无标签 7 个、重复标签 3 组、小触控目标 5 个、焦点环路 1 条。

真正改变处理方式的判断是:无障碍信息不是附着在 UI 上的一段文案,而是界面状态的一部分。只要状态会变,语义快照就要跟着变;只要节点会复用,标签的身份就不能只依赖下标;只要有弹层,焦点就必须能进、能出、能回到触发点。
二、先把页面翻译成可比较的语义快照
门禁工具没有尝试替代系统读屏。它做的是更窄、也更适合 CI 的事情:进入约定页面,收集当前可交互节点的角色、文本、描述、边界、可用状态和焦点关系,然后在一次动作前后做差异比较。目录里最重要的不是测试用例数量,而是职责分离:AccessibilityAuditPage.ets 负责演示状态,SemanticCollector.ets 负责采集,A11yRuleSet.ets 只做纯规则判断,AuditReporter.ets 输出审核材料。
第一段代码解决“采到的节点每次顺序不同,报告无法稳定比较”的问题。快照以语义身份排序,而不是以遍历顺序排序;同时把像素边界转换成 vp,避免不同密度模拟器让同一触控目标得到两个结论。
// SemanticCollector.ets
export interface SemanticNode {
key: string
role: string
label: string
enabled: boolean
boundsVp: [number, number, number, number]
nextFocusKey?: string
}
export async function collectSemanticSnapshot(page: string): Promise<SemanticNode[]> {
const raw = await uiInspector.query({ page, interactiveOnly: true })
const density = display.getDefaultDisplaySync().densityPixels
return raw.map((node) => ({
key: node.accessibilityId || `${node.role}:${node.resourceId}`,
role: node.role,
label: (node.accessibilityText || node.text || '').trim(),
enabled: node.enabled,
boundsVp: node.bounds.map((px: number) => Math.round(px / density)) as
[number, number, number, number],
nextFocusKey: node.nextFocusId
})).sort((a, b) => a.key.localeCompare(b.key))
}
这里最容易犯的错,是拿可见文本当唯一标签。图标按钮通常没有文本,带角标的按钮还会把数字拆成另一个节点;列表项复用后,可见文本正确也不代表 accessibilityText 已更新。采集器因此保留“显式描述优先、文本兜底”的顺序,并要求业务侧提供稳定的 accessibilityId。采集发生在页面 onPageShow 完成、首帧布局稳定之后,离开页面立即释放 Inspector 会话,避免下一页沿用旧节点树。
三、规则要能解释,不只给红灯
第二段代码把规则集中到 lintNode。门禁不直接返回布尔值,而是返回规则编号、节点身份和可操作的说明。触控目标采用 48 vp 门槛;禁用控件仍保留标签,因为“当前不可操作”也需要被读屏解释;纯装饰元素在采集阶段已被排除,避免用无意义描述凑通过率。
// A11yRuleSet.ets
export interface AuditIssue { rule: string; key: string; message: string }
export function lintNode(node: SemanticNode): AuditIssue[] {
const issues: AuditIssue[] = []
const [left, top, right, bottom] = node.boundsVp
if (!node.label) {
issues.push({ rule: 'A11Y_LABEL_EMPTY', key: node.key, message: '交互节点缺少可读标签' })
}
if (right - left < 48 || bottom - top < 48) {
issues.push({ rule: 'A11Y_TARGET_SMALL', key: node.key,
message: `触控目标 ${right - left}×${bottom - top}vp,小于 48×48vp` })
}
if (node.nextFocusKey === node.key) {
issues.push({ rule: 'A11Y_FOCUS_SELF_LOOP', key: node.key, message: '焦点指向自身' })
}
return issues
}
重复标签不能在单节点规则里判断。我们按“同一可见区域、同一角色、同一标签”分组,允许正文里出现多个“已完成”,但不允许三个相邻按钮都叫“更多”。修复时,订单按钮的标签改成“订单 20261003-18,更多操作”,既包含对象身份,也保留动作。触控区域则用透明热区扩到 48 vp,而不是把图标本身硬拉大。
四、动态标签必须经过动作前后对账
静态扫描通过后,最隐蔽的问题仍然存在。AccessibilityAuditPage 里的退款按钮由 orderState 驱动,视觉文字更新发生在状态提交后;旧实现的无障碍标签却在组件创建时拼接一次。我们给工具加了“注入动态标签丢失”按钮,故意恢复这个错误,再由 compareAfterAction 执行动作、等待语义树稳定并核对变化。
// AccessibilityAuditPage.ets
@State private orderState: 'PAID' | 'REFUNDED' = 'PAID'
@State private actionLabel: string = '订单 20261003-18,申请退款'
private async compareAfterAction(): Promise<void> {
const before = await collectSemanticSnapshot('AccessibilityAuditPage')
this.orderState = 'REFUNDED'
this.actionLabel = '订单 20261003-18,查看退款'
await inspectorBridge.waitForStableTree(2, 120)
const after = await collectSemanticSnapshot('AccessibilityAuditPage')
const changed = semanticDiff.changed(before, after, 'refund-action')
hilog.info(0x1712, 'A11yGate',
`task=A11Y-1712 state=REVIEW_READY changed=${changed} pass=24/24`)
}
Button(this.orderState === 'PAID' ? '申请退款' : '查看退款')
.accessibilityText(this.actionLabel)
.width(120).height(48)
等待策略没有使用固定一秒延迟。工具要求连续两次语义树哈希一致,采样间隔 120 ms,才认为状态稳定。这样既不会因为动画尚未结束误判,也不把 CI 时间浪费在每个动作后的长等待。若页面在后台,动作不会继续执行;恢复前台后会重建会话并从该用例起点重跑,避免拿半截状态生成审核材料。

调试时,HiLog 只保留能和报告对账的字段:任务 ID、页面、节点数、规则编号、动作名和最终状态。示例里的关键日志是 task=A11Y-1712 page=AccessibilityAuditPage nodes=486,修复完成后是 state=REVIEW_READY pass=24/24 unlabeled=0 duplicate=0 small=0 cycle=0。如果图片、报告和日志里任何一个数字不一致,门禁不会允许导出。
五、焦点环路比空标签更难发现
那条焦点环路来自筛选弹窗。打开时焦点进入首个选项,关闭时却回到一个已经销毁的临时节点,框架随后选择页面第一个可聚焦元素;读屏用户感觉像被突然扔回顶部。我们把触发按钮的语义 key 记录为 filter-entry,弹窗关闭后显式恢复焦点。弹窗自身只形成有限循环,返回键始终能退出。
这个修复也暴露出门禁的边界:它能发现自环、断链和关闭后落点错误,但不能判断播报语气是否自然,也不能替代真实用户对复杂手势的体验。最终发布前仍保留人工抽检,只是把人工从“找空标签”这种机械工作中解放出来,集中检查读屏顺序、措辞和多指手势。
工具最终把 24 个页面全部跑完:486 个节点,无标签从 7 降到 0,重复标签从 3 组降到 0,小目标从 5 个降到 0,焦点环路从 1 条降到 0。任务状态进入 REVIEW_READY,按钮“导出审核报告”才变为可用。

六、把门禁放在提交之前,而不是审核之后
我们最后没有把它做成一个只在发布日运行的大脚本,而是拆成两层。开发阶段只扫描改动页面,十几秒就能给出错误;发布流水线跑完整 24 页并生成 JSON、Markdown 和截图索引。规则版本和应用版本一起写入报告,防止同一份结果在规则升级后仍被误用。
还要特别处理重复调用:Inspector 会话按页面单例持有,新的扫描开始前先取消旧任务;报告写入采用临时文件加原子替换,避免两次“导出审核报告”产生半份文件。页面离开、Ability 进入后台或测试中止时,监听器、定时器和浮层都必须释放。否则下一轮看似多出一个焦点节点,实际是上轮调试浮层残留。
在团队协作上,我们还把规则编号写进缺陷模板。业务开发看到 A11Y_TARGET_SMALL,可以直接定位到节点 key、页面和 32×32 vp 的实测边界;设计同学则能判断是扩大透明热区,还是重新安排控件间距。对于确实无法达到 48 vp 的密集图表控制点,必须提交带原因、替代手势和人工验证记录的例外,而不是在代码里偷偷降低阈值。例外也有过期版本,下一次大改版会自动重新进入检查队列。
多语言是另一个容易漏掉的边界。中文标签不重复,并不代表英文翻译后仍可区分。完整流水线会在中文和英文资源各跑一遍,同时检查格式化参数是否真的进入 accessibilityText。若订单号因为资源占位符错误丢失,三个按钮就会在英文环境重新退化成相同的 “More actions”。
这次最有价值的不是把 16 个问题改成 0,而是建立了一套可重复的证据链:页面状态变化会触发语义变化,语义变化能被快照捕获,规则能解释失败原因,报告又能回到具体节点和日志。无障碍适配从发布前的清单项,变成了和布局、性能一样可以持续回归的工程约束。
更多推荐


所有评论(0)