ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

屏幕朗读只会“点击”,收藏和加入清单怎么办:HarmonyOS 7 accessibilityCustomActions 实战

屏幕朗读只会“点击”,收藏和加入清单怎么办:HarmonyOS 7 accessibilityCustomActions 实战

屏幕朗读只会“点击”,收藏和加入清单怎么办:HarmonyOS 7 accessibilityCustomActions 实战

一道菜谱卡片在视觉上往往同时提供“打开详情、收藏、加入购物清单、稍后提醒”四个动作。普通用户可以直接点右上角图标,开启屏幕朗读的用户却常常只听到“宫保鸡丁,按钮,双击激活”。如果把四个图标分别暴露成焦点,用户又要在每张卡片里连续滑动四次,十张卡就是四十多个焦点,列表很快变得难以浏览。

HarmonyOS 7 对应的 API 26 新增了 AccessibilityCustomAction 和通用属性 accessibilityCustomActions。它允许组件在保留主要点击动作的同时,再向无障碍服务声明一组有名称、有回调的自定义操作。本文用“中式美食”的菜谱卡和备菜清单做两个不同案例,给出动作构建、动态刷新、条目复用、幂等处理、日志和宿主断言。

当前机器只有 API 24 SDK,且没有连接 HDC 设备。下列 ArkTS 写法依据当前官方 API 26 文档整理;纯 TypeScript 断言只验证动作目录、条目身份和幂等边界,不声称已经完成 API 26 编译、屏幕朗读手势或真机播报验证。

先复现:两个极端都不好用

准备 12 张菜谱卡,每张卡有标题、耗时、收藏图标和“加入清单”按钮。打开屏幕朗读后分别测试两种旧实现:

旧实现焦点表现用户损失
整张卡只设一个点击动作每张卡只有一个焦点收藏、加入清单等次要动作不可发现
卡片内每个图标都是独立焦点一张卡出现 3 至 5 个焦点浏览长列表需要大量重复滑动

问题不在于有没有 accessibilityText,而在于“一个可浏览对象如何暴露多个可执行动作”。标题和状态应该让用户先理解这张卡,主要点击仍负责打开详情;收藏、加入清单等操作则进入自定义操作菜单,避免把列表拆成过多焦点。

API 26 的能力边界

官方基础类型定义中,AccessibilityCustomAction 从 26.0.0 开始提供,包含两个必填字段:

  • name:自定义操作名称,用于标识动作并绑定回调;文本长度不应超过官方限制。
  • onAction:执行该动作时调用的 VoidCallback。

组件通过 accessibilityCustomActions 接收 AccessibilityCustomAction 数组。这里有四个工程边界必须提前写进验收:

  1. 自定义操作是“补充动作”,不应替代组件清晰的主要角色和主要点击。
  2. 名称必须短、具体、可区分,不能都叫“更多操作”。
  3. 列表条目复用时,回调必须绑定稳定业务 ID,不能依赖当前可视索引。
  4. 动作集合应随收藏、库存、登录和业务状态变化及时重建;过期动作不应继续显示。

每个组件的动作数量也应受控。官方接口对数组数量设有上限,业务侧不要贴着上限堆功能;菜谱卡保留 2 至 4 个高频动作即可,低频设置仍放进详情页。

案例一:菜谱卡保留一个焦点,暴露三个补充动作

先定义稳定的业务模型。动作回调只携带 recipeId,不把整个用户对象、图片地址或账号数据塞进日志。

interfaceRecipeCardModel{recipeId:string;title:string;durationText:string;favorite:boolean;inShoppingList:boolean;reminderEnabled:boolean;}typeRecipeActionKind='favorite'|'shopping-list'|'reminder';interfaceRecipeActionEvent{recipeId:string;kind:RecipeActionKind;}

然后把“当前状态允许哪些动作”收口在一个构建函数中。收藏后名称变成“取消收藏”,已加入购物清单后变成“移出购物清单”,用户听到的是下一步会发生什么,而不是当前状态的重复描述。

