
前阵子做 HarmonyOS 6 适配时我遇到一个很典型的无障碍问题自定义卡片在 TalkBack 下能朗读内容但用户双击之后完全没有反应。第一反应是事件绑定写错了后来把 onClick 改成无障碍动作问题当场解决。这个案例说明很多开发者对 ArkUI 无障碍事件Accessibility Event的理解还停留在“触摸事件”层面实际上它有自己的分发链路、动作注册机制和调试方式。HarmonyOS 6 的适配工作已经排上日程从镜像尝鲜到真机回归无障碍往往是最晚被注意到、却最容易翻车的一环。这篇文章我从事件链路讲起再把 ArkUI 侧真正能用的属性和 API 逐个说透最后给三个实战场景的完整代码和验证步骤。无论你是刚开始接触无障碍还是已经踩过几个坑应该都能在这篇文章里找到可以直接抄走的结论。1. 无障碍事件到底在做什么链路、分类和触发源头1.1 无障碍事件和触摸事件到底差在哪里很多人会把无障碍事件理解成“用代码模拟一次点击”这是最常见的误区。触摸事件是物理手势驱动的手指按下去、抬起来系统通过输入管道把事件发给当前触摸位置命中的组件。而无障碍事件是语义层面的指令它不关心你手指落在哪个坐标只关心当前无障碍焦点在哪个节点上然后要求这个节点执行一个动作。我用一个生活场景来说明普通点击像你直接上手按电梯楼层按钮无障碍事件则像你告诉旁边的人“我要去 12 楼”旁边的人替你去按电梯。对电梯来说它收到的是“目标楼层 12”这个语义指令而不是“某根手指在某坐标按下”。在 TalkBack 模式下用户单指双击屏幕系统并不会把这两次触摸原样转发给应用而是先判断当前焦点所在节点再向这个节点下发一个“激活”动作。这个动作就属于无障碍事件。所以在 ArkUI 里自定义组件如果只写了 onClick那它只处理了物理触摸路径。TalkBack 用户双击时系统走的是无障碍动作分发通道组件没注册对应的动作自然毫无反应。1.2 一次无障碍动作从服务端到组件的完整跨越完整的无障碍事件链路涉及三个角色无障碍服务端也就是 TalkBack、屏幕朗读这类读屏软件。它负责接收系统侧的各种语义信息并把用户手势翻译成动作指令。系统无障碍框架负责维护全局的无障碍节点树管理焦点分发动作和事件通知。应用侧的 ArkUI 组件树。每个支持无障碍的组件会被映射成无障碍节点节点上面挂着节点文本、描述、角色、状态和可执行动作列表。当用户执行手势时服务端把请求交给系统框架系统框架查一下当前焦点节点属于哪个应用然后通过跨进程通道把动作指令送到应用侧。ArkUI 会根据指令中的动作类型去节点的动作注册表里找到对应 handler 并执行。执行完毕再把结果返给系统框架由它决定是否播放音效、语音提示或者震动反馈。在应用侧这个节点注册表就是从 accessibilityActions 和相关回调来的。你注册了“click”系统就知道这个节点可以被激活你注册了“favorite”系统就知道这个节点还有一个叫“收藏”的自定义操作。1.3 常见无障碍事件分类动作、焦点、内容、自定义我习惯把无障碍事件分成四个大类这样排查问题时思路会清晰很多事件大类典型场景谁触发ArkUI 侧关键处理动作触发事件TalkBack 单指双击激活节点无障碍服务下发动作accessibilityActions 里的 handler焦点事件单指滑动把焦点从一个组件移到另一个组件系统焦点管理节点自身语义是否完整是否适合成为焦点内容更新事件文本加载完成、列表新增项、数值变化组件属性/内容变化更新无障碍文本或描述让读屏重新感知自定义动作事件用户在 TalkBack 自定义菜单里选择“收藏”“分享”服务端读取动作列表后由用户选择accessibilityActions 中自定义 type 的 handler动作触发事件是“系统让你干活”内容更新事件是“你主动告诉系统事情有变化”焦点事件则决定了读屏会不会找到你的组件。理解这层关系后再看 ArkUI 的 API 就不会觉得碎片化属性是给节点提供信息动作注册是让节点具备响应能力而内容更新是让节点信息保鲜。2. 动手前的 ArkUI 能力盘点从属性到动作 API2.1 信息语义属性先让读屏“说对”给节点提供正确的信息是一切无障碍事件能被正确触发的前提。ArkUI 通用属性里最核心的有四个accessibilityText节点的无障碍文本。读屏获得焦点时优先朗读这段文本。accessibilityDescription接入辅助性描述的组件属性通常解释节点用途在用户执行动作后由系统提示用途。accessibilityLevel节点在无障碍树中的存在级别支持“yes”“no”“no-hide-descendants”等值。accessibilityGroup是否将子节点合并为一个整体常用于让一组文本变成一个可朗读取的卡片。一个很容易犯的错是在描述里把节点上所有文本原样重复一遍。比如卡片上已经有“华为 Mate 60 Pro”和“¥6999”accessibilityText 又写了一遍同样的话。读屏会先读节点文本再读描述用户听到的是两遍完全一样的内容。正确的做法是描述里写操作用途比如“双击查看商品详情长按加入对比”。再看 accessibilityLevel 的三个值。设置成“yes”当前节点成为独立无障碍节点子节点如果也有该属性可能造成焦点过碎。设置成“no”当前节点自己不作为无障碍节点但子孙节点仍然保留。设置成“no-hide-descendants”当前节点及其所有子孙节点全部从无障碍树隐藏读屏完全找不到它们。最后这个值要慎用一旦用在列表容器上整页内容都可能失效。2.2 动作响应属性明确告诉系统“你可以对我做什么”节点光有信息不够一个纯展示的 Text 组件系统不会允许它被激活因为它的节点动作列表是空的。内置的 Button 会自动注册点击动作但自定义的 Column、Row、GridItem 这类容器组件默认只有滚动、焦点移动等系统通用动作没有“click”。想让它们能响应用户激活就得显式注册。ArkUI 的 accessibilityActions 属性接收一个数组数组项里有 type 和 handler。type 可以是系统标准动作也可以是自定义字符串handler 就是动作真正执行时的回调。Column() { Text(商品名称) Text(商品描述) } .accessibilityLevel(yes) .accessibilityText(商品卡片) .accessibilityDescription(双击查看商品详情) .accessibilityActions([ { type: click, handler: () this.showDetail() } ])这段代码注册了一个标准点击动作。用户用 TalkBack 把焦点移到这个卡片上单指双击系统就会触发这个 handler。这里的 handler 和 onClick 是两个通道的东西哪怕你在 Column 上同时写了 onClick那也只是触摸通道的逻辑不影响无障碍通道的执行。我建议动作 handler 保持轻量只做业务跳转或状态更新的入口不要在这里弹复杂的 Dialog也不要做耗时操作。读屏用户触发动作后系统可能还要根据动作执行结果给反馈如果 handler 卡顿语音反馈也会跟着延迟体验非常明显。2.3 容器的合并语义accessibilityGroup 怎么搭配事件当用户滑动焦点浏览页面时读屏按无障碍节点逐个朗读。如果页面里 Text 过多焦点会碎得很严重用户要刷十几下才能走完一个卡片。要解决这个问题可以用 accessibilityGroup(true) 把一组组件合并成一个可聚焦的整体。一旦合并焦点落在整个组上读屏朗读的文本来源就变成组的无障碍文本或子孙文本的聚合。这时组的动作也要跟着梳理清楚。比如一个卡片上有主按钮和副按钮合并后整个卡片是一个大焦点如果你注册了卡片的 click那么用户双击卡片只能执行一个动作副按钮就失去了入口。我的做法是能合并展示信息但不要把多个操作类子组件强行塞进一个 Group。如果确实要合并就把非主操作改成自定义动作放进无障碍菜单保证每个操作都有入口。.accessibilityGroup(true) .accessibilityActions([ { type: click, handler: () this.openMainAction() }, { type: more, handler: () this.openSubAction() } ])3. 实战一把自定义卡片从“能看见”改成“能操作”3.1 目标与页面结构设计假设现在有一个今日推荐卡片视觉上长这样卡片顶部是标题“今天吃什么”下面是推荐理由文字底部有两个按钮“选它”“换一个”。默认情况下TalkBack 会把这三个文本块识别成三个独立焦点用户浏览时非常累。我们想让用户一划就聚焦到整张卡片听到一句话“今日推荐卡片菜名是轻食沙拉理由是根据热量和口味推荐可用操作选它、换一个。”然后双击卡片的默认动作是“选它”再通过自定义菜单可以执行“收藏”或“换一个”。这个设计把多个操作放在同一个大焦点下既减少了焦点数量又保留了操作入口。关键在于合理使用 accessibilityGroup 和 accessibilityActions。3.2 逐步实现代码先看页面主体我用 ArkTS 写了如下结构Entry Component struct RecommendCardPage { State selected: string 轻食沙拉 State favorite: boolean false build() { Column({ space: 12 }) { Text(今天吃什么) .fontSize(18) .fontWeight(FontWeight.Bold) Text(根据热量和口味推荐一份轻食沙拉。) .fontSize(14) .fontColor(#666666) Row({ space: 8 }) { Button(选它) .onClick(() { this.selected 轻食沙拉 }) Button(换一个) .onClick(() { this.selected 换个口味推荐鸡胸肉套餐 }) Button(收藏) .onClick(() { this.favorite !this.favorite }) } } .padding(16) .borderRadius(12) .backgroundColor(#FFFFFF) .accessibilityLevel(yes) .accessibilityText(今日推荐卡片) .accessibilityDescription(this.selected (this.favorite ? 已收藏 : )) .accessibilityGroup(true) .accessibilityActions([ { type: click, handler: () { this.selected 轻食沙拉 } }, { type: 换一个, handler: () { this.selected 换个口味推荐鸡胸肉套餐 } }, { type: 收藏, handler: () { this.favorite !this.favorite } } ]) } }关键点有三个第一整个 Column 设置了 accessibilityGroup(true)三个文本块合并成一个语义整体读屏焦点不再被拆成三条。第二accessibilityText 给了这个整体一个名字accessibilityDescription 动态拼接当前选中内容这样状态变化时用户能通过焦点重读感知到。第三把“选它”绑定为默认 click 动作把“换一个”“收藏”绑定为自定义动作。这里有一处比 onClick 更严谨的地方Button 自带的 onClick 只是触摸事件无障碍通道并不一定会走它。我注册了自定义 handler 后不管用户是物理点击还是 TalkBack 激活状态变化路径都是同一条不容易出现两条路径状态不同步的问题。3.3 TalkBack 测试口令与预期表现完成代码后在真机上做验证的步骤是固定的。先在设置里打开语音无障碍服务比如 TalkBack。此时触摸模式会改变单指滑动变成浏览焦点单击变成朗读。测试口令如下单指从屏幕左侧向右滑动把焦点挪到卡片上。此时读屏播放“今日推荐卡片轻食沙拉已收藏”或等效的聚合文本。焦点停在卡片上时单指双击。卡片触发 click handler页面重新渲染状态保持“轻食沙拉”。继续滑动或使用读屏自定义菜单找到“换一个”“收藏”两个自定义动作。选择一个执行观察朗读内容是否反映状态变化。如果双击后完全没有语音反馈最可能的原因仍然是动作没注册上。我建议先在设备上用 hdc 拉取应用日志看 handler 内日志是否打印。如果 handler 打印了但是读屏没有反馈那就要检查节点描述是否更新或者组件是否被无心的条件渲染替换成了新节点。4. 实战二动态列表中焦点保持与内容变化播报4.1 动态列表的焦点流浪问题在 Feed 流、任务清单、聊天列表这类高频变化的场景里无障碍事件的难度会上升一个量级。动态列表新增或删除项时系统可能会把无障碍焦点留在原位置但那个位置的组件已经被 Recycler 复用成别的数据了读屏朗读的内容和用户看到的内容对不上。严重的时候焦点会直接掉到根节点读屏播报整页开头。我复现过最常见的一个场景任务清单里用户删除了当前焦点项焦点消失后读屏立刻播报“列表共 0 项”然后停顿。这时如果列表还在后台刷新用户完全不知道焦点去哪了。要稳定此场景需要做到删除当前焦点项后把焦点主动迁到相邻项或者明确的容器上。实现上我给 ListItem 设置了稳定的无障碍 level并给 List 外层施加 Container 查询。代码大致长这样List({ space: 8 }) { ForEach(this.tasks, (task: string, index: number) { ListItem() { Row() { Text(task) Button(完成) .onClick(() { this.removeTask(index) }) } } .accessibilityGroup(true) .accessibilityText(task) .accessibilityDescription(包含完成按钮) .accessibilityLevel(yes) }, (task: string) task) }这样每个 ListItem 都是独立的无障碍焦点不依赖默认的整列表焦点。删除发生后即使系统把焦点退回列表容器由于容器本身没有长文本读屏也只念“任务列表”不会念出一长串多余内容。4.2 让内容变化以无障碍事件形式发出来动态列表还有一类问题视觉上数据变了但读屏不知道。比如一项任务的进度从 30% 变成 60%用户焦点正好停在这项上读屏仍然读“30%”。原因在于如果组件的语义属性没有变化系统不会自动产生内容更新事件。解决方法是把动态语义数据和状态绑定让 accessibilityText 或 accessibilityDescription 跟着状态走。ArkUI 的声明式绑定会把属性值变化同步到无障碍节点节点内容一旦变化就有机会触发系统侧的内容更新通知。参考如下写法State progress: number 30 Text(任务进度) .accessibilityText(任务进度${this.progress}%)当 progress 变为 60 时无障碍节点的文本属性同步变化读屏再次聚焦或长按读取时就能得到新值。如果你希望用户不做任何操作就能听到“进度已更新到百分之六十”那需要配合系统播报能力在业务逻辑里触发无碍提示接口通常这类接口是系统能力的一部分应用侧按官方文档调用即可。4.3 列表容器的语义口径不用 no-hide-descendants动态列表还容易犯一个隐藏错误为了实现“整列表只读一次”有人会把 List 的 accessibilityLevel 设为 no-hide-descendants。这样确实把整个列表折叠成了一个节点但 ListItem 内的所有按钮、滑块、勾选框也跟着隐藏了操作入口全部消失。对读屏用户来说这种列表只能看不能碰功能等于残废。如果确实想让列表不至于焦点过多正确做法是让每个 ListItem 用 accessibilityGroup(true) 聚合再通过 accessibilityActions 暴露该条目的核心操作。整个 List 容器保持默认就好。容器默认情况下负责条目间的焦点分组不会替代条目本身。5. 无障碍事件的调试观察方法和易犯错误5.1 用日志把事件的来龙去脉映射出来无障碍事件最难调试的点在于它不产生视觉变化也不能像打断点那样盯着触摸坐标。我自己习惯在动作 handler 入口和出口都打日志先确定“系统有没有把事件送进来”再确定“我的逻辑有没有执行完”。用 hdc 抓取日志时可以按关键字过滤hdc shell hilog | grep -E Accessibility|a11y|RecommendCard如果你在 handler 里打了 log却一直等不到多半是节点身份不对。比如你把动作注册在 Column 上但当前焦点实际停在子 Button 上事件就会发给 Button 的可访问节点而 Button 又没有这个自定义动作事件就被吞了。我排查事件去向的顺序是这样先确认焦点是否正确落在注册动作的组件上再确认 accessibilityLevel 没有被设置成隐藏最后确认 handler 是否有异常抛出。大多数“没反应”的问题走到第二步就能定位。5.2 真机测试优先级焦点、激活、自定义操作我经常看到有人只打开 TalkBack用单指双击试了一下就结束测试。这远远不够。我建议三次真机测试按优先级执行第一次只测焦点流转。单指滑动浏览整个页面看焦点顺序是否和视觉阅读顺序一致中途有没有意外隐藏的节点、重复朗读的节点。第二次测默认激活。焦点停在每个可操作节点上用单指双击执行默认动作确认业务逻辑执行成功并且读屏有明确的语音反馈。第三次测自定义操作。在 TalkBack 自定义菜单里逐个执行自定义动作重点检查动作完成后焦点是否仍然可控、朗读是否断掉。这三次测试都过了才算把无障碍事件的链路跑通。5.3 常见误区和错误对照表下面这几个问题我在实际项目里反复遇到过列成表便于对照。错误表现真正原因正确做法TalkBack 双击自定义卡片无反应组件没有注册 click 动作accessibilityActions 里增加 type 为 click 的 handler读屏把所有文字拆成多个焦点容器内多个 Text 组件各自可访问对容器设置 accessibilityGroup(true)设置了 no-hide-descendants 后按钮全部消失该值隐藏了所有子孙节点改为容器默认或者只把单独组件 level 设为 no状态更新后读屏仍旧读旧值语义属性没有随状态变化刷新让 accessibilityText/Description 绑定状态变量自定义动作名称是英文但菜单显示正常type 作为动作标识显示文本由服务端决定自定义动作的显示与处理统一命名避免歧义还有一个常被忽略的细节同一个节点如果既注册了 click又注册了语义上重叠的自定义动作用户会混淆。比如 click 是“收藏”自定义动作里又有一个“收藏”两个入口执行逻辑却不一致那就等着被用户投诉。我习惯让 click 只负责最主要的激活行为其他辅助行为全部走自定义动作。6. 一份可以长期复用的无障碍事件自查清单6.1 提交前自查表建议把以下清单放进你的代码评审模板里每次涉及自定义组件、列表、表单页时逐项过一遍检查项通过标准所有可点击组件都有无障碍动作入口焦点在该节点时TalkBack 双击能执行主操作焦点数量合理信息性组件已用 accessibilityGroup 合并无隐藏内容的误用没有 no-hide-descendants 导致关键操作消失动态内容语义刷新状态变化后读屏能获取到最新文本自定义动作与默认动作无重叠两个动作语义不冲突执行逻辑一致文本描述不重复朗读结果自然不出现连续两遍相同内容这套清单不需要每天全部执行但每个交互组件改版后必须抽查一条主线焦点从页面顶部滑到底部过程中所有关键操作都能被双击激活。6.2 把无障碍事件带进日常开发的流程里最后说一点项目级经验。无障碍适配最怕的不是接口复杂而是项目末期才开始做。到时候节点层级已经堆得很深语义属性补起来牵一发动全身。我现在会在需求评审时就把无障碍事件方案写进去特别是自定义组件和动态列表直接标注出“焦点策略”和“动作列表”两栏。开发自测阶段跑一遍 TalkBackCode Review 阶段过一遍自查表能省掉后期至少一轮返工。真正上手之后你会发现无障碍事件并不玄乎它就是把视觉上“能不能点、点了干什么、变化了没有”用语义化的语言告诉读屏组件。只要把链路理清把动作注册完整剩下的就是在真机上多划几次焦点、多双击几轮而已。