这次问题不是崩溃,也不是某个按钮点不动。上架预检报告只写了几项很“软”的异常:筛选任务后朗读焦点跳到页面标题,三枚图标按钮没有可读名称,一条任务被标题、截止时间和状态拆成三次播报。正常触控用户几乎察觉不到,可一旦打开屏幕朗读,完成一条清单要横扫六七次。Demo 叫 FocusLedger,页面是 AccessibilityAuditPage,我把这次修复的任务号定为 A11Y-1818。

一、报告里的四个数字,背后是同一个语义树问题

初始页面有 24 个可访问节点,预检记录为:缺失标签 3、重复播报 2、筛选后焦点跳失 4、孤立操作 1,综合分 68。看起来像四类问题,实际都来自“视觉组件树直接等同于无障碍语义树”:一张任务卡里有标题、标签、时间、复选图标、更多图标,视觉上分开合理,朗读时却不应该每个子组件都成为独立停靠点。

修复目标没有写成“属性补全”,而是一个可验收状态机:BASELINE_FAIL → SEMANTICS_PATCHED → FOCUS_RESTORED → PRECHECK_PASS。筛选词固定为“未完成”,筛选前焦点落在 CHK-07,筛选后它仍存在于结果第 3 位,就应该继续成为首个可读任务,而不是让用户从页面顶部重新探索。18:18 的最终报告必须同时满足 labels=0、duplicates=0、focusJumps=0、orphanActions=0、score=100。

HarmonyOS 当前 ArkUI 无障碍属性覆盖分组、文本、说明、重要性、首焦点和下一个焦点。官方文档说明,启用 accessibilityGroup 后,组件及其子组件会作为一个整体;设置 accessibilityText 时,屏幕朗读优先播报该文本;accessibilityDescription 用于补充操作后果;accessibilityLevel 决定节点是否可被辅助服务识别。无障碍属性参考。这几个属性不是越多越好,关键是先决定一张卡到底要成为一个节点还是多个节点。

二、目录按“语义、焦点、证据”拆,而不是按页面堆代码

FocusLedger 的 pages/AccessibilityAuditPage.ets 只拼装界面;components/ChecklistRow.ets 定义任务卡语义;a11y/FocusContinuation.ets 保存稳定焦点键;audit/SemanticsProbe.ets 汇总预检规则;model/ChecklistItem.ets 保证任务 ID 不跟数组下标走。这样筛选、排序和局部刷新不会让语义策略散落在多个 ForEach 回调里。

第一段代码解决“一张卡被拆成多次播报”。整行设置为无障碍组,播报文本由业务字段显式生成;装饰图标不进入语义树,复选按钮仍保留为独立操作,并给出操作说明。这里不能简单把整行所有子节点都设成 no,否则用户听得到任务信息却无法完成任务。

@Component
export struct ChecklistRow {
  @Prop item: ChecklistItem;
  @Prop restoreId: string;
  onToggle: (id: string) => void = () => {};

  build() {
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text(this.item.title)
        Text(`${this.item.dueText} · ${this.item.done ? '已完成' : '未完成'}`)
      }
      .layoutWeight(1)
      .accessibilityLevel('no-hide-descendants')

      Button(this.item.done ? '撤销完成' : '标记完成')
        .id(`action_${this.item.id}`)
        .accessibilityText(`${this.item.title},${this.item.done ? '撤销完成' : '标记完成'}`)
        .accessibilityDescription('双击后更新任务状态')
        .accessibilityLevel('yes')
        .onClick(() => this.onToggle(this.item.id))
    }
    .id(`task_${this.item.id}`)
    .accessibilityGroup(true, { accessibilityPreferred: true })
    .accessibilityText(`${this.item.title},${this.item.dueText},${this.item.done ? '已完成' : '未完成'}`)
    .accessibilityDefaultFocus(this.item.id === this.restoreId)
  }
}