functionbuildRecipeActions(item:RecipeCardModel,dispatch:(event:RecipeActionEvent)=>void):AccessibilityCustomAction[]{return[{name:item.favorite?'取消收藏':'收藏菜谱',onAction:()=>dispatch({recipeId:item.recipeId,kind:'favorite'})},{name:item.inShoppingList?'移出购物清单':'加入购物清单',onAction:()=>dispatch({recipeId:item.recipeId,kind:'shopping-list'})},{name:item.reminderEnabled?'关闭备菜提醒':'设置备菜提醒',onAction:()=>dispatch({recipeId:item.recipeId,kind:'reminder'})}];}

在卡片根节点上同时提供名称、角色、主要点击和自定义操作。图标仍可供视觉用户直接点击,但是否继续作为独立无障碍焦点,要根据组件聚合方案验收,不能让同一个动作在焦点树中重复两次。

@Componentstruct AccessibleRecipeCard{@ObjectLinkitem:RecipeCardModel;onOpen:(recipeId:string)=>void=()=>{};onAction:(event:RecipeActionEvent)=>void=()=>{};privategetDescription():string{constfavoriteText=this.item.favorite?'已收藏':'未收藏';constlistText=this.item.inShoppingList?'已加入购物清单':'未加入购物清单';returnthis.item.title+','+this.item.durationText+','+favoriteText+','+listText;}build(){Row({space:16}){Image($r('app.media.recipe_placeholder')).width(96).height(72).objectFit(ImageFit.Cover).accessibilityLevel('no')Column({space:8}){Text(this.item.title).fontSize(20).fontWeight(FontWeight.Medium)Text(this.item.durationText).fontSize(14)}.alignItems(HorizontalAlign.Start).layoutWeight(1)}.width('100%').padding(16).accessibilityGroup(true).accessibilityRole('button').accessibilityText(this.getDescription()).accessibilityDescription('双击打开详情,可使用更多操作').accessibilityCustomActions(buildRecipeActions(this.item,this.onAction)).onClick(()=>this.onOpen(this.item.recipeId))}}

这段代码要在 API 26 工程中核对最终类型签名和角色取值。图片被设置为不参与焦点,是因为卡片根节点已经完整描述了菜名和状态;若图片本身承载额外信息,例如过敏原图标,则不能简单禁用,需要把等价文本合并到根节点描述中。

怎么验证菜谱卡

  1. 左右滑动列表时,一张卡只形成一个主要焦点。
  2. 聚焦“宫保鸡丁”后,能听到菜名、耗时、收藏和清单状态。
  3. 打开自定义操作菜单,能发现“收藏菜谱、加入购物清单、设置备菜提醒”。
  4. 执行收藏后再次打开动作菜单,名称变为“取消收藏”。
  5. 双击卡片仍然打开详情,自定义动作不会抢走主要点击。

不要让回调抓住错误的列表索引

LazyForEach、筛选和排序会改变可视索引。如果回调闭包保存 index=3,用户在执行动作前又切换了“只看快手菜”,第 3 项可能已经不是原来的菜谱。回调应该带稳定 recipeId,处理层再查当前实体。

classRecipeActionController{privatepending=newSet<string>();asynchandle(event:RecipeActionEvent):Promise<void>{constoperationKey=event.recipeId+':'+event.kind;if(this.pending.has(operationKey)){return;}this.pending.add(operationKey);try{constcurrent=RecipeRepository.findById(event.recipeId);if(!current){console.warn('[a11y-action] item-missing id='+event.recipeId);return;}switch(event.kind){case'favorite':awaitRecipeRepository.setFavorite(event.recipeId,!current.favorite);break;case'shopping-list':awaitRecipeRepository.setInShoppingList(event.recipeId,!current.inShoppingList);break;case'reminder':awaitReminderService.setEnabled(event.recipeId,!current.reminderEnabled);break;}}finally{this.pending.delete(operationKey);}}}

pending 只挡同一条目同一动作的重复执行,不会阻塞用户给另一道菜加收藏。真正写入仓储后,页面状态更新,buildRecipeActions 才会生成反向动作名称。

案例二:备菜清单只暴露当前可执行动作

第二个案例不是菜谱卡换文案,而是状态机不同的备菜清单。条目可能处于 todo、done 或 unavailable。完成项可以“恢复待办”,缺货项可以“查看替代食材”,但不应继续暴露“标记完成”。

