上周有个做鸿蒙版App的兄弟找我,说他们客户端用React Native搭的,功能都正常,但无障碍适配一直没排上日程。结果领导拿测试机开了屏幕朗读,在首页点了几下,页面上的内容跟哑巴一样,一个字都不念。他当场慌了,回来问我:RN写的页面到底能不能让鸿蒙朗读出来?
答案是能,但这里面的路比你想象的要绕。我从那之后专门把RN鸿蒙这套无障碍链路捋了一遍,包括JS层能用哪些API、鸿蒙系统侧怎么处理播报事件、哪些情况必须掉头写原生桥接。这篇文章就把完整链路、实现步骤和实测踩坑一次说清楚。不管你是刚把RN项目跑到鸿蒙上,还是已经在线上被"朗读功能"这个需求反复折磨,下面的内容应该都能直接派上用场。
1. 先把卡点定位清楚:RN鸿蒙做朗读,难在链路太长
1.1 RN在鸿蒙上的"存在形式"决定了一切
很多人不知道,现在"React Native跑鸿蒙"并不是一个单一的东西。市面常见的有两个方向:一个是OpenHarmony社区维护的react-native-ohos适配方案,另一个是部分厂商基于自身系统做的RN适配分支。两者维护节奏、API补齐程度都不一样。这就带来了第一个现实问题:你在网上搜到的RN无障碍文章,大概率是Android或iOS的,直接搬到鸿蒙上,一半的API是失效的。
我接触到的多数团队,最终都是选OpenHarmony方向的RN适配。这个方案有一个好处:它把RN的JS层API和ArkUI组件做了映射,很多组件属性和API是有对应实现的。但无独有偶,无障碍相关的属性恰恰是映射最不完整的地方之一。比如Android上常用的accessible、accessibilityLabel,在部分鸿蒙RN适配版本里能看到,但行为跟Android不完全一致;而AccessibilityInfo里的isScreenReaderEnabled、announceForAccessibility这类方法,有的版本支持、有的版本压根没实现。
1.2 一条完整的朗读链路,每一环都不能断
为了不被"版本差异"带偏,我建议先建立一个全局认知:要让一个RN页面在鸿蒙上被朗读出来,语音从哪来、谁去触发、中间经过什么,其实是一条固定链路。
RN JS层(组件属性/API调用) -> RN鸿蒙适配层(属性映射/方法转发) -> ArkUI组件(语义信息/事件) -> 鸿蒙系统无障碍服务(焦点捕获/播报调度) -> TTS引擎(最终出声音)
拿Android类比会更好理解:RN代码不直接跟Android无障碍服务打交道,而是通过Android原生视图的contentDescription等属性传递语义,TalkBack读取这些语义完成播报。鸿蒙这边底层逻辑类似,只不过承接方换成了ArkUI组件和鸿蒙系统辅助功能服务。任何一个环节断了,比如RN把accessibilityLabel映射丢了,或者ArkUI组件的语义属性没被系统读到,朗读就会静音。
1.3 所以这个需求应该拆成三个子问题
基于上面的链路,我习惯把"朗读功能实现"拆成三个子问题:
- 怎么让静态内容在屏幕朗读开启时被自动念出来,这是标记语义的问题。
- 怎么在某些时机主动触发一段文字广播,这是主动播报的问题。
- 怎么控制朗读焦点,让用户按顺序、按节奏地浏览,这是焦点管理的问题。
后面的章节基本就按这三个子问题展开。每一章我都会给出JS层的优先方案,以及JS层不够用时的原生兜底。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸿蒙无障碍框架速览:播报这条链路,语义从哪来、事件往哪发
2.1 系统阅读器到底在读什么
鸿蒙的屏幕朗读服务(华为手机上叫"屏幕朗读",基于系统辅助功能框架)工作时,会构建一棵无障碍节点树。树上的每个节点对应界面上的一个可聚焦元素,节点里带着文本内容、说明文字、操作类型、可用状态这些信息。阅读器拿到这棵树之后,按用户的触摸位置、滑动手势、焦点移动顺序,把对应节点的文本喂给TTS引擎。
这里有个关键认知:阅读器读的不是屏幕上的像素,而是语义节点。你屏幕上画了一个很漂亮的自定义按钮,上面是图标加文字,如果语义树上这个按钮只有一个节点,那阅读器只会在焦点进入时念一次,而不是分别念"播放图标"和"音乐两个字"。
这跟RN开发习惯有一个直接冲突:RN的View结构天然是嵌套的,一个Button内部可能有多个Text节点。如果不显式标记,鸿蒙系统可能会把它们当成独立节点逐个朗读,导致用户听到一串碎片化的内容。
2.2 ArkUI侧的语义属性,才是无障碍的"母语"
在鸿蒙侧,与无障碍相关的属性集中在ArkUI组件上。日常最常用的几个:
- accessibilityText:组件被朗读时优先使用的文本。
- accessibilityDescription:补充说明,类似辅助描述。
- accessibilityLevel:控制该节点是否对无障碍服务可见。
- accessibilityActions:自定义无障碍动作,比如"长按展开菜单"。
RN要实现朗读,要么你的RN适配版本已经把这些ArkUI属性与RN的accessibility属性做了映射,要么就得走原生桥接自行设置。两种方案我在后面都会给到具体代码。
2.3 主动播报事件:系统级发送,应用侧只需组好内容
如果是系统屏幕朗读在工作,应用想主动说一句话(比如"订单提交成功"),需要向无障碍服务发送一个播报事件。鸿蒙侧对应的能力在AccessibilityKit里,核心是AccessibilityManager下的无障碍事件发送接口。这与Android的AccessibilityEvent类型里的announce事件类似。
但要注意,主动播报的API在不同RN鸿蒙适配版本里,封装程度差别很大。有的版本你直接调JS的announceForAccessibility就能通,有的版本这个方法就是空壳。所以代码里必须有探测和降级策略,不能一把梭。
3. 最省力的自动朗读:先把RN的accessibility属性吃透
3.1 默认行为:文本节点优先可读
RN的Text组件在鸿蒙侧如果适配正常,本身就会把文本内容暴露给无障碍服务。但注意"本身会读"不等于"读得对"。如果你的页面只有一个Text,那屏幕朗读打开后选中它,确实能听到文本。一旦页面结构复杂,比如Text套在TouchableOpacity里、多个Text并列、图标和文字混排,不显式标记语义就会出各种幺蛾子。
我见过最典型的问题是:一个商品卡片里有商品名、价格、原价、销量四个Text,没做任何标记。系统朗读时,用户手指摸到卡片,四个Text依次被念出来,中间还夹着"原价199元"这样的冗余信息。正常用户看到的是卡片,盲人用户听到的是四段割裂的句子,体验差到离谱。
3.2 组合组件必须显式声明accessible和accessibilityLabel
举一个最典型的例子:确认支付按钮。
tsx复制<Pressable
accessible
accessibilityRole="button"
accessibilityLabel="确认支付,金额99元"
accessibilityHint="双击完成支付"
onPress={handlePay}
>
<View style={styles.btnInner}>
<Text style={styles.icon}>¥</Text>
<Text style={styles.text}>立即支付</Text>
</View>
</Pressable>
加了accessible等于告诉系统:这个View子树是一个整体,别把里面每个Text拆开读。如果再配上accessibilityLabel,朗读时直接念你指定的文本。这个模式在Android、iOS、鸿蒙三端都是通用做法,差异只在鸿蒙某些适配版本里,accessibilityRole可能映射不到位,但Label通常没问题。
3.3 accessibilityState在鸿蒙端的处理
状态类场景,比如"已选中""禁用",用accessibilityState表达:
tsx复制<Pressable
accessibilityState={{ disabled: isDisabled, selected: isSelected }}
>
好处是系统会拼读出"已选中"或"不可用",比你自己在Label里硬写状态更规范,语义变化时也能触发更自然的播报。但在鸿蒙端实测,部分版本对accessibilityState的支持并不稳定,如果你发现拼读不出来,退而求其次把状态拼进Label里,不是最优雅,但有效。
3.4 隐藏冗余内容的两个属性
页面上有些装饰性元素不需要朗读,比如背景图、装饰分隔线、重复的标题图标。给它们设置不可访问即可。在RN侧对应的是accessibilityElementsHidden(iOS)和importantForAccessibility(Android)。
tsx复制<View
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
<Text>纯装饰内容</Text>
</View>
在鸿蒙端,如果RN适配层做了映射,这会对应到ArkUI的accessibilityLevel之类的属性。实测中这个属性的支持程度不少版本是不足的,如果装饰内容确实干扰朗读,且RN属性不生效,就得走原生桥接去给那棵树设置accessibilityLevel值为"no"。这个我在第5章展开。
下面用一张表把常用属性和鸿蒙端的实测情况做个汇总:
| RN属性 | 预期行为 | 鸿蒙适配实测 |
|---|---|---|
| accessible | 子树合并为单一可访问节点 | 多数版本可用 |
| accessibilityLabel | 自定义朗读文本 | 多数版本可用 |
| accessibilityRole | 声明元素角色 | 部分版本映射缺失 |
| accessibilityState | 声明选中/禁用状态 | 部分版本拼读异常 |
| importantForAccessibility | 控制节点可见性 | 支持不稳定,需兜底 |
| accessibilityElementsHidden | 隐藏子树 | 同上 |
4. 主动发声的JS方案:AccessibilityInfo的价值与坑
4.1 announceForAccessibility:什么时候用、怎么用
RN官方提供了AccessibilityInfo模块。其中announceForAccessibility(message)是用代码触发朗读的最直接方法。常见场景是:页面状态不通过焦点变化反馈,但需要让用户知道结果。比如登录失败弹Toast、购物车数量变化、订单状态刷新。
tsx复制import { AccessibilityInfo, NativeModules, Platform } from 'react-native';
export function speak(message: string) {
const harmony = Platform.OS === 'harmony' || Platform.OS === 'ohos';
if (harmony) {
// 部分鸿蒙RN版本下announceForAccessibility为空壳
const module = NativeModules.AccessibilityModule;
if (module?.announceForAccessibility) {
module.announceForAccessibility(message);
} else {
AccessibilityInfo.announceForAccessibility(message);
}
} else {
AccessibilityInfo.announceForAccessibility(message);
}
}
这里有个细节:判断鸿蒙平台不能只写一个值。不同适配版本里Platform.OS可能返回'ohos',也可能返回'harmony',最稳妥的做法是把两个都写上,或者在你自己工程里先打印确认。这个函数建议放在统一的基础模块里,全项目复用。
4.2 先探测屏幕朗读是否开着
很多"朗读没声音"的问题,根因是用户压根没开启屏幕朗读,或者你用的设备/模拟器根本不带这个服务。所以代码里要有探测:
tsx复制useEffect(() => {
const listener = AccessibilityInfo.addEventListener(
'screenReaderChanged',
(enabled) => {
setScreenReaderEnabled(enabled);
}
);
AccessibilityInfo.isScreenReaderEnabled().then((enabled) => {
setScreenReaderEnabled(enabled);
});
return () => listener.remove();
}, []);
这个检测有什么用?我举个例子:页面加载时,如果检测到屏幕朗读没开,那主动播报就不要发了,播了也没人听,还白白占用资源。如果检测到开了,再去走播报逻辑。
4.3 用setAccessibilityFocus控制焦点
如果页面里有多块内容需要按特定顺序朗读,可以在合适的时机把焦点移到指定组件上。RN用setAccessibilityFocus(reactTag)实现。使用前需要通过findNodeHandle拿到组件的tag:
tsx复制import { AccessibilityInfo, findNodeHandle } from 'react-native';
const cardRef = useRef(null);
// 页面加载后把焦点移动到订单卡片
useEffect(() => {
const tag = findNodeHandle(cardRef.current);
if (tag) {
AccessibilityInfo.setAccessibilityFocus(tag);
}
}, []);
return (
<View
ref={cardRef}
accessible
accessibilityLabel="订单卡片,共3件商品,实付金额199元"
>
{/* 卡片内容 */}
</View>
);
实测下来,setAccessibilityFocus在某些鸿蒙RN版本上能移动焦点,但朗读不跟随焦点走,属于"焦点动了、声音没动"的尴尬状态。遇到这种情况,先升级RN适配版本,不行就原生侧处理焦点分发。
4.4 一个完整场景:支付结果页自动播报
把上面的API串起来的经典例子是支付结果页。支付完成后,用户可能还没把手指放到结果区域,如果不主动播报,他要摸半天才知道结果。这时候适合做两件事:移动焦点到结果标题,再主动播出一段结果文案。
tsx复制const handlePaymentResult = async (amount: string) => {
const tag = findNodeHandle(resultTitleRef.current);
if (tag) {
AccessibilityInfo.setAccessibilityFocus(tag);
}
speak(`支付成功,到账金额${amount}元`);
};
这个流程的好处是,不管用户当前手指在屏幕哪个位置,都能第一时间听到结果。缺点是如果鸿蒙侧焦点API不可靠,可能只播报不移动焦点,体验会打折扣。所以正式项目里,我通常直接走第5章的原生兜底方案。
5. 都不好使就抄底:自定义NativeModule直连鸿蒙无障碍API
5.1 什么时候必须走原生层
如果RN适配层把"自动朗读"这块映射得还行,但"主动播报"这类动态能力缺失,那就只能绕开JS层API,自己写一个NativeModule,直接调用鸿蒙系统侧的无障碍播报接口。这个方案不依赖RN适配版本,语义明确。说句实话,做过几个项目后,我反而觉得原生兜底方案在鸿蒙上才是"最稳的主方案",JS层API反而不太敢全信。
5.2 ArkTS侧实现一个播报模块
下面给的是在OpenHarmony方向RN适配版本下写一个NativeModule的骨架。整体思路是:在原生侧暴露一个announceForAccessibility(message)方法,内部调用鸿蒙无障碍能力发送播报事件。
typescript复制// AccessibilityModule.ets
// 一个极简的原生桥接模块,只做一件事:把文本交给系统无障碍播报
import { AccessibilityManager } from '@kit.AccessibilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { TurboModule } from 'react-native-oh-turbo'; // 具体包名以工程为准
export class AccessibilityModule extends TurboModule {
announceForAccessibility(message: string): void {
const eventInfo = {
eventType: 'announce', // 播报类型
message: message, // 要朗读的文本
};
try {
// 实际API名称和参数结构以当前HarmonyOS SDK为准
AccessibilityManager.sendAccessibilityEvent(eventInfo);
} catch (err) {
console.error(`announceForAccessibility failed: ${(err as BusinessError).message}`);
}
}
}
注意,我不能保证贴的API名称在每个SDK版本里都一样。实现前的正确姿势是去翻当前工程的SDK里AccessibilityKit的接口说明,把对应方法填进去。核心逻辑就四行:组装事件、发送、捕获异常、记日志。
5.3 注册模块并暴露给JS
原生模块写完后,需要在RN鸿蒙的模块注册表里注册。不同适配框架的注册方式略有差异,但核心都是把你的Module类加进去,让JS侧能通过NativeModules拿到它。以最常见的Legacy NativeModule方式为例,大致是:
typescript复制// 模块注册示意
export function registerAccessibilityModule() {
// 请按你的RN鸿蒙适配框架要求编写
TurboModuleRegistry.add('AccessibilityModule', new AccessibilityModule());
}
注册完成后,JS侧调用:
typescript复制import { NativeModules } from 'react-native';
const { AccessibilityModule } = NativeModules;
export function speakViaNative(message: string) {
if (AccessibilityModule?.announceForAccessibility) {
AccessibilityModule.announceForAccessibility(message);
}
}
这一步跑通之后,主动播报就彻底不依赖RN的AccessibilityInfo了。即便适配版再缺胳膊少腿,系统侧的能力你已经直接拿到了手。
5.4 原生侧还能顺便解决的另一个问题:焦点管理
既然都写了原生模块,建议把焦点管理也一起带出来。ArkUI的组件可以通过focusControl.requestFocus等方法请求焦点,或者通过组件实例的无障碍焦点相关方法。这么做的好处是,不管RN层属性映射缺失多严重,你都能精确控制朗读的节拍。
提示:原生桥接方案虽然稳,但有一个代价——你需要对鸿蒙的运行环境和ArkTS开发有一定了解,而且升级SDK后要回归验证API是否变化。它不是第一选择,而是兜底选择。不过以目前的适配成熟度,我倾向于把它当成"正式路线"来维护,反而比依赖JS层残缺API更省心。
5.5 桥接内容与RN属性的映射维护建议
如果你在团队里维护RN鸿蒙的无障碍基础设施,建议维护一张映射表,把RN的accessibility属性、鸿蒙的ArkUI语义属性、原生桥接方法列出来,版本升级后逐项回归。
| 功能 | RN JS层入口 | ArkUI原生属性/接口 | 原生桥接方法 |
|---|---|---|---|
| 自动朗读文本 | accessibilityLabel | accessibilityText | 无 |
| 隐藏装饰内容 | importantForAccessibility | accessibilityLevel | setAccessibilityLevel |
| 主动播报 | AccessibilityInfo.announceForAccessibility | 无障碍事件接口 | announceForAccessibility |
| 焦点移动 | AccessibilityInfo.setAccessibilityFocus | focusControl.requestFocus | requestFocus |
这张表也是我每次做版本升级时的回归清单,省了不少排查时间。
6. 真机实测与避坑:模拟器、TTS、焦点乱跳
6.1 模拟器根本测不了朗读,别浪费时间
先说最坑的一件事:RN鸿蒙跑在模拟器上,很多情况下系统屏幕朗读服务或TTS引擎缺失,你调announceForAccessibility、桥接原生API,它都像一拳打在棉花上,没有任何声音。这不一定是你代码错了,而是模拟器环境压根不具备播报条件。
所以第一个实测建议:开发阶段用模拟器跑逻辑,但验证朗读功能一定要上真机。真机打开"设置 -> 辅助功能 -> 屏幕朗读",开启之后再去测。没有这一步,后面全是白搭。我见过有人拿模拟器调了两天,最后发现代码没问题,纯粹是环境不支持。
6.2 TTS引擎的配置会影响中文朗读内容
真机上开了屏幕朗读,默认语音是系统TTS里的。有些测试机装了第三方语音引擎,朗读的语速、音色、中文发音会有明显不同。如果客户反馈"朗读声音不对""语速太快",先检查TTS引擎和语速设置,未必是代码问题。
这里还有一个容易忽略的点:如果你的播报文本是"toast"之类的英文单词,系统TTS可能会按英文发音,导致这个单词在中文语境里显得突兀。建议在代码里主动把这类词替换成中文描述,比如"提交成功"而不是"submit成功"。
6.3 焦点乱跳与顺序错乱:从节点结构上解决
在复杂页面里,最容易出现的问题是焦点顺序乱跳。比如用户摸到一个商品卡片,系统先读了卡片里的图片说明,又读了价格,最后才读商品名。原因通常是语义节点树没整理干净,多个可访问子节点被系统当成独立节点。
解决办法从根上分两步:
- 同类信息合并成一个可访问节点,用accessibilityLabel指定整段朗读文本。
- 确保装饰性元素不可访问,见3.4。
如果RN层的importantForAccessibility在鸿蒙端映射不到位,就在原生层给对应View的ArkUI原生节点设置accessibilityLevel。另外,如果页面里有弹窗或者动态插入的节点,不要指望系统自动搞定焦点。弹窗出现时要主动把焦点移动进弹窗,关闭时再移回触发元素。
6.4 连续播报被吞:加队列或延后
主动播报有一个典型问题:短时间内连续调用两次announce,第二次经常被系统吞掉。我实测遇到的场景是:用户双击提交订单,代码里先播"正在请求支付",紧接着又播"支付失败",结果用户只听到第一条。
原因在于系统播报队列里的消息优先级和处理机制,重复请求会覆盖或被丢弃。对策也很简单,在业务层做一个小队列:每次播报前延迟300到500毫秒,或等上一次播报状态回调后再播下一条。原生桥接里,也可以在自己的Module内部加一个消息队列,顺序发送。
typescript复制// 业务层简单防吞实现
let speakingFlag = false;
export function speakSafe(message: string) {
if (speakingFlag) {
setTimeout(() => speakSafe(message), 350);
return;
}
speakingFlag = true;
speak(message);
setTimeout(() => { speakingFlag = false; }, 350);
}
6.5 列表滚动场景下的朗读优化
长列表(FlatList/FlashList)里的内容变更如果触发朗读,需要在滚动稳定后再去设置焦点和播报,否则容易出现焦点跟随滚动失败或播报内容与当前可视区域不一致。实现上可以用onMomentumScrollEnd和onScrollBeginDrag做节流。
我常用的做法是:在列表滚动开始时取消待播报任务,等滚动彻底停止后再根据当前第一个可视项去播报。这样既省流量,也避免用户被连续多条播报轰炸。
7. 从一次失败适配反推的排查顺序与长期建议
7.1 如果一句话都读不出来,按顺序查这三样
遇到"完全没声音"的情况,别急着重构代码,先按下面顺序排查:
- 系统屏幕朗读是否开启、设备是否支持,换真机验证。
- RN鸿蒙适配版本对accessibility属性/API的映射支持,直接翻node_modules里对应源码,确认属性有没有被处理。
- 语义节点树是否被正确构建,在原生侧打点,看系统侧拿到的节点信息是否带上了你要的文本。
7.2 如果"自动朗读"正常但"主动播报"失效
这种场景基本可以锁定在AccessibilityInfo的鸿蒙实现上。先确认JS层调用的方法是否在适配层有真实实现,如果实现为空函数或直接return,就去走原生桥接方案。我的经验是:主动播报在鸿蒙端最好的归宿就是原生桥接,别在JS层死磕。
7.3 长期维护建议:把无障碍测试纳入常规回归
最后给一个长期建议:无障碍朗读不是一次做完就完事的功能。RN适配版本升级、鸿蒙SDK升级、页面结构调整,都可能导致朗读行为变化。建议在核心支付、登录、列表页面固定加一个"朗读冒烟案例",每次发版前跑一遍。哪怕只是手动操作两次,也能拦截大部分回归问题。这个成本很低,但价值很高,尤其是金融、健康、电商类App。
我在实际项目里还会额外加一条自动化打点:在开发模式里监听AccessibilityInfo的screenReaderChanged,把屏幕朗读是否开启记录到日志平台。这样线上用户反馈"朗读没声音"时,我们可以先判断是不是用户自己没开启,减少很多排查成本。
