双栏页面“看起来没有跳”不代表读屏焦点也没有跳。SplitFocusLab 这次没有改右栏路由栈,而是专门处理列表、详情、弹层关闭和单双栏切换后的焦点归属。

一、看得见的双栏,听起来却像两张页面

问题出现在一次平行视界验收。页面左侧是 24 个商品,右侧是当前商品详情。视觉上,点击“云杉耳机”以后右栏标题和参数都更新了;打开屏幕朗读再操作,系统却继续读左栏下一项。有时焦点会跳到右栏底部的“加入比较”,有时切回单栏后又从页面顶端重新开始。

这不是路由栈错误。右栏内容确实已经切换,选中态也正确,问题在于三套状态没有一起走:业务选中项、键盘焦点和无障碍焦点。过去的代码只更新 selectedId,默认系统会自动找到最合适的节点。对普通触控用户,这个假设大多成立;对键盘和读屏用户,页面更新反而像被悄悄换走了半张地图。

Demo 叫 SplitFocusLab,本轮任务 ID 固定为 a11y_20261001_19。验收脚本执行 96 次焦点转移,覆盖点击列表、右栏打开、规格弹层关闭、双栏变单栏、单栏恢复双栏五条路径。改造前错栏 14 次、重复播报 11 次;改造后两项归零,24 个商品恢复锚点全部命中,焦点恢复耗时 p95 为 42 ms。

二、先把“焦点”拆成可记录的状态

最早的修复是在点击事件里直接调用 requestFocus('detailTitle'),很快又失效了。因为详情标题不是永久节点:商品变化会重建内容,单双栏切换会改变组件树,弹层关闭时原来的触发按钮也可能已被回收。一个字符串 key 不带页面代次和内容版本,恢复时就不知道它是否仍有效。

下面这段代码解决恢复锚点过期的问题。锚点按左右窗格分别保存,并携带内容版本;详情更新后,旧版本锚点不能继续使用。

enum Pane { LIST = 'LIST', DETAIL = 'DETAIL' }

interface FocusAnchor {
  key: string
  contentVersion: number
  itemId: string
}

export class FocusAnchorStore {
  private anchors = new Map<Pane, FocusAnchor>()

  remember(pane: Pane, anchor: FocusAnchor): void {
    this.anchors.set(pane, anchor)
  }

  resolve(pane: Pane, version: number, fallback: FocusAnchor): FocusAnchor {
    const saved = this.anchors.get(pane)
    if (!saved || saved.contentVersion !== version) return fallback
    return saved
  }

  clear(pane: Pane): void {
    this.anchors.delete(pane)
  }
}

用户在左栏商品行上操作时,Store 记录 list_item_p7;右栏进入详情后记录 detail_title_p7。数据变化不是简单的“当前 key”,而是“某个窗格、某个内容版本、某个业务对象的可恢复节点”。商品从 p7 切到 p8,详情版本递增,p7 的右栏锚点自动失效,回退到 p8 标题。

Store 放在页面级 ViewModel 中,页面销毁时整体释放;不能做进程全局单例,否则再次进入页面可能恢复到上次会话的旧商品。remember() 可以重复调用,但只有真正获得焦点的节点才有资格写入。鼠标经过、列表预加载或组件 onAppear 都不能冒充用户焦点,否则锚点会被离屏节点污染。

三、右栏更新以后,下一帧再交接

点击列表后立刻请求右栏标题获焦,第一次常常返回失败。原因并不神秘:selectedId 已更新,但新详情节点可能还没有完成构建。ArkUI 的焦点控制区分当前帧和后续帧,平行视界又会叠加窗格结构更新,因此焦点交接必须有代次,且只能接受最新一次点击。

这段代码解决快速点击时的焦点串台。协调器先更新业务状态,再把焦点请求安排到下一帧;如果用户已经点了另一个商品,旧请求即使执行也会被代次拦住。

export class PaneFocusCoordinator {
  private generation = 0
  private focusedPane: Pane = Pane.LIST

  enterDetail(productId: string, onSelect: (id: string) => void): void {
    const current = ++this.generation
    onSelect(productId)
    const targetKey = `detail_title_${productId}`

    setTimeout(() => {
      if (current !== this.generation) return
      const moved = focusControl.requestFocus(targetKey)
      if (moved) this.focusedPane = Pane.DETAIL
      hilog.info(0x0000, 'SplitFocusLab',
        `focus target=${targetKey} moved=${moved} generation=${current}`)
    }, 0)
  }