typePrepState='todo'|'done'|'unavailable';interfacePrepItem{itemId:string;name:string;amount:string;state:PrepState;}interfacePrepAction{itemId:string;action:'complete'|'restore'|'replace'|'remove';}functionbuildPrepActions(item:PrepItem,dispatch:(event:PrepAction)=>void):AccessibilityCustomAction[]{constactions:AccessibilityCustomAction[]=[];if(item.state==='todo'){actions.push({name:'标记已备齐',onAction:()=>dispatch({itemId:item.itemId,action:'complete'})});}elseif(item.state==='done'){actions.push({name:'恢复为待备',onAction:()=>dispatch({itemId:item.itemId,action:'restore'})});}else{actions.push({name:'查看替代食材',onAction:()=>dispatch({itemId:item.itemId,action:'replace'})});}actions.push({name:'移出备菜清单',onAction:()=>dispatch({itemId:item.itemId,action:'remove'})});returnactions;}

这里的重点是“动作目录来源于当前状态”,而不是在页面初始化时生成一次后永远缓存。条目从 todo 变为 done 后,旧的“标记已备齐”必须消失;否则朗读菜单与业务状态发生冲突,用户会遇到看不见也解释不清的失败。

删除类动作还要遵守产品的二次确认策略。accessibilityCustomActions 的回调可以打开确认弹窗,但不应在动作菜单里一选中就静默删除重要数据。

怎么验证备菜清单

初始状态应出现的动作不应出现的动作
todo标记已备齐、移出备菜清单恢复为待备、查看替代食材
done恢复为待备、移出备菜清单标记已备齐
unavailable查看替代食材、移出备菜清单标记已备齐、恢复为待备

验证时先聚焦条目,再从另一个入口改变状态,回到条目重新打开动作菜单。若仍显示旧动作,说明状态刷新、组件复用或对象观察没有闭环。

业务侧先做动作目录预检

设备无障碍菜单由系统负责,但业务可以先用纯函数检查名称、数量和动作键,避免把明显错误送进组件。

interfaceActionSpec{key:string;label:string;enabled:boolean;}interfaceActionAudit{accepted:ActionSpec[];rejected:string[];}functionauditActionSpecs(input:ActionSpec[],maxCount:number=16):ActionAudit{constaccepted:ActionSpec[]=[];constrejected:string[]=[];constkeys=newSet<string>();constlabels=newSet<string>();for(constitemofinput){constlabel=item.label.trim();if(!item.enabled){rejected.push(item.key+':disabled');continue;}if(!item.key||!label){rejected.push((item.key||'empty')+':missing-name');continue;}if(keys.has(item.key)||labels.has(label)){rejected.push(item.key+':duplicate');continue;}if(accepted.length>=maxCount){rejected.push(item.key+':over-limit');continue;}keys.add(item.key);labels.add(label);accepted.push({...item,label});}return{accepted,rejected};}

这个函数不模拟系统的手势、菜单或播报,只做业务清单审计。名称字节长度仍应按官方文档和资源字符串实值在 API 26 环境中检查,不能用 JavaScript 字符数替代 UTF-8 字节数。

六组宿主断言

constaudited=auditActionSpecs([{key:'favorite',label:'收藏菜谱',enabled:true},{key:'shopping',label:'加入购物清单',enabled:true},{key:'reminder',label:'设置备菜提醒',enabled:true}]);if(audited.accepted.length!==3)thrownewError('正常动作被拒绝');constduplicateKey=auditActionSpecs([{key:'favorite',label:'收藏菜谱',enabled:true},{key:'favorite',label:'再次收藏',enabled:true}]);if(!duplicateKey.rejected.includes('favorite:duplicate'))thrownewError('重复键未拦截');constduplicateLabel=auditActionSpecs([{key:'favorite',label:'收藏',enabled:true},{key:'pin',label:'收藏',enabled:true}]);if(!duplicateLabel.rejected.includes('pin:duplicate'))thrownewError('重复名称未拦截');constdisabled=auditActionSpecs([{key:'remove',label:'移除',enabled:false}]);if(disabled.accepted.length!==0)thrownewError('禁用动作仍被接受');constmissingName=auditActionSpecs([{key:'replace',label:' ',enabled:true}]);if(!missingName.rejected.includes('replace:missing-name'))thrownewError('空名称未拦截');constoverLimit=auditActionSpecs([{key:'a',label:'动作 A',enabled:true},{key:'b',label:'动作 B',enabled:true},{key:'c',label:'动作 C',enabled:true}],2);if(!overLimit.rejected.includes('c:over-limit'))thrownewError('数量边界未拦截');

