HarmonyOS 7 + ArkUI Accessibility:闪控窗紧凑态的语义树裁剪与读屏焦点收敛【鸿蒙心迹】

闪控窗从完整态切到紧凑态,视觉上通常只是少了几枚按钮、缩短了标题、把进度区域压成一行。对读屏服务来说,事情并不只是"画面变小":如果旧节点还留在无障碍语义树里,焦点可能继续落到不可见按钮;如果每个子组件都单独播报,用户要听完九个节点才能找到主操作;如果形态切换和焦点恢复同时发生,旧任务还会把焦点抢回已经消失的控件。

本文用 FloatA11y 演示工程拆解这个问题。任务编号固定为 A11Y-FLOAT-0060,页面是 CompactPanelPage,完整态到紧凑态的目标不是"看起来没问题",而是把可访问节点从 9 个收敛为 5 个、隐藏焦点目标从 4 个降为 0,并让恢复焦点稳定落到 taskTitle。文中的结果是可复现演示数据,不冒充真实项目测试结论。

一、真正的异常发生在看不见的树里

演示页最初有标题、进度、暂停、取消、展开、关闭、剩余时间、网络状态和任务编号九个可访问节点。进入紧凑态后,界面只保留标题、进度、主操作、关闭和状态摘要五项。肉眼检查时一切正常,但读屏焦点从进度继续右滑,仍然会读到已经不显示的 pauseButton 与 cancelButton。

问题并不等同于"控件隐藏失败"。渲染树、命中树和无障碍语义树服务于不同目的。一个组件即使透明、移出可视区域,或者被动画压到零尺寸,也不意味着它一定不再被辅助服务识别。工程上需要把"当前是否可见"和"当前是否应该被读屏访问"明确建模,而不是把语义行为寄托在视觉副作用上。

这次演示把状态拆成三层:FULL 表示完整操作面板,COMPACT 表示紧凑控制条,HIDDEN 表示窗口已关闭。每次形态变化都生成新的 semanticEpoch。只有与当前 epoch 一致的焦点恢复任务才允许执行,旧任务直接丢弃。这样,布局变化和无障碍焦点就有了同一套时序边界。

基线数据如下:完整态 9 个节点;切换紧凑态后画面显示 5 项,但语义树仍有 9 项;其中 4 项不可见却可聚焦;一次切换触发 3 条重复状态播报。目标数据则是 5 个节点、0 个隐藏焦点、1 条合并播报。这里的数字不是性能跑分,而是审核语义树时可直接核对的验收项。

二、先把视觉状态翻译成语义契约

如果组件自己判断"我要不要被读",业务会很快出现分叉:暂停按钮看 isCompact,取消按钮看 expanded,状态摘要又看任务进度。更稳妥的方式是由一个纯函数把窗口形态转换为语义契约,UI 只消费契约。

这段代码解决什么问题:把窗口形态、主操作和可访问节点统一映射为不可变的语义快照,避免各组件自行猜测。

ts 复制代码
type PanelMode = 'FULL' | 'COMPACT' | 'HIDDEN';

interface SemanticSnapshot {
  epoch: number;
  mode: PanelMode;
  titleText: string;
  progressText: string;
  primaryActionText: string;
  hideSecondaryActions: boolean;
  targetFocusId: string;
}

function buildSemanticSnapshot(
  epoch: number,
  mode: PanelMode,
  progress: number
): SemanticSnapshot {
  const running = progress < 100;
  return {
    epoch,
    mode,
    titleText: '文件同步任务 A11Y-FLOAT-0060',
    progressText: `当前进度 ${progress}%`,
    primaryActionText: running ? '暂停同步' : '查看结果',
    hideSecondaryActions: mode !== 'FULL',
    targetFocusId: mode === 'HIDDEN' ? '' : 'taskTitle'
  };
}

这段写法的关键不是接口复杂,而是它只接收事实并返回快照。COMPACT 不再由五个组件分别判断,hideSecondaryActions 成为统一语义条件。状态从 FULL 进入 COMPACT 时,epoch 从 14 增加到 15,目标焦点同时变为 taskTitle;进入 HIDDEN 时目标为空,不再请求焦点。

容易出错的地方有两个。第一,不要把 progressText 缓存在另一个对象中,否则进度变化后读屏仍可能读旧值。第二,不要把业务对象、窗口对象或 UIContext 塞进快照;它应当保持可序列化、可记录、可测试。实际项目中可以为快照增加语言资源 key,但不应直接拼接不可本地化的长文案。