  invalidate(): void {
    this.generation++
  }
}

当前实现用零延迟任务表达“等新节点进入组件树”,工程里也可以把请求放到明确的渲染完成回调中。关键不在定时器,而在代次:连续点 p7、p8,只有 p8 对应的请求能改变焦点。状态从 LIST_FOCUSED 进入 DETAIL_OPEN,标题成功获焦后才进入 DETAIL_FOCUSED,不能在 onSelect 刚执行时提前宣布成功。

易错点是把键盘焦点和无障碍播报混成同一件事。focusControl.requestFocus 管理 ArkUI 焦点,主动读屏焦点需要按 Accessibility Kit 的事件语义处理;页面不应在每次状态变化时都强行播报,否则会和系统正常走焦重复。SplitFocusLab 只在窗格语义发生变化、用户需要上下文时发一次说明,其余内容交给组件的可访问属性和自然顺序。

四、弹层关闭要回到触发点,不是页面顶部

详情页的规格弹层是第二个麻烦。关闭弹层后,系统偶尔把焦点送到右栏第一个可聚焦节点,有时又回到左栏。用户听到的结果像是操作被重置。我们给每次弹层打开都登记 returnAnchor,关闭时先验证触发节点仍属于当前商品;若商品已切换,则回到当前详情标题。

下面的代码解决弹层关闭、单双栏切换和节点消失三个边界。恢复不是盲目请求旧 key,而是逐级选择合法目标。

interface RestoreContext {
  paneMode: 'SINGLE' | 'SPLIT'
  productId: string
  detailVersion: number
  triggerKey?: string
}

export class FocusRestorer {
  restore(ctx: RestoreContext): string {
    const detailFallback: FocusAnchor = {
      key: `detail_title_${ctx.productId}`,
      contentVersion: ctx.detailVersion,
      itemId: ctx.productId
    }
    const saved = anchorStore.resolve(Pane.DETAIL,
      ctx.detailVersion, detailFallback)
    const target = ctx.triggerKey ?? saved.key

    const finalKey = ctx.paneMode === 'SPLIT'
      ? target : `single_detail_title_${ctx.productId}`
    focusControl.requestFocus(finalKey)
    return finalKey
  }
}

双栏下优先回触发按钮,触发点不存在则回详情标题;单栏下使用单栏节点命名空间,避免请求已经被移除的右栏节点。恢复完成后状态进入 FOCUS_RESTORED,并写入实际 finalKey,而不是只记计划目标。本次样本的最终锚点是 detail_title_p7。

实际产品还要处理页面离开。aboutToDisappear 中应调用协调器 invalidate(),取消在途焦点请求,并停止无障碍事件订阅;否则返回上一页后,旧任务可能把焦点拉回已经不可见的组件。重复关闭弹层也要幂等,第一次恢复后立即清除 triggerKey,第二次回调不能再播报或再次抢焦。

五、DevEco 日志比“我听着正常”可靠

测试无障碍最危险的说法是“我操作一遍听着没问题”。96 次脚本里,错误集中在快速连点和分栏切换,慢速手工操作几乎复现不了。SplitFocusLab 给每次转移分配序号,记录来源窗格、目标 key、内容版本、代次、请求结果和最终节点,才能把一次错栏还原出来。

工程目录按 focus、accessibility、pages 和 model 拆分。中间编辑器显示 FocusRestorer,右侧模拟器固定展示 p7 商品和当前锚点,底部 HiLog 使用同一任务 ID 与状态机。

最终结果为:商品 24,焦点转移 96;错栏 14 → 0,重复播报 11 → 0,恢复命中 24/24,p95 42 ms;状态流 LIST_FOCUSED → DETAIL_OPEN → DETAIL_FOCUSED → FOCUS_RESTORED,锚点 detail_title_p7。

六、读屏顺序不是视觉顺序的副产品

双栏从左到右排列,不意味着辅助服务一定按业务期望朗读。我们把左栏定义为浏览区,右栏定义为当前详情区;列表行内部把图片、名称和价格组合成一个有意义的节点,装饰图标不进入语义树。右栏标题先于参数和操作按钮,价格变化使用克制的状态播报,避免每次局部刷新都打断用户。

当分栏比例被拖动时,视觉尺寸变化不应触发重新聚焦。只有窗格从存在变为不存在、当前业务对象改变、弹层关闭这三类事件可以启动恢复。把所有 onAreaChange 都接到 requestFocus,会造成拖动期间连续抢焦,也会让读屏不断重读标题。