accessibilityPreferred: true 让分组优先采用显式无障碍文本,而不是把视觉子节点按深度顺序随意拼接。复选按钮用 accessibilityLevel('yes') 逃离父分组约束,这是官方文档明确支持的边界。需要注意,按钮的无障碍文本包含任务标题,否则连续滑到多个“标记完成”时用户无法知道正在操作哪一项。页面销毁不需要释放这些声明式属性,但必须清掉后面用于延迟恢复首焦点的计时器。

三、焦点续接不能记录数组下标,要记录业务身份

原实现保存 focusedIndex=6。筛选后列表只剩 9 项,第 6 项已经换成另一条任务,系统只能回到第一个可聚焦节点。FocusLedger 改为记录 CHK-07。过滤完成后先检查该 ID 是否仍存在:存在就恢复它;不存在则在原排序中向后找最近的未完成项;再没有才回退到筛选结果第一项。这个策略比“永远跳到第一个”多了几行,却保住了用户的探索上下文。

第二段代码解决筛选与异步渲染的时序。accessibilityDefaultFocus 必须在新节点树建立前确定,因此恢复键先写入状态,再提交过滤结果。代次 filterEpoch 防止快速切换筛选条件时,较早的计算结果覆盖较新的焦点计划。

export class FocusContinuation {
  private filterEpoch = 0;
  private lastFocusedId = '';

  remember(id: string): void {
    this.lastFocusedId = id;
  }

  async applyFilter(all: ChecklistItem[], query: string): Promise<FilterResult> {
    const mine = ++this.filterEpoch;
    const filtered = await TaskFilter.run(all, query);
    if (mine !== this.filterEpoch) {
      throw new Error(`STALE_FILTER:${mine}`);
    }

    const exact = filtered.find((item) => item.id === this.lastFocusedId);
    const fallback = exact ?? filtered.find((item) => !item.done) ?? filtered[0];
    return {
      items: filtered,
      restoreId: fallback?.id ?? '',
      restorePosition: fallback ? filtered.findIndex((item) => item.id === fallback.id) + 1 : 0
    };
  }

  invalidate(): void {
    this.filterEpoch += 1;
  }
}

STALE_FILTER 不是页面错误,它表示用户的第二次筛选已经胜出。UI 只记录一条 STALE_FILTER_DROPPED,不弹 Toast,也不清空当前列表。项目边界上,如果当前焦点项被用户删除,恢复到最近未完成项是产品规则,不是系统默认;其他产品可能更适合回到删除位置的下一项。关键是规则必须基于稳定 ID,并在自动化测试里固定下来。

四、把上架预检变成可重复的本地账本

只在屏幕朗读里“听起来差不多”不够。SemanticsProbe 为每个业务行登记预期节点:行摘要、操作节点、稳定 ID、是否为恢复目标。它不试图替代系统无障碍服务,而是先抓最容易回归的业务约束:空标签、重复摘要、没有归属的操作、恢复目标缺失。真正的上架预检仍通过 DevEco Testing 运行,本地账本负责在提交前挡住低级回归。

第三段代码解决报告可追踪性。每次筛选完成生成一份 AuditSnapshot,只有页面代次仍有效且四项均为 0 才允许状态进入 PRECHECK_PASS。离开页面时 dispose() 会使旧报告失效,避免上一个页面实例的 100 分覆盖新页面的基线结果。

type AuditSnapshot = {
  taskId: string; nodeCount: number; missingLabels: number;
  duplicates: number; focusJumps: number; orphanActions: number; score: number;
};

export class SemanticsProbe {
  private pageEpoch = 1;

  audit(rows: SemanticRow[], restoreId: string, taskId: string): AuditSnapshot {
    const labels = rows.map((row) => row.spokenText.trim());
    const missingLabels = labels.filter((text) => text.length === 0).length;
    const duplicates = labels.length - new Set(labels).size;
    const orphanActions = rows.filter((row) => !row.ownerId || !row.actionText).length;
    const focusJumps = restoreId && !rows.some((row) => row.ownerId === restoreId) ? 1 : 0;
    const deductions = missingLabels * 8 + duplicates * 4 +
      orphanActions * 6 + focusJumps * 10;
    return { taskId, nodeCount: rows.length, missingLabels, duplicates,
      focusJumps, orphanActions, score: Math.max(0, 100 - deductions) };
  }