三、裁剪语义树,而不是给隐藏节点"消音"

ArkUI 的无障碍通用属性提供了显式语义控制。accessibilityText 用于给节点提供稳定名称,accessibilityDescription 用于补充动作或后果,accessibilityGroup 可以把一组内容作为整体理解;accessibilityLevel('no-hide-descendants') 则可让当前组件及其子组件不被无障碍辅助服务识别。官方参考把该值的语义写得很明确,因此它适合处理紧凑态下整组次要操作的退出。

这段代码解决什么问题:让次要操作在紧凑态真正退出无障碍语义树,同时把标题和进度组合成稳定摘要。

ts 复制代码
@Component
struct CompactPanelPage {
  @State snapshot: SemanticSnapshot =
    buildSemanticSnapshot(14, 'FULL', 68);

  build() {
    Column({ space: 12 }) {
      Column({ space: 4 }) {
        Text('文件同步')
          .id('taskTitle')
        Text('68% · 还需约 2 分钟')
      }
      .accessibilityGroup(true)
      .accessibilityText(this.snapshot.titleText)
      .accessibilityDescription(this.snapshot.progressText)

      Row({ space: 8 }) {
        Button('暂停').accessibilityText('暂停同步')
        Button('取消').accessibilityText('取消同步任务')
        Button('展开').accessibilityText('展开完整控制面板')
        Button('网络').accessibilityText('查看网络状态')
      }
      .accessibilityLevel(
        this.snapshot.hideSecondaryActions
          ? 'no-hide-descendants'
          : 'auto'
      )

      Button(this.snapshot.primaryActionText)
        .accessibilityDescription('执行当前任务的主要操作')
      Button('关闭').accessibilityText('关闭闪控窗')
    }
  }
}

这里没有把每个隐藏按钮分别设置为不可访问,而是对"次要操作组"一次性裁剪。原因是紧凑态的产品语义本来就是整组退出;逐个设置很容易漏掉后续新增按钮。accessibilityGroup(true) 也不是为了减少组件数量而滥用,它只包裹标题与进度这组天然相关的信息,让用户一次听到任务名和当前状态。

状态变化可以这样理解:渲染层仍可保留动画所需的容器,但语义层立刻从 9 节点收敛到 5 节点。最容易犯的错误是把整个根容器设置为 no-hide-descendants,这样关闭按钮也会消失;另一个错误是在分组后仍给每个子节点设置冗长描述,造成同一信息重复播报。实际项目要用读屏遍历顺序检查,而不是只看属性是否写上。

图中的 DevEco Studio 画面是演示配图,不是实际 IDE 测试证据。左侧项目结构把 SemanticSnapshot.ets、CompactPanelPage.ets 与 FocusCoordinator.ets 分开;中间标出 no-hide-descendants;右侧模拟器显示 68% 紧凑态;底部 HiLog 对应 epoch=15 nodes=5 hidden=0 focus=taskTitle。

四、焦点恢复必须服从形态事务

语义树收敛后,第二个问题才出现:从完整态切到紧凑态时,原焦点可能位于即将退出语义树的按钮。此时需要把焦点迁移到稳定锚点,但不能在状态赋值后立刻无条件调用 requestFocus。组件树提交需要时间,而且用户可能在这段间隙再次关闭窗口。

演示采用 generation/epoch 模式:每次形态改变先递增 epoch,再提交新快照;焦点任务记录自己的 epoch,下一帧执行时重新比对。只有窗口仍可见、目标仍存在、任务仍是最新一代时才请求焦点。

这段代码解决什么问题:阻止旧的异步焦点任务把读屏焦点抢回已退出语义树的控件。

ts 复制代码
class FocusCoordinator {
  private currentEpoch: number = 0;

  beginTransition(): number {
    this.currentEpoch += 1;
    return this.currentEpoch;
  }

  restoreAfterCommit(epoch: number, targetId: string): void {
    if (targetId.length === 0) {
      return;
    }
    setTimeout(() => {
      if (epoch !== this.currentEpoch) {
        console.info(`[FloatA11y] stale focus dropped epoch=${epoch}`);
        return;
      }
      const accepted = focusControl.requestFocus(targetId);
      console.info(
        `[FloatA11y] focus target=${targetId} accepted=${accepted} epoch=${epoch}`
      );
    }, 0);
  }
}