建议记录的诊断信息

[a11y-action] surface=recipe-card itemId=mapo-tofu action=favorite stateBefore=false result=success stateAfter=true catalogVersion=17

catalogVersion 用于确认用户执行的动作目录是否来自当前状态;itemId 用于确认列表复用后没有串项。日志不要记录账号、语音内容、完整购物清单或用户备注。

七个高频错误

1. 所有动作都叫“更多操作”

屏幕朗读菜单无法区分结果。名称应直接描述下一步,例如“取消收藏”“加入购物清单”。

2. 既暴露图标焦点,又在根节点重复同一动作

用户会连续听到两个“收藏”。组件聚合后要检查最终焦点树,而不是只看代码里每个节点都写了属性。

3. 回调保存可视 index

筛选、排序和 LazyForEach 复用后 index 会变化。回调必须绑定稳定业务 ID,并在执行时重新读取实体。

4. 动作执行后不重建目录

收藏成功后仍然显示“收藏菜谱”,第二次执行会产生歧义。状态更新、动作名称和播报反馈必须同一轮闭环。

5. 把十几个低频功能都塞进菜单

接口允许多动作不等于产品应该堆满。卡片只保留高频且上下文明确的动作,其余进入详情页。

6. 以宿主断言代替屏幕朗读验证

纯函数只能证明目录规则,不能证明系统菜单、焦点、播报顺序、资源多语言或手势行为。必须在 API 26 环境补设备证据。

7. 删除动作没有确认

自定义操作降低了触发成本,也放大误操作风险。不可逆删除要进入确认流程,并在返回后恢复合理焦点。

可复用目录

features/accessibility-actions/ AccessibleRecipeCard.ets # 卡片主要焦点和自定义操作 RecipeActionFactory.ets # 根据状态构建动作目录 RecipeActionController.ets # 稳定 ID、幂等与仓储写入 AccessiblePrepItem.ets # 备菜状态机案例 ActionCatalogAudit.ts # 名称、去重、数量预检 action_catalog_audit.test.ts # 六组宿主断言 docs/device-evidence.md # API 26 焦点、菜单和播报证据

上线前检查表

  • 卡片保留清晰的主要角色、主要点击和简洁状态描述。
  • 自定义动作名称说明“执行后会发生什么”,没有笼统的“更多操作”。
  • 同一动作没有同时出现在根节点菜单和重复图标焦点中。
  • 回调绑定 recipeId/itemId,不绑定可视 index。
  • 收藏、清单、提醒和备菜状态变化后,动作目录同步刷新。
  • 重复键、重复名称、禁用动作、空名称和数量边界通过宿主断言。
  • 删除、清空等高风险动作仍有明确确认步骤。
  • 中英文和资源字符串都核对名称长度与可理解性。
  • API 26 工程完成类型检查,设备完成焦点、菜单、播报和返回位置验证。
  • 证据记录不包含账号、语音原文、完整清单等敏感信息。

验证边界

本文依据华为当前通用无障碍属性、AccessibilityCustomAction 基础类型、屏幕朗读体验指南、开发者月刊与 HarmonyOS 7 / API 26 升级适配资料整理。宿主断言只验证业务动作目录,不代替 API 26 编译、系统自定义操作菜单、屏幕朗读播报、资源多语言、设备焦点或真实仓储联调。

官方资料

  • 通用属性中的无障碍属性
  • 基础类型定义:AccessibilityCustomAction
  • 提升屏幕朗读无障碍体验
  • 2026 年 7 月开发者月刊
  • HarmonyOS 7 / API 26 升级适配指南

结论

菜谱卡的无障碍问题不是“多写一句 accessibilityText”就结束了。真正要解决的是:列表保持低焦点密度,同时让收藏、购物清单和提醒等次要动作可发现、可执行、可回退。accessibilityCustomActions 提供了 API 26 的平台入口,业务仍要负责稳定 ID、状态驱动目录、幂等写入和风险确认。把动作目录当成一份可测试的状态产物,屏幕朗读用户才不会在“只能打开”和“每张卡滑四次”之间二选一。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进