前阵子在真机上做 OpenHarmony 版的 React Native 适配,第一个让我意外的地方就是 TabBar 徽标。底部导航加个未读数字,这在 Android 和 iOS 上简直不要太常规,react-navigation 里传一个tabBarBadge参数就完事。结果同样的代码跑到 OpenHarmony 上,徽标直接消失,控制台安安静静,不报错不崩溃,就是看不到那个红点。
我当时第一反应是样式问题,查了半天发现根本不是。OpenHarmony 的 RN 适配层对tabBarBadge的支持是缺失的,或者说还没有完整映射到底层原生 TabBar 组件上。这意味着你不能把 Android 那套经验直接搬过来,得从桥接层开始自己补。这篇文章就记录一下我整个排查和实现的过程,包括为什么徽标会在鸿蒙上失效,以及最终怎么通过原生侧注入把徽标数字从 JS 侧送到 ETS 侧的 TabBar 组件上。如果你也在做 OpenHarmony 上的 RN 底部导航,或者正要给鸿蒙应用加角标提示,这篇应该能帮你少走不少弯路。
1. OpenHarmony这条路上RN的架构起点:为什么徽标不能照抄Android方案
1.1 先搞清楚一件事:OpenHarmony上的RN到底长什么样
OpenHarmony 上跑 React Native,和 Android/iOS 上跑 RN 的架构类似但不完全相同。JS 侧依然是你的业务代码,通过react-native框架统一调用;但原生这一侧,OpenHarmony 用的不是 Android 的 View 体系,也不是 iOS 的 UIKit,而是它自己的 ArkUI 声明式框架,组件用 ETS(Extended TypeScript)编写。
这就带来一个直接后果:RN 官方维护的原生组件映射表,只覆盖了 Android 和 iOS 两个平台。到了 OpenHarmony,需要一套独立的适配层,把 RN 的虚拟组件树翻译成 ArkUI 的组件树。这套适配层目前主要由社区和 OpenHarmony SIG 在推进,虽然已经能跑起来,但很多细节组件的属性映射并不完整。
TabBar 就是一个典型。RN 生态里,底部导航绝大多数用的是@react-navigation/bottom-tabs,它内部会读取options.tabBarBadge,然后把这个值传给原生侧。在 Android 上,原生BottomNavigationView或者第三方实现有对应的 Badge API,接收数字后直接绘制。但 OpenHarmony 的适配层在实现 TabBar 时,可能只是把各个 Tab 项渲染成了 ArkUI 的Tabs或者自定义组件,压根没有去解析tabBarBadge这个字段。
这里顺带提一句,如果底部导航是你自己在 JS 侧写的一套自定义组件,而不是 react-navigation 生成的,那情况会简单很多,后面会展开说。
1.2 tabBarBadge在鸿蒙上为什么失效
从表现形式上看,tabBarBadge失效有三种可能:
第一种,适配层根本没有暴露这个属性。RN 的组件属性是通过一个映射表传到原生侧,OpenHarmony 适配层如果没实现这个字段,值到了原生侧就被丢掉了。
第二种,原生侧收到了值,但 ArkUI 组件没有原生的 Badge 设置接口。ArkUI 自身有 Badge 组件,但它是声明式里静态写死的,如果你要动态更新数字,需要持有组件实例或者通过状态变量驱动。
第三种,生命周期时序问题。RN 侧 JS 代码在useEffect里设置徽标时,原生 TabBar 还没挂载完成,调用被吞掉。这种不一定会报错,就是设置了个寂寞。
我当时遇到的是哪一种呢?准确说,是第一种和第二种的叠加。适配层没有把tabBarBadge传到原生侧,原生侧也没有提供对应的方法来接收这个值。所以问题不能靠改配置解决,必须自己动手。
如果你去看 react-navigation 的源码,会发现它其实做了平台判断,比如:
// @react-navigation/bottom-tabs 内部逻辑(简化) tabBarBadge?: number | string这个字段在 Android 原生侧会被转成BottomNavigationView的badge属性。但在 OpenHarmony 上,没有对应的转换逻辑,所以 JS 侧传了个寂寞。这也就解释了为什么控制台不报错——JS 侧觉得传出去了,原生侧觉得没收到,两边都以为对方有问题,但谁也说不清,只能到源码里找答案。
2. 两条路线怎么选:JS侧拼装和原生侧注入的取舍
既然tabBarBadge不可用,第一反应是在 JS 侧自己画。这个思路很直接:TabBar 不也是 UI 吗?我在图标上面叠一个绝对定位的红色小圆点,里面放数字,不就完事了。
2.1 JS侧拼装的优点和隐藏成本
优点非常明显:跨端一致。不管是 OpenHarmony、Android 还是 iOS,只要 JS 能渲染,徽标就能显示。实现起来也快,十几行代码的事,大概长这样:
// 伪代码,示意在 TabBar 的 tab icon 上叠加徽标 <TouchableOpacity> <View> <Image source={icon} /> <View style={badgeStyle}> <Text>{badgeCount}</Text> </View> </View> </TouchableOpacity>但用起来你就会发现几个问题。
第一个是对齐成本。TabBar 组件在不同平台高度、图标大小、间距都不一样,你要在 OpenHarmony 上手动调徽标的位置。调完鸿蒙,回到 Android 又歪了。当然如果你只适配鸿蒙,这个问题不大。
第二个是点击区域遮挡。如果徽标是绝对定位的一个 View,它可能会拦截触摸事件。特别是你想在徽标上叠加一个小数字,用户点到了数字区域,Tab 的切换事件却被这个 View 吃掉。解决方式倒是有,给徽标容器设置pointerEvents="none",让触摸穿透。这招在 RN 里是有效的,但有点绕。
第三个是不能覆盖原生绘制的内容。如果 TabBar 是原生 TabBar 控件,JS 侧的 View 是叠不到原生控件上面的,或者说层级非常不可控。在 OpenHarmony 上,如果你的 RN 页面用的是原生 TabBar 作为底栏,JS 侧拼装方案基本作废。
所以,JS 侧拼装只适合一种情况:你的 TabBar 本身就是纯 JS 写的,不涉及原生控件。否则这条路的问题比解决掉的问题还多。
2.2 原生侧注入:看似麻烦但最贴近系统
原生侧注入的思路是:RN 侧通过桥接模块把徽标数字传给 OpenHarmony 原生侧,原生侧拿到值之后,调用所在 ArkUI 组件的接口,更新 Badge。
这条路的好处是性能好、位置精准、效果跟随系统组件走。你不需要关心 JS 里 View 的层级、对齐、触摸穿透,只需要让原生侧暴露一个方法,专门更新徽标。
坏处也很现实:需要动适配层代码,要理解桥接机制,还要处理组件生命周期。对很多业务开发同学来说,这可能有点劝退。但做 OpenHarmony 适配,这本来就是绕不开的功课。
2.3 我的选型结论
我当时的场景是:RN 应用跑在 OpenHarmony 设备上,底部导航用了 react-navigation,但 TabBar 实际由适配层映射到原生 ArkUI 组件。所以道理上只能走原生注入。
另外我还查了一下flutter tabbar 点击取消动画、uniapp tabbar 输入法顶起这些热词相关的讨论,发现好多人在做跨端 TabBar 时都有类似的困境——要么徽标不显示,要么点击动画去掉,要么输入法把底栏顶起来。这些问题的共同根源都是:跨端框架和原生组件之间的联动能力不完整,所以必须通过桥接做补偿。徽标只是其中一个典型场景。
所以我的选型结论是:JS 侧拼装作为临时方案,原生侧注入作为正式方案。临时方案先让产品看到效果,正式方案慢慢做,最后切换。你要是时间紧,也可以一步到位直接上原生注入,但要有心理准备,调试链路比纯 JS 长不少。
3. 桥接调用与数据同步:让数字从JS流到ETS侧的完整实现
3.1 桥接的整体链路
在 OpenHarmony 上,RN 和原生侧通信的通道主要是通过TurboModule或者老的NativeModule机制(具体看你用的 RN 版本,新版基本都走 TurboModule)。大致流程是:
JS 代码调用NativeModules.OpenHarmonyTabBar.setBadge(...),RN 框架把调用序列化,通过桥接到原生侧,原生侧找到对应的模块和函数,执行 ETS 代码,然后返回结果。
这个链路在 Android 上跑了很多年,稳定可靠。但在 OpenHarmony 上,适配层对NativeModules的支持完整度,取决于你使用的适配框架版本。
如果你用的 OpenHarmony 的 RN 适配包比较新,它已经实现了 TurboModule 注册机制,你可以直接在 ETS 侧写一个TurboModule的子类,用@TurboModule装饰器暴露方法。如果是老版本,可能要手动改注册表。我建议先确认你的适配包版本再动手。
3.2 ETS侧实现:给TabBar一个setBadge入口
这里假定你已经有一个原生的 TabBar 组件,不管是直接用的 ArkUI 的Tabs,还是自定义组件。要做的事情是:暴露一个方法给 JS 侧调用,方法内部更新 TabBar 某个 Tab 的徽标。
我用一个简化的 ETS 示例来说明核心逻辑:
import { TurboModule, TurboModuleContext } from '@rnoh/react-native-openharmony/tsc'; import { emitter } from '@kit.ArkUI'; // 声明给 JS 侧看的接口 interface Spec extends TurboModule { setBadge(index: number, count: number): void; } export class OpenHarmonyTabBarTurboModule extends TurboModule implements Spec { private tabBarInstance: TabBarComponent | undefined; constructor(ctx: TurboModuleContext) { super(ctx); // 监听 TabBar 挂载完成事件 emitter.on('tabBarReady', (instance: TabBarComponent) => { this.tabBarInstance = instance; this.pendingQueue.forEach((item) => { this.tabBarInstance?.setBadge(item.index, item.count); }); this.pendingQueue = []; }); } private pendingQueue: Array<{ index: number; count: number }> = []; setBadge(index: number, count: number): void { if (this.tabBarInstance) { // 如果 TabBar 已就绪,直接设置 this.tabBarInstance.setBadge(index, count); } else { // 否则先缓存,等 TabBar 挂载后再补 this.pendingQueue.push({ index, count }); } } }然后 TabBar 组件内部要有setBadge方法:
@Component export struct TabBarComponent { @State badgeList: Array<number> = [0, 0, 0]; setBadge(index: number, count: number) { // 更新状态变量,ArkUI 会自动刷新 this.badgeList[index] = count; } build() { Tab('首页') { Column() { // 你的页面内容 } } .tabBar(this.TabBuilder(0)) // ... 其他 Tab } @Builder TabBuilder(index: number) { Column() { Text('图标') if (this.badgeList[index] > 0) { Text(`${this.badgeList[index] > 99 ? '99+' : this.badgeList[index]}`) .fontSize(10) .textAlign(TextAlign.Center) .width(20) .height(20) .borderRadius(10) .backgroundColor(Color.Red) .fontColor(Color.White) } } } }思路很直接:ETS 侧维护一个badgeList状态数组,setBadge方法更新对应 Index 的值,ArkUI 的响应式系统检测到@State变化,自动重新渲染 TabBar 上的徽标。
这里有个很重要的点,ArkUI 状态管理。不能把badgeList定义成普通变量,必须用@State或者至少@Observed/@ObjectLink,否则数据变了 UI 不刷新,又是那种“代码明明写了但不生效”的诡异问题。
JS 侧调用就很简单:
import { NativeModules } from 'react-native'; // 在需要更新徽标时 NativeModules.OpenHarmonyTabBar?.setBadge(1, 5); // 给第二个 Tab 设置数字 5如果这个方法不存在(比如适配包比较老),?.会静默失败,这也是常见的“调了没反应”的原因之一。排查时优先确认 NativeModules 里到底有没有这个模块。
3.3 时序问题:组件还没挂载怎么办
上一节代码里我已经写了一个 pendingQueue 的缓存机制,这是实际开发中非常关键的一环。
RN 应用启动,JS bundle 解析执行,业务代码在useEffect里发请求拿未读数,拿到之后立刻调用setBadge。这个时间点大概率比原生 TabBar 挂载完成要早。如果直接调用,this.tabBarInstance还是undefined,徽标就丢了。
解决方式有两种,我代码里用的是第一种,事件订阅 + 缓存队列,TabBar 挂载完成再补发。还有一种是轮询检查,但太丑不建议。
另外还有一种情况是 TabBar 被销毁重建。比如从子页面返回主页面,原来的 TabBar 可能被回收。这时候 JS 侧没有再次发请求,徽标数还在 RN 的内存里,但原生侧已经丢了。解决办法是:在 TabBar 的aboutToAppear生命周期里,主动向 JS 侧拉一次最新的徽标数据,或者由 JS 侧在页面 focus 时重新下发一次。
RN 侧监听页面 focus 其实很方便:
useFocusEffect( useCallback(() => { NativeModules.OpenHarmonyTabBar?.setBadge(1, badgeCount); }, [badgeCount]) );这个useFocusEffect是@react-navigation/native提供的,当页面获得焦点时会执行。这样每次从别的页面回来,都能保证原生徽标是最新的。
4. 踩坑实录:启动白屏、桥接失效、重复渲染的高频事故现场
这一章我把自己实际踩过、以及从社区讨论里汇总的坑集中讲一下。这些坑单独看都不起眼,但凑在一起非常消磨信心。
4.1 白屏是很多问题的“统一出口”
热词里有一条是“react native 启动白屏”,我这次调试也撞上了。第一次在 OpenHarmony 真机上跑起来,屏幕一片白,控制台没有任何 JS 报错。
排查过程我建议按这个顺序走:
- 先确认 bundle 有没有加载出来。在 ETS 侧日志里搜 JS bundle 的关键字,看是否执行到了。如果根本没有执行,白屏是框架都没跑起来,和业务代码无关。
- 然后看原生组件有没有渲染。在 TabBar 的
build()里加日志,或者临时在页面上放一个静态文本,如果文本出来了但 TabBar 没有,问题在 TabBar 适配。 - 再看 JS 和原生通信是否正常。调用一下最简单的模块,比如
NativeModules.XXX.getConstants(),看返回值是否为 null。
我那次的白屏其实是因为 JS bundle 路径配错了,导致整个 RN 容器一直挂在加载阶段。修复之后,后续才轮到徽标问题暴露。
所以如果你也遇到白屏,先别急着查徽标,把根因找到再说。白屏是结果,不是原因,定位到底层才能药到病除。
4.2 桥接调用失败:等一等再执行
等你把白屏修好,开始调徽标,会发现第二个高频问题:JS 调setBadge时原生侧没反应。
我上面写了 pendingQueue,但真实调试时你会面对更简单的困境:不知道到底有没有调用成功。这时候我建议在 ETS 侧加日志,比如:
setBadge(index: number, count: number): void { console.info(`[OpenHarmonyTabBar] setBadge ${index} -> ${count}`); }别小看这个日志。它能帮你确定三件事:第一,桥接是否通;第二,参数是否传对;第三,调用时机到底早不早。如果日志根本没出现,说明 JS 侧的NativeModules.OpenHarmonyTabBar可能是 undefined,根本调用不到模块,你得检查模块注册名是否一致。
模块注册名特别坑。RN 侧NativeModules.OpenHarmonyTabBar必须对应原生侧注册时的名字,大小写、拼写都不能错。我见过好几次把TabBar写成Tabbar或者tabBar,查了半天。
4.3 点击动画和重复渲染:三个隐形杀手
桥接通了,徽标也显示了,接下来的问题往往出在体验层面。
第一个是点击 Tab 时徽标闪烁。原因可能是点击切换时 TabBar 重新渲染了,如果你在状态变量里存了临时值,会把徽标覆盖掉。比如@State里有个selectedIndex,切换时badgeList没被重置,但由于重绘导致路径变化,表现为闪一下。
第二个是重复渲染。RN 侧如果数据没变但每次 focus 都调setBadge,原生侧会多做很多无意义的 UI 刷新。解决办法很简单,RN 侧加个判断,只有数据变化时再调:
const prevCountRef = useRef(0); useFocusEffect( useCallback(() => { if (count !== prevCountRef.current) { prevCountRef.current = count; NativeModules.OpenHarmonyTabBar?.setBadge(1, count); } }, [count]) );第三个是点击动画干扰。热词里有一条是“flutter tabbar 点击取消动画”,在 ArkUI 里同样存在。ArkUI 的 Tab 在点击切换时会有一个动画过渡,如果你希望徽标数字在切换瞬间保持稳定,不跟随动画移动,需要把 TabBar 的animationDuration设置成0,或者把动画模式改为无动画。
ArkUI 的Tabs组件有个animationDuration属性,默认有值。底部导航切换时如果你觉得动画卡顿,尤其是有徽标时那种“数字跳一下”的感觉,直接设成 0 就行。但注意,这会让切换完全没有过渡,产品可能不答应。折中办法是保留切换动画,但徽标容器在动画期间不做位移动画,只做数字变化。
5. 性能实测与优化:让徽标更新不再卡顿
5.1 实测数据与体验
在 OpenHarmony 真机上,我用上面那套方案做了简单压测。JS 侧用定时器每 200 毫秒调一次setBadge(0, random),连续跑 2 分钟,观察原生侧 UI 是否卡顿、JS 线程是否阻塞。
实测下来:200ms 一次的频率完全无压力,页面滑动不掉帧,CPU 占用增量可以忽略。这个结果是符合预期的,因为徽标更新本质上只是状态变量赋值 + ArkUI 局部刷新,不涉及复杂布局。
但如果把频率提高到 50ms 一次,也就是每秒 20 次,就会出现肉眼可见的闪烁。原因是状态变化太快,ArkUI 的 UI 刷新跟不上数据变化,出现覆盖竞态。
所以我的建议是:正常业务场景下,徽标更新频率控制在 500ms 以上就够了。未读数变化往往来自网络回调,不可能那么频繁,真的频繁说明架构设计有问题,应该在前端做节流。
5.2 优化方向:批量同步、原生截流、异常降级
既然做的是原生注入,优化空间也主要在原生侧。
第一个优化是批量同步。有时候你一次要更新四个 Tab 的徽标,比如“首页有 5 条未处理,消息有 12 条未读,我的有 1 张优惠券”,如果分别调四遍setBadge,就有四次桥接开销。改为一次调用传一个数组:
setBadges(list: Array<{ index: number; count: number }>): void;桥接次数从四次降为一次,对于性能要求高的场景有明显帮助。
第二个优化是原生侧截流。如果 JS 侧因为某种原因短时间连续调用,原生侧可以合并刷新,例如用setTimeout把 100ms 内的更新合并到一次。
第三个优化是异常降级。如果桥接调用经常因为时序问题失败,除了缓存队列之外,还可以准备一个兜底:原生 TabBar 在每次页面可见时,主动从 JS 侧拉一次数据。不过这个需要 RN 侧配合提供一个 getter 方法。
还有一点,如果某些设备性能较差,徽标数字更新可能引起布局抖动。特别是“0 -> 1”从无到有这个过程,组件尺寸变化会导致旁边图标位置变化。解决方式是提前预留徽标位置,把徽标容器设为固定宽高,用透明度切换而不是条件渲染切换。ArkUI 里用opacity控制显隐比if更平滑,代价是始终占据布局空间。
我在实际项目中最后的做法是:TabBar 组件内部固定预留 20x20 的徽标区域,默认透明度为 0;需要显示时把透明度设为 1,并更新数字。这样不管有没有角标,图标的位置都稳定,不会出现“有角标时图标偏左,没角标时图标居中”这种让人崩溃的布局偏移。
如果你也遇到角标导致 Tab 图标跳动的问题,可以试试这个思路。它比动态计算布局可靠得多,也省去了一大堆样式适配工作。
最后分享一个我做 OpenHarmony 适配以来最大的体会:这类跨端适配问题,永远不要指望官方适配层能一步到位,尤其是徽标这种“看似小功能、实则依赖完整桥接链路”的能力。把桥接机制、组件生命周期、状态管理几个核心环节搞明白,以后类似的自定义原生组件联动都会从容很多。