const focusCoordinator = new FocusCoordinator();

function switchToCompact(progress: number): SemanticSnapshot {
  const epoch = focusCoordinator.beginTransition();
  const next = buildSemanticSnapshot(epoch, 'COMPACT', progress);
  focusCoordinator.restoreAfterCommit(epoch, next.targetFocusId);
  return next;
}

这里的 setTimeout(..., 0) 只代表"把请求放到当前同步状态修改之后",并不是对组件提交时长的硬编码保证。更复杂页面应使用自身已有的布局完成信号或生命周期边界,但仍要保留 epoch 校验。状态从 epoch 14 切到 15 后,如果紧接着关闭窗口进入 epoch 16,epoch 15 的任务会记录 stale focus dropped,不会执行请求。

容易出错的是只检查 targetId,不检查窗口当前形态。ID 字符串可能被另一个页面复用;请求成功也不代表焦点落在业务预期对象上。工程中应给锚点使用页面内稳定、唯一的 ID,并在页面销毁或窗口关闭时递增 epoch。焦点恢复不是一次性的 UI 技巧,而是与窗口生命周期成对管理的资源。

五、把重复播报收成一条状态摘要

紧凑态切换常伴随三个变化:窗口形态改变、操作集合改变、进度文本刷新。如果每个变化都触发自己的播报,用户会连续听到"窗口已收起""按钮已隐藏""当前进度 68%",信息量不大,打断感却很强。

本例不在文章中虚构系统播报接口,而是在应用层先实现一条可测试的摘要合并器。它只负责决定"本次事务应该说什么",具体如何触发辅助服务事件,应按项目目标版本的官方 API 和测试方案接入。这样可以避免为了展示而写出不存在或版本不匹配的接口。

这段代码解决什么问题:把同一形态事务中的多条变化压成一条可审计摘要,并过滤过期事务。

ts 复制代码
interface SemanticAnnouncement {
  epoch: number;
  text: string;
}

function buildAnnouncement(
  snapshot: SemanticSnapshot,
  visibleNodeCount: number
): SemanticAnnouncement | undefined {
  if (snapshot.mode === 'HIDDEN') {
    return { epoch: snapshot.epoch, text: '文件同步控制窗已关闭' };
  }
  if (snapshot.mode === 'COMPACT') {
    return {
      epoch: snapshot.epoch,
      text: `控制窗已收起,${snapshot.progressText},${visibleNodeCount} 个可访问项目`
    };
  }
  return undefined;
}

const announcement = buildAnnouncement(
  buildSemanticSnapshot(15, 'COMPACT', 68),
  5
);
// 控制窗已收起,当前进度 68%,5 个可访问项目

为什么只返回文本而不直接发事件?因为语义决策和平台调用的失败模型不同。纯函数可以做单元测试、快照对比和多语言检查;平台事件需要考虑服务是否启用、页面是否仍存活、是否与系统自动播报重复。把两者耦合后,测试往往只能靠耳朵听,很难进入持续集成。

实际项目还要注意隐私:摘要中不要朗读文件名、联系人或支付信息等敏感内容。标题也不应只依赖图标含义。本文的 文件同步任务 A11Y-FLOAT-0060 是演示标识,生产中可在锁屏、投屏或隐私模式下退化为"后台任务"。

六、运行页先看焦点路径,再看视觉细节

运行页固定时间为 06:18,状态栏显示 Wi‑Fi、5G、信号与 66% 电量。页面展示 FloatA11y / CompactPanelPage,任务 ID 为 A11Y-FLOAT-0060,进度 68%,模式 COMPACT。红色箭头从"当前焦点 taskTitle"指向标题与进度摘要,旁边标注 9 → 5 nodes 和 hidden focus 4 → 0。

这张图承担的是"可操作结果"说明:紧凑态仍有清晰主操作,焦点从标题开始,随后经过主操作和关闭,不再进入隐藏按钮。验证时不要只按一次右滑;应从窗口创建、完整态进入紧凑态、紧凑态回完整态、关闭窗口四条路径分别遍历。任何路径出现幽灵节点,都说明语义状态没有与视觉状态一起提交。

七、诊断页要能回答是哪一代状态出了问题

详情页时间同样是 06:18,显示 semanticEpoch 15、FULL → COMPACT、visibleNodes 5、hiddenFocusable 0、announcement 1 和 focus taskTitle。日志区保留一条 epoch=14 stale focus dropped,用红圈强调旧请求已被拒绝。与运行页相比,它不展示漂亮的控制条,而是展示状态事务的证据链。

诊断字段要少而稳定。建议至少保留模式、epoch、目标焦点、可访问节点数、隐藏可聚焦节点数和摘要次数。不要把整棵无障碍树长期写入生产日志;节点文本可能包含隐私,且大对象会放大日志开销。开发构建可以导出脱敏快照,正式构建只保留计数和匿名 ID。

演示验收结果为:节点 9→5,隐藏焦点 4→0,重复播报 3→1;旧 epoch 14 的焦点任务被丢弃,epoch 15 的 taskTitle 请求被接受。这里的"接受"指应用调用返回值与演示状态一致,不等价于所有辅助服务、语言和设备形态都已通过真实用户测试。

八、边界比结论更重要

第一,accessibilityLevel('no-hide-descendants') 适合整组退出语义树,不适合用来遮掩本应可操作但标签写得不好的控件。可见且可点击的关键操作,应补足名称、状态和后果,而不是简单排除。

第二,accessibilityGroup(true) 会改变遍历粒度。标题与进度适合组合,多个独立按钮通常不适合组合。分组过度会让用户失去逐项操作能力,分组不足则会造成播报碎片化。

第三,焦点恢复一定要和窗口生命周期成对。窗口关闭、页面销毁、Ability 切后台或模式再次变化时,都应让旧任务失效。仅靠延时数字无法建立正确性。

第四,本文只覆盖应用自己的 ArkUI 语义树。若闪控窗中嵌入 XComponent、自绘内容或三方渲染引擎,需要按照对应无障碍接入方式提供节点、Action 和事件,不能期待 ArkUI 自动理解像素内容。

最后,建议把无障碍验收写成数据:visibleNodes=5、hiddenFocusable=0、announcement=1、focus=taskTitle。这些字段比"读起来还行"更适合作为回归基线,也能让视觉、交互和无障碍三条链路在同一次形态事务中对齐。

九、测试矩阵不能只覆盖一次收起动作

如果测试脚本只有"打开窗口---点击收起---向右滑动",很容易漏掉真正麻烦的路径。闪控窗往往由任务状态、窗口状态和系统状态共同驱动。任务在 68% 时进入紧凑态,与任务恰好完成时进入紧凑态,主操作文案不同;窗口位于前台与 Ability 切到后台再恢复,焦点锚点的可用性也不同;横竖屏或字体放大后,标题可能换行,分组摘要仍应保持一次播报。

演示工程把回归矩阵分成四个维度。第一维是形态:FULL、COMPACT、HIDDEN。第二维是任务:RUNNING、PAUSED、COMPLETED、FAILED。第三维是进入方式:用户点击、系统窗口调整、任务回调触发。第四维是辅助设置:读屏关闭、读屏开启、字体放大。不是所有组合都需要人工逐项执行,但必须挑出状态边界:RUNNING/FULL 到 RUNNING/COMPACT,PAUSED/COMPACT 到 COMPLETED/COMPACT,以及 COMPACT 在后台恢复后回到 FULL。

每条用例至少检查五件事。其一,视觉节点与语义节点是否对应;其二,隐藏节点能否被辅助服务发现;其三,焦点锚点是否仍在当前页面;其四,摘要是否重复;其五,关闭后是否还有延迟任务触发。这样定义后,"能听到"不再等于通过,"焦点可达、顺序合理、状态准确、退出干净"才是完整结果。

对自动化测试来说,可以先验证纯函数:同一输入必须生成同一 SemanticSnapshot,FULL 的 hideSecondaryActions 为 false,COMPACT/HIDDEN 为 true,HIDDEN 的目标焦点为空。再验证状态协调器:连续生成 epoch 14、15、16 时,只有 16 的任务有资格执行。最后才在设备上检查真实辅助服务行为。分层的价值在于,平台测试失败时能先排除业务状态错误。

还要专门验证重复进入。很多窗口组件在快速点击时会收到两次收起意图,如果两次都递增 epoch 并生成同样快照,虽然最终界面正确,却可能产生两次播报。解决办法不是简单加一个固定防抖时间,而是在提交前比较目标模式;当前已经是 COMPACT 时,重复意图应返回同一状态,不创建新的语义事务。防抖处理时间,幂等处理事实,两者不能混为一谈。