验收还要覆盖键盘与读屏同时开启的情况。两者可以共享业务锚点,却不能假设始终落在同一内部对象上。页面层保存的是“用户当前在哪个业务位置”,具体由键盘焦点控制器和无障碍事件适配器各自执行。这样后续替换组件或调整语义分组,不会把业务状态绑死在某个实现细节上。

七、这次真正修的不是一个 requestFocus

问题表面是焦点跳错,根因是页面只保存了视觉状态。把业务选中项、窗格归属、焦点锚点、内容版本和请求代次放进同一条链路后,平行视界才对非触控用户保持连续。

这套实现不会保证所有自定义组件天然可访问。组件仍需提供准确名称、角色、状态和操作,动态内容也要控制播报频率。它解决的是多窗格切换时“焦点去哪儿、何时去、旧请求还能不能去”的工程边界。

后续增加第三个比较窗格时,不需要继续在点击事件里堆判断,只需新增 Pane、定义默认锚点和允许的转移关系。对我来说,这才是本次复盘最有价值的结果:无障碍不是页面最后加几个属性,而是和路由、状态、生命周期一样,需要可记录、可恢复、可验证。

1. 焦点契约写进组件边界

复盘以后,我给 SplitFocusLab 的组件补了一份焦点契约。列表行承诺自己拥有稳定 key、业务 itemId 和完整可访问名称;详情容器承诺在标题节点可用后通知协调器;弹层承诺打开前登记触发点,关闭后只发一次恢复请求。页面只负责窗格模式与内容版本,不再猜每个子组件内部哪个按钮应该获焦。

这份契约解决了一个长期问题:组件重构时,视觉测试可能全部通过,焦点 key 却悄悄改名。现在 key 不是随手写在 build 中的字符串,而是由 FocusKeyFactory 根据窗格、角色和 itemId 生成。代码评审看到组件角色变化,就会同步检查恢复策略;自动化测试也能枚举出预期节点,而不需要读取具体文案。

2. 验收要覆盖节点消失

正常路径只能证明目标存在时能聚焦,真正容易出错的是目标消失。我们额外做了四组测试:当前商品被筛选移除、详情加载失败、弹层打开时切到另一个商品、双栏收窄为单栏。每组都要求恢复器走到明确 fallback,并在日志里写出 reason,不能静默回页面顶部。

例如 p7 被筛选掉后,左栏锚点不能继续指向 list_item_p7,而应落到相邻可用项;右栏若展示空状态,则焦点落到空状态标题,并给出下一步操作。这样用户听到的不是突然沉默,而是知道内容已经变化。这个分支同样要清理旧锚点,否则重新放宽筛选条件时,过期记录可能再次被命中。

3. 重复播报如何归零

11 次重复播报来自两个入口:详情标题获得焦点时系统自然朗读一次,业务层又发送一次“已打开详情”;弹层关闭后按钮获得焦点,页面又播报“已返回”。修复并不是简单删掉全部主动事件,而是区分上下文是否已经由焦点节点表达。标题文本足够说明位置时不额外播报;只有异步错误、筛选导致当前项消失这类无法从焦点名称获知的状态,才发送一次状态说明。

为了防止相同状态短时间重复,播报适配器使用 eventType + itemId + contentVersion 作为签名,并设置很短的去重窗口。窗口只抑制完全相同事件,不合并不同错误。页面销毁时清理计时器和最近签名,否则下一次进入页面可能错误吞掉第一条有效说明。

4. 图上的 42 ms 代表什么

p95 42 ms 从业务选中状态更新开始,到目标节点报告实际获焦结束。它不是点击到页面绘制完成的总耗时,也不是读屏语音播放时长。把口径写清楚很重要,否则后续有人优化动画,指标没变化,就会误以为焦点链路没有收益。

自动化脚本每次记录计划目标和实际目标;只有两者一致才算成功。请求接口返回 true 只表示请求被接受,不能替代最终节点验证。96 次转移还会按路径分桶,避免大量简单的列表移动稀释少量高风险的分栏切换。这样 14 → 0 才是可复查的工程结果,而不是演示页面上的绿色数字。

八、参考资料

  • HarmonyOS 平行视界与多设备适配:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
  • ArkUI 焦点控制:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V14/ts-universal-attributes-focus-V14
  • Accessibility Kit 概述:https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V5/accessibilitykit-overview-V5
Logo

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

更多推荐