一、审核失败不是“少写了一个标签”这次的起点是一份很短的测试反馈读屏进入订单列表后三个“更多”按钮读出来完全一样把订单改成已退款按钮视觉已经变成“查看退款”读屏却仍然播报“申请退款”右上角筛选图标的触控区域只有 32 vp。单看任何一项都不难修麻烦在于它们都不是稳定复现的静态缺陷而是随着列表复用、状态切换和弹窗关闭不断漂移。项目叫 A11yReleaseGate页面是 AccessibilityAuditPage。我们没有再靠人工逐页点而是做了一个上架前门禁任务 A11Y-1712扫描 24 个页面、486 个可交互节点把无标签、重复标签、小触控目标和焦点环路收敛为一份可追踪报告。第一次跑出来的数据不算好看无标签 7 个、重复标签 3 组、小触控目标 5 个、焦点环路 1 条。真正改变处理方式的判断是无障碍信息不是附着在 UI 上的一段文案而是界面状态的一部分。只要状态会变语义快照就要跟着变只要节点会复用标签的身份就不能只依赖下标只要有弹层焦点就必须能进、能出、能回到触发点。二、先把页面翻译成可比较的语义快照门禁工具没有尝试替代系统读屏。它做的是更窄、也更适合 CI 的事情进入约定页面收集当前可交互节点的角色、文本、描述、边界、可用状态和焦点关系然后在一次动作前后做差异比较。目录里最重要的不是测试用例数量而是职责分离AccessibilityAuditPage.ets负责演示状态SemanticCollector.ets负责采集A11yRuleSet.ets只做纯规则判断AuditReporter.ets输出审核材料。第一段代码解决“采到的节点每次顺序不同报告无法稳定比较”的问题。快照以语义身份排序而不是以遍历顺序排序同时把像素边界转换成 vp避免不同密度模拟器让同一触控目标得到两个结论。// SemanticCollector.etsexportinterfaceSemanticNode{key:stringrole:stringlabel:stringenabled:booleanboundsVp:[number,number,number,number]nextFocusKey?:string}exportasyncfunctioncollectSemanticSnapshot(page:string):PromiseSemanticNode[]{constrawawaituiInspector.query({page,interactiveOnly:true})constdensitydisplay.getDefaultDisplaySync().densityPixelsreturnraw.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.etsexportinterfaceAuditIssue{rule:string;key:string;message:string}exportfunctionlintNode(node:SemanticNode):AuditIssue[]{constissues:AuditIssue[][]const[left,top,right,bottom]node.boundsVpif(!node.label){issues.push({rule:A11Y_LABEL_EMPTY,key:node.key,message:交互节点缺少可读标签})}if(right-left48||bottom-top48){issues.push({rule:A11Y_TARGET_SMALL,key:node.key,message:触控目标${right-left}×${bottom-top}vp小于 48×48vp})}if(node.nextFocusKeynode.key){issues.push({rule:A11Y_FOCUS_SELF_LOOP,key:node.key,message:焦点指向自身})}returnissues}重复标签不能在单节点规则里判断。我们按“同一可见区域、同一角色、同一标签”分组允许正文里出现多个“已完成”但不允许三个相邻按钮都叫“更多”。修复时订单按钮的标签改成“订单 20261003-18更多操作”既包含对象身份也保留动作。触控区域则用透明热区扩到 48 vp而不是把图标本身硬拉大。四、动态标签必须经过动作前后对账静态扫描通过后最隐蔽的问题仍然存在。AccessibilityAuditPage 里的退款按钮由orderState驱动视觉文字更新发生在状态提交后旧实现的无障碍标签却在组件创建时拼接一次。我们给工具加了“注入动态标签丢失”按钮故意恢复这个错误再由compareAfterAction执行动作、等待语义树稳定并核对变化。// AccessibilityAuditPage.etsStateprivateorderState:PAID|REFUNDEDPAIDStateprivateactionLabel:string订单 20261003-18申请退款privateasynccompareAfterAction():Promisevoid{constbeforeawaitcollectSemanticSnapshot(AccessibilityAuditPage)this.orderStateREFUNDEDthis.actionLabel订单 20261003-18查看退款awaitinspectorBridge.waitForStableTree(2,120)constafterawaitcollectSemanticSnapshot(AccessibilityAuditPage)constchangedsemanticDiff.changed(before,after,refund-action)hilog.info(0x1712,A11yGate,taskA11Y-1712 stateREVIEW_READY changed${changed}pass24/24)}Button(this.orderStatePAID?申请退款:查看退款).accessibilityText(this.actionLabel).width(120).height(48)等待策略没有使用固定一秒延迟。工具要求连续两次语义树哈希一致采样间隔 120 ms才认为状态稳定。这样既不会因为动画尚未结束误判也不把 CI 时间浪费在每个动作后的长等待。若页面在后台动作不会继续执行恢复前台后会重建会话并从该用例起点重跑避免拿半截状态生成审核材料。调试时HiLog 只保留能和报告对账的字段任务 ID、页面、节点数、规则编号、动作名和最终状态。示例里的关键日志是taskA11Y-1712 pageAccessibilityAuditPage nodes486修复完成后是stateREVIEW_READY pass24/24 unlabeled0 duplicate0 small0 cycle0。如果图片、报告和日志里任何一个数字不一致门禁不会允许导出。五、焦点环路比空标签更难发现那条焦点环路来自筛选弹窗。打开时焦点进入首个选项关闭时却回到一个已经销毁的临时节点框架随后选择页面第一个可聚焦元素读屏用户感觉像被突然扔回顶部。我们把触发按钮的语义 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而是建立了一套可重复的证据链页面状态变化会触发语义变化语义变化能被快照捕获规则能解释失败原因报告又能回到具体节点和日志。无障碍适配从发布前的清单项变成了和布局、性能一样可以持续回归的工程约束。