  canCommit(epoch: number, report: AuditSnapshot): boolean {
    return epoch === this.pageEpoch && report.missingLabels === 0 &&
      report.duplicates === 0 && report.focusJumps === 0 && report.orphanActions === 0;
  }

  currentEpoch(): number { return this.pageEpoch; }
  dispose(): void { this.pageEpoch += 1; }
}

这里的分数只是项目内门禁,不伪装成官方审核分。官方测试体系把 UX、兼容性、稳定性等维度分开,DevEco Testing 的上架预检基于应用上架质量标准给出自动化检测报告。DevEco Testing 资源与上架预检;应用测试概述。本地分数的意义,是让同一缺陷在开发机、CI 和提审前都有同样的名字与计数。

五、调试时我只盯四条日志,不盯“感觉顺了”

本轮固定日志为:task=A11Y-1818 nodes=24、baseline labels=3 duplicates=2 focusJumps=4 orphanActions=1 score=68、restore id=CHK-07 position=3 query=未完成、final labels=0 duplicates=0 focusJumps=0 orphanActions=0 score=100、state=PRECHECK_PASS。如果 24 个节点突然变成 30,说明视觉子节点又泄漏进语义树;如果分数为 100 但恢复 ID 为空,说明门禁只检查了标签,没有检查导航连续性。

IDE 图中左侧是 FocusLedger 的语义、焦点和审计目录,中间停在 ChecklistRow.ets 的分组代码,右侧模拟器展示同一任务号与 68→100 的修复结果,底部 HiLog 使用上述五条日志。红色标注只圈 accessibilityGroup、CHK-07 恢复点和四项归零,避免把图做成满屏审查意见。

六、最终运行页必须让测试人员一眼复核

18:18 的 AccessibilityAuditPage 显示状态 PRECHECK_PASS。筛选词“未完成”命中 9 项,CHK-07 恢复到第 3 位;24 个可访问节点中,缺失标签、重复播报、焦点跳失和孤立操作全部为 0,项目内预检分由 68 提升到 100。页面还保留“运行语义扫描”和“复测焦点续接”两个按钮,测试人员不必返回 IDE 才能重新制造场景。

手机图不是把 100 分放大做海报,而是把恢复 ID、位置、筛选词和四项计数并列出来。状态栏时间、5G、Wi‑Fi、信号、电量图标与数字完整保留;画面本身就是 9:16 屏幕,不套手机外壳。对这类审核问题,证据越具体,后续版本越容易知道到底是哪条语义规则回退了。

七、几个容易被“全绿”掩盖的边界

第一,中文朗读文本不要直接拼内部状态码,PENDING 应转换成“未完成”;数量、日期和货币也应使用面向用户的本地化表达。第二,动态列表里 ForEach 必须使用稳定业务键,若仍用数组下标,焦点续接代码再正确也会绑定到错误节点。第三,分组并不代表越少焦点越好;可独立执行的收藏、删除或展开操作仍应成为可识别节点,只是要补充对象名称和后果说明。

第四,页面进入后台时不要主动抢回焦点。只有筛选、排序、删除等由用户触发且导致节点树替换的操作,才执行一次续接计划。第五,自动化扫描不能替代真实屏幕朗读复测,尤其是长文本截断、语速下的理解成本和连续滑动顺序。第六,accessibilityDefaultFocus 只能有一个恢复目标,多个列表分区同时标记会产生不可预测的首焦点竞争。

八、这次修复的不是四个属性,而是一条探索路径

无障碍缺陷常被误解成“给图标加描述”。实际用下来,更难的是状态变化之后用户能不能接着刚才的位置继续操作。FocusLedger 最终把视觉树、语义树、焦点身份和预检证据分开管理:卡片负责说清楚自己,稳定 ID 负责续接上下文,代次阻止旧筛选回写,审计账本把回归变成数字。PRECHECK_PASS 因而不只是页面上的绿色状态,而是一条能从代码、日志、手机运行页到上架预检报告互相核对的证据链。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