十、日志、隐私和发布检查要同时收口

开发阶段为了定位焦点问题,团队往往会把节点文本、完整树结构和事件序列全部写入 HiLog。短期很好用,长期却可能留下隐私风险。任务标题、文件名和通知内容都可能出现在无障碍文本里。正式版本建议只记录匿名节点 ID、模式、epoch、计数和结果码;需要导出详细树时,使用明确的调试开关并在导出前脱敏。

日志格式也要保持可关联。本文使用固定前缀 [FloatA11y],一次事务至少带 taskId=A11Y-FLOAT-0060 与 epoch=15。窗口层记录 FULL→COMPACT,语义层记录 nodes=5 hidden=0,焦点层记录 target=taskTitle accepted=true。三条日志靠同一个 epoch 串联,比按时间猜测哪条回调属于哪次切换可靠得多。

发布前不应把"读屏测试"留给最后一天。组件新增按钮时,代码评审就要回答它在 FULL、COMPACT、HIDDEN 三种状态下的无障碍级别;修改标题或进度组合时,要检查是否造成重复播报;更改关闭逻辑时,要确认 epoch 是否失效。把这些问题放进评审模板,能减少上线前集中修补。

真实用户测试仍然不可替代。自动化可以发现隐藏节点和顺序变化,却无法完整判断文案是否自然、信息是否过载、用户是否能形成空间预期。演示中的"控制窗已收起,当前进度 68%,5 个可访问项目"适合调试,但正式文案可能不需要朗读项目数量。数量是工程证据,不一定是用户价值。产品文案应在不丢失状态的前提下尽量短。

最后再做一次成对检查:创建窗口与销毁窗口成对,注册监听与注销监听成对,进入语义事务与 epoch 失效成对,初始化焦点锚点与页面退出成对。闪控窗的问题常被误解为尺寸适配,实际上它更像一个短生命周期的状态容器。只有视觉、输入、语义和生命周期同时提交,紧凑态才是真正收敛,而不是把复杂度藏到屏幕之外。

本次演示的最终判断并不是"增加几行无障碍属性就结束",而是把语义树纳入窗口状态机。FULL、COMPACT、HIDDEN 各自拥有可核对的节点集合,形态变化拥有 epoch,焦点恢复只能消费最新快照,播报文本先经过合并再交给平台层。这样设计后,按钮增减、标题改版或任务状态扩展都能沿着同一条审计链检查,不必再靠测试人员偶然发现幽灵焦点。

如果团队只能先做一件事,建议先给紧凑态列出"应该被读到的五个节点",再逐项对照实现。明确的允许清单通常比不断修补排除规则更可靠。等节点集合稳定后,再增加焦点事务和诊断字段,问题会从难以描述的体验反馈,转化为可复现、可回归的工程差异。

官方资料(核对日期:2026-10-01):

相关推荐
李游Leo2 小时前
HarmonyOS 7 ArkUI Navigation + WindowStage:折叠屏窗口宽度驱动的单双栏切换与草稿状态保持【鸿蒙心迹】
华为·harmonyos
HwJack202 小时前
【共创稿事节】HarmonyOS 7材质与光照:真实感的营造与性能的平衡
pytorch·harmonyos·材质
翼辉cto2 小时前
otlinx-datetime 官方 KMP 日期时间库的 OpenHarmony 鸿蒙化适配实战
华为·harmonyos
m0_738185822 小时前
Flutter 鸿蒙化实战:flutter_iot_wifi 适配 OpenHarmony,IoT 设备 WiFi 配网
物联网·flutter·华为·harmonyos·鸿蒙
TMT星球2 小时前
华为Mate 90系列及全场景新品发布会举行,多款重磅新品亮相
华为
李游Leo3 小时前
HarmonyOS 7 + Hvigor-module.json5:权限增量审计与隐私声明一致性门禁【鸿蒙心迹】
数码相机·华为·harmonyos
李游Leo3 小时前
HarmonyOS 7 ArkTS + Node.js:把权限声明、运行时授权与隐私文案做成提审前一致性扫描【鸿蒙心迹】
华为·node.js·harmonyos
fellow994 小时前
把 DeepSeek Harness 搬进鸿蒙的 8 个坑
华为·harmonyos
垆边人似月.4 小时前
华为机考题(一):质数因子
算法·华为