React Native适配OpenHarmony:通知设置模块桥接实战与踩坑记录
最近在做一件事把团队用 React Native 写的 Steam 资讯 App 往 OpenHarmony 设备上移植。UI 层其实没有想象中费劲因为 RN 的社区适配已经能覆盖大部分基础组件真正让人挠头的是系统能力——尤其是通知设置。通知设置页面表面上只是几个开关背后却牵涉 React Native 与 OpenHarmony Notification Kit 的桥接、通知开关状态查询、用户偏好的持久化还有和系统设置页的联动。这篇文章把我这个模块从建模到实现再到踩坑的完整过程写出来做 React Native for OpenHarmony 的同学可以直接抄作业。1. 项目背景为什么是Steam 资讯 通知设置1.1 资讯类 App 的通知业务模型先交代一下场景。Steam 资讯 App 的典型业务是给用户推送游戏资讯、新品上架、折扣提醒、好友动态。这类 App 的通知有个特点用户对内容的敏感度差异极大。有人只想收打折信息有人只关心某几个游戏的资讯还有人新游情报一条都不想错过。如果只做一个总开关用户要么被信息轰炸到直接卸载要么把所有通知全关掉产品方完全失去触达渠道。所以通知设置模块在设计上不能是一个开关管全部而是需要一套可组合的策略。我在项目里拆成了三层总开关通知服务是否启用对应系统层的通知权限分类开关资讯、折扣、好友动态、系统公告每一类独立可控细项配置比如免打扰时段、声音是否跟随系统这类属于偏好而非开关这次实战文章主要讲总开关和分类开关的实现链路免打扰时段属于本地业务逻辑不是系统能力的关键路径。1.2 React Native 跑在 OpenHarmony 上的现状说实话RN 生态往 OpenHarmony 上搬比想象中成熟。基础组件如 View、Text、FlatList、StyleSheet 基本能用网络请求、图片加载也没遇到太大阻碍。原因在于 RN 本身是一层自绘 UIOpenHarmony 的适配版把渲染层映射到了 ArkUI 的能力上业务代码写起来和 iOS/Android 差异不大。真正麻烦的是系统能力。通知、权限、震动、角标这类能力RN 的 JS 侧没有现成的 API 可直接调用你必须写桥接。桥接本身不难难在文档少、样例少、报错信息不够友好。我这次做通知设置百分之七十的时间都花在桥接踩坑上UI 反而半小时就写完了。如果你的 App 属于业务为主、系统能力为辅的类型用 RN for OpenHarmony 是划算的如果 App 重度依赖系统能力比如语音助手、后台保活、蓝牙外设那你得先评估桥接工作量再决定。1.3 本次模块的验收标准技术文章最怕写完不知道成没成。我提前定了四条验收标准后面所有测试都围绕这四条来首次安装启动进入设置页时开关显示正确的默认状态用户打开总开关系统弹出通知授权确认授权后开关保持打开用户在系统设置里手动关闭通知后回到 App 设置页开关自动变成关闭态在设置页能直接触发一条测试通知验证整条链路通了这样的验收标准直接对应了后续的所有实现和排查环节。2. 环境准备与工程初始化这一层的坑都在版本上2.1 版本矩阵做 OpenHarmony 开发第一个要命的问题就是版本。我列一下这次实际用的版本组合省得大家一个个去试组件推荐版本说明DevEco Studio5.0.3较新的版本对 API 12 支持更好OpenHarmony SDKAPI 12Notification Kit 的接口最稳定Node.js18 LTS太新或太旧都和 RN CLI 有兼容问题react-native0.72.x社区适配版本基于这个分支维护react-native-openharmony跟随官方 release注意和 RN 版本配套这里有个很容易踩的坑DevEco Studio 新版本会自动下载最新的 SDK但 OpenHarmony 的最新 SDK 不一定和 react-native-openharmony 的适配版本兼容。我一开始用的 API 13 预览版结果 Notification Kit 某些接口签名变了桥接层编译直接报错。最后老老实实退回 API 12世界就清净了。2.2 工程初始化工程初始化分两步用 RN 的 CLI 创建 JS 侧工程这套和做常规 RN 项目一样创建完跑一遍确保 JS 代码能跑起来在工程里加上 OpenHarmony 的 entry 模块也就是 hap 壳工程这里不推荐手工从零搭直接参考 react-native-openharmony 官方 sample 的结构更省事。sample 工程里已经帮你把 entry、assets 加载、Metro 配置都串好了你要做的是把自己的业务代码放进去然后验证 Metro 是否能连上壳工程。一个关键点是 bundle 资源路径。RN 开发时默认从 Metro 拉 JS bundle但 OpenHarmony 真机上 Metro 需要走网络调试时没问题发布时必须把 bundle 打进 hap 包。我的做法是先在调试模式下跑通再执行 bundle 打包命令把产物放到 entry/src/main/resources/rawfile/index.ohos.bundle 下。2.3 运行到真机OpenHarmony 的开发调试通过 hdc 命令操作类似 adb。安装命令是hdc install entry/build/xxx.hap调试时有一个细节真机上的 RN App 要连到你 PC 上的 Metro需要把 Metro server 的 host 设置成 PC 的局域网 IP而不能是 localhost。否则真机加载不到 bundle页面直接白屏。这个细节后面还会提到它是启动白屏的常见原因之一。3. 通知设置的数据层设计先建模再写 UI3.1 开关项与配置表很多人写通知设置页喜欢直接在 JSX 里堆 Switch 组件每行一个业务字段写死。这种写法后患无穷因为通知类型以后一定会加加一种就要改一遍页面。我习惯的做法是先定义一张配置表页面遍历渲染。我定义了一个开关项结构export type NotifySwitchKey news | discount | friend | bulletin; export interface NotifySwitchItem { key: NotifySwitchKey; label: string; desc: string; defaultValue: boolean; } export const NOTIFY_SWITCH_CONFIG: NotifySwitchItem[] [ { key: news, label: 游戏资讯, desc: 关注游戏的新闻动态推送, defaultValue: true, }, { key: discount, label: 折扣提醒, desc: 愿望单游戏降价时提醒你, defaultValue: true, }, { key: friend, label: 好友动态, desc: 好友上线或游戏成就动态, defaultValue: false, }, { key: bulletin, label: 系统公告, desc: 版本更新与产品公告, defaultValue: true, }, ];这样做的价值不只是少写几行代码而是让新增通知类型变成改配置而不是改页面逻辑。社区经验是通知设置的开关项一旦超过三个配置化是必须的否则下一个需求过来你就得在 UI 层挖坑。3.2 状态管理Zustand状态管理我选了 Zustand。没选 Redux因为这个页面的状态树很浅只有几个布尔值Redux 那一套在这种场景下明显偏重。Zustand 的写法直观对 TypeScript 的类型推导也好。通知设置 store 的骨架import { create } from zustand; import { persist, createJSONStorage } from zustand/middleware; import AsyncStorage from react-native-async-storage/async-storage; import { NOTIFY_SWITCH_CONFIG } from ../config/notifySwitches; interface NotifyState { notifySwitches: Recordstring, boolean; setSwitch: (key: string, value: boolean) void; resetAll: () void; } const buildInitialSwitches () { const init: Recordstring, boolean {}; NOTIFY_SWITCH_CONFIG.forEach((item) { init[item.key] item.defaultValue; }); return init; }; export const useNotifyStore createNotifyState()( persist( (set) ({ notifySwitches: buildInitialSwitches(), setSwitch: (key, value) set((state) ({ notifySwitches: { ...state.notifySwitches, [key]: value }, })), resetAll: () set({ notifySwitches: buildInitialSwitches() }), }), { name: steam-news-notify-store, storage: createJSONStorage(() AsyncStorage), } ) );persist 中间件会帮你把开关状态落到 AsyncStorage下次启动还能恢复。这里要注意一个坑不要用整个 store 里所有字段都持久化。如果以后 store 里加入 session 这类临时字段会把脏数据也存进去。最好给 persist 配置 partialize 只筛选需要的字段。3.3 默认值策略与首次启动分类开关的默认值值得单独说。我调查过几个资讯类产品常见做法分两种全部默认打开用户自己关按业务重要性分配比如资讯和折扣默认开好友动态默认关第二种更合理。因为所有开关默认全开对用户来说等于轰炸很容易导致用户直接关掉总开关。而把低价值、高打扰的推送默认关掉反而能给用户留出掌控感。但无论哪种默认值首次启动时都要注意一个点用户还没有授权通知此时页面显示的开关状态只是本地偏好不等于系统层的真实状态。展示层必须区分偏好值和系统授权值。这个区别后面写交互时要重点处理。4. 桥接层封装 OpenHarmony 通知能力4.1 为什么必须写桥接RN 的 JS 侧没有 OpenHarmony 的 notificationManager 模块这是显然的。那些系统能力要么通过官方提供的 NativeModule 暴露要么自己写桥接。通知相关的能力官方适配版一般不会默认带上所以基本靠自己。桥接的边界很清楚把通知开关查询、请求开启、发通知这三件事暴露给 JS。其余逻辑比如是否弹引导、UI 展示、持久化都留在 JS 层做。桥接层暴露的方法越少后续维护越省心。4.2 ArkTS 侧实现先写一个纯粹的 ArkTS 服务类封装 Notification Kit 的调用// entry/src/main/ets/service/NotificationService.ets import notificationManager from ohos.notificationManager; export class NotificationService { static async isEnabled(): Promiseboolean { return await notificationManager.isNotificationEnabled(); } static async requestEnable(): Promisevoid { await notificationManager.requestEnableNotification(); } static async publishNotification(title: string, text: string): Promisevoid { const request: notificationManager.NotificationRequest { id: Date.now() % 100000, content: { notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, normal: { title: title, text: text, }, }, }; await notificationManager.publish(request); } }这里的三个方法对应了系统层的核心语义查询、请求授权、发布。重点解释一下第二个方法 requestEnableNotification在 OpenHarmony 上普通应用发布通知不需要申请运行时权限但系统要求通知开关必须打开这个方法会弹一个系统对话框请求用户允许应用发送通知。等价于你在 iOS/Android 上第一次请求通知权限时的系统弹窗。业务上即便你在 JS 侧把设置页的开关显示成打开如果系统通知开关没打开通知照样发不出去。所以用户打开开关这个动作必须触发 requestEnableNotification让系统弹出授权框拿到授权后通知链路才算真正打通。4.3 JS 侧的 Promise 封装在 JS 侧用 NativeModules 拿到桥接对象再包一层错误处理。通知能力是异步的而且失败场景很常见不允许裸调用// src/services/notification.ts import { NativeModules } from react-native; const { NotificationModule } NativeModules; export const notifyBridge { async isEnabled(): Promiseboolean { try { return await NotificationModule.isEnabled(); } catch (e) { console.warn([Notify] isEnabled error:, e); return false; } }, async requestEnable(): Promiseboolean { try { await NotificationModule.requestEnable(); return true; } catch (e) { console.warn([Notify] requestEnable error:, e); return false; } }, async publish(title: string, text: string): Promiseboolean { try { await NotificationModule.publish(title, text); return true; } catch (e) { console.warn([Notify] publish error:, e); return false; } }, };有一点要强调桥接方法被拒绝时catch 一定要有兜底返回值。通知设置页的 UI 交互依赖这些布尔返回值如果异常时抛出来页面可能崩或者开关状态错乱。我的习惯是每个方法都返回布尔值或带错误结构的对象绝不让异常穿透到 JS。4.4 桥接层需要关注的时序问题桥接层的实现不算难但时序问题很容易被忽视。ArkTS 侧的方法执行完成后通过 Promise 回调回到 JS 侧。RN 的 NativeModule 调用默认在 JS 线程执行OpenHarmony 侧涉及通知服务的调用会跨进程所以耗时不确定。我遇到的一个典型问题是用户在设置页快速连点开关JS 侧并发发起了多次 requestEnable 调用系统弹窗一个接一个弹出体验极差。这里需要在 JS 侧做互斥进入请求中状态后Pending 的点击一律忽略。这个坑在后面的踩坑节里会详细展开。5. 核心交互从开关到通知发出的完整链路5.1 开关与系统状态的联动逻辑通知设置页的 UI 交互不能简单理解成Switch 绑一个布尔值然后 setState。你需要一个状态机来处理系统授权值和用户偏好值之间的关系。我在实现里把开关呈现分成两个层次总开关直接绑定系统通知开关状态来源是 notifyBridge.isEnabled()分类开关绑定 store 里的本地偏好不需要询问系统总开关的操作动作用于触发授权链路。点开总开关时流程是先把 Switch 置为 pending/loading 状态防止用户连点调用 notifyBridge.isEnabled()如果已经是 true说明系统已授权直接更新本地状态如果是 false调用 notifyBridge.requestEnable()请求成功、系统中通知开关变成 true再更新 UI 为打开状态用户拒绝授权UI 保持关闭并且可以提示一句通知关闭期间将无法收到任何推送这段逻辑如果写进 useCallback 里会比较长我单独抽了一个函数const handleToggleMaster useCallback(async (next: boolean) { if (pendingRef.current) return; if (!next) { useNotifyStore.getState().setSwitch(master, false); return; } pendingRef.current true; checkPanel.setLoading(true); const enabled await notifyBridge.isEnabled(); if (enabled) { useNotifyStore.getState().setSwitch(master, true); } else { const ok await notifyBridge.requestEnable(); if (ok) { useNotifyStore.getState().setSwitch(master, true); } else { useNotifyStore.getState().setSwitch(master, false); // show toast: 需要打开系统通知权限 } } pendingRef.current false; checkPanel.setLoading(false); }, []);这里最关键的细节是关闭总开关不需要请求系统因为 OpenHarmony 没有提供代码直接关掉系统通知开关的能力。你能做的只是关闭本地偏好的声音/震动等子项或者引导用户去系统设置里关。所以我们的关闭操作只是改 store。5.2 引导用户去系统设置页产品上有一个真实需求用户在授权弹窗里点了拒绝之后开关一直开不了。你总不能每次都弹系统授权框OpenHarmony 对这种场景的约束是已经拒绝过一次后再次 requestEnableNotification 可能直接不弹窗。这时候就需要引导用户去系统设置页手动开启通知。拉起系统设置页的方法是利用 want 启动系统的 settings 应用import common from ohos.app.ability.common; import { Want } from ohos.app.ability.Want; async function openSystemNotificationSettings(context: common.UIAbilityContext) { const want: Want { bundleName: com.ohos.settings, abilityName: com.ohos.settings.MainAbility, }; try { await context.startAbility(want); } catch (e) { console.error(open settings failed, e); } }实际上不同 OpenHarmony 版本的 settings 应用包名和能力名可能不同有的版本的通知设置页需要通过 uri 参数直达。最稳妥的做法是先用上面这段拉起设置首页再靠用户手动进通知页如果想要直达需要查目标设备上 settings 应用的声明配置。这个属于跟版本走的细节我在公司里是专门写了一个 caniuse 表记录每个版本的行为差异。代码这边要注意 context 从哪里来。RN 工程里壳工程的 MainAbility 入口有 UIAbilityContext你可以在桥接的 ArkTS 侧把它保存为全局 context或者每次调用时由 RN 的当前 UI 栈传入。我选择了在壳工程初始化时把 context 存到单例里桥接方法直接拿。5.3 通知渠道Slot 配置OpenHarmony 的通知服务引用了类似 Android 通知渠道的概念叫 NotificationSlot。渠道的类型决定了通知的打扰级别、是否亮屏、是否震动等。如果不创建 slot 直接 publish系统会按默认渠道处理通常也弹得出来但严重依赖系统默认行为不可控。我在获取授权后马上创建了两个 slot一个用于一般资讯一个用于重要提醒。Slot 的创建在 ArkTS 侧import notificationManager from ohos.notificationManager; async function setupNotificationSlots() { const slotInfo { type: notificationManager.SlotType.SOCIAL_COMMUNICATION, level: notificationManager.SlotLevel.LEVEL_HIGH, }; await notificationManager.addSlot(slotInfo); }这里 SlotType 的选择不要乱用。资讯推送用 CONTENT_INFORMATION 更合理好友动态才用 SOCIAL_COMMUNICATION。Slot 级别如果设成 LEVEL_DEFAULT通知会出现在列表但可能不打扰设成 LEVEL_HIGH 则会有横幅和声音。我的建议是重要通知用高等级普通推送用默认宁可少用打扰。5.4 发布一条测试通知设置页里放一个发送测试通知按钮这个按钮的调试价值极大。点一下走了完整的 publish 链路能直接验证桥接、slot、系统通知开关是否都正常。测试通知的调用很简单const ok await notifyBridge.publish(Steam 资讯, 这是一条测试通知如果你看到了说明通知链路正常);这个按钮我强烈建议保留到正式环境藏在设置页底部或开发者菜单里。它能在现场排查问题时节省大量时间。有一次用户反馈收不到通知我让他在设置页点测试通知发现本地通知能发出来只有服务端推送收不到瞬间就把问题定位到了推送服务侧而不是客户端。6. 踩坑记录白屏、状态不同步和通知不弹6.1 启动白屏的排查链路React Native 在 OpenHarmony 上启动白屏这是社区里被问烂的问题。我第一次遇到时排查了很久后来总结出一条排查链路按这个顺序查五分钟内基本能定位。第一步看 Metro 日志。如果 Metro 没有输出 bundle 请求记录说明 App 压根没去找 Metro。这通常是壳工程的 bundleUrl 配错了指向了不存在的地址。第二步看 assets 里有没有 bundle 文件。发布模式下 RN 跑的是打进 hap 的 bundle。路径在 entry/src/main/resources/rawfile/ 下文件名要和壳工程加载代码里写的一致。举个例子如果加载代码里写的是index.ohos.bundle但产物叫index.android.bundle那白屏就是必然的。第三步检查 View 挂载时机。react-native-openharmony 的壳工程会在 Ability 的 onWindowStageCreate 里做初始化。有时你把 RN 的初始化放到了 onForeground窗口还没就绪渲染出来的就是空白。还有一种隐蔽情况是 Metro 端口被占用。RN 默认是 8081如果本机另一个进程占了这个端口Metro 会换端口但壳工程里的配置没跟着变白屏。排查方法很简单Metro 起来后看终端第一行输出的 URL对比壳工程里的加载地址。6.2 首次启动开关状态闪烁设置页打开时总开关的值来自系统查询而系统查询是异步的。如果你初始渲染时给 Switch 一个默认值比如 true等异步查询返回 false用户会看到开关闪一下从开变关体验很差。我的解决方案是给总开关加一个加载中状态。在通知开关的查询结果返回前Switch 保持 disable 或显示 loading查询完成后再渲染真实值。这个思路说起来简单但很多人漏掉是因为只在 Android 上做通知设置时系统的 query 非常快几乎感知不到异步。OpenHarmony 上的适配层查询明显慢闪烁问题就暴露了。跨端开发就是这样稍慢的环节就要考虑 loading 态。6.3 通知授权弹窗与页面生命周期冲突这个坑值得单独记录。我在测试时发现一个现象从设置页点击开启通知系统授权弹窗弹出来但没等用户点击页面就切到了后台等用户再回来时授权结果丢失开关状态错乱了。根因是 requestEnableNotification 的 Promise 在 UIAbility 进入后台时被系统挂起返回时机不可控。解决思路是不要把授权结果绑定到页面的局部状态而是统一写入 store并且进入前台时重新查询系统状态。我在壳工程的 onForeground 回调里加了一个事件通知当页面重新回到前台设置页重新调用 isEnabled() 刷新总开关。这样即使授权结果丢了用户回来时也会拿到最新的真实状态。6.4 通知渠道导致的不弹通知还有一次测试通知发不出去查了很久发现是 Slot 没创建成功。原因是 addSlot 方法需要在通知服务初始化之后调用我在 App 启动时太早调用了 addSlot系统还没就绪返回失败被打进了 catch 里后续 publish 也没有报错但通知就是不见踪影。所以 addSlot 和 publish 之间要有依赖关系publish 之前建议先检查当前 slot 是否有值。我在桥接层里加了一个 ensureSlot 方法publish 时如果发现 slot 不存在先补创建再发布。另外同一个类型的 slot 重复创建不会报错但如果你修改了 level 参数需要先 removeSlot 再 addSlot否则新的 level 不生效这个容易让人误以为代码没生效。6.5 系统设置改开关之后App 内不同步用户到系统设置页把通知关掉再切回 App设置页的总开关还显示打开状态。这在原生开发里也要处理很多新手会忽略。RN 开发时我采用两个机制AppState 监听从 background 回到 active 时重新查询 isEnabled设置页的 onFocus 回调里重新查询实现了一个轻量的 hook 来做这件事function useForegroundRefresh(refreshFn: () void) { const appState useRef(AppState.currentState); useEffect(() { const sub AppState.addEventListener(change, (nextState) { if (appState.current background nextState active) { refreshFn(); } appState.current nextState; }); return () sub.remove(); }, [refreshFn]); }这个 hook 不只用于通知设置页后续任何需要回到前台刷新的页面都能复用。和页面生命周期绑在一起的逻辑抽成 hook是跨端开发里避免重复代码的有效手段。7. 验收清单与可扩展方向7.1 手动验收清单写完了实现最后做一个手动验收。我把测试步骤做成清单方便团队其他成员自测测试场景操作方式预期结果首次启动安装后直接进设置页总开关为关或按产品策略显示引导分类开关显示默认值开关不闪烁打开总开关点击总开关系统弹出授权框同意后开关保持打开拒绝授权点击总开关在弹出的系统框中拒绝开关回到关闭提示引导文案系统设置关闭到系统设置里关闭通知回 AppApp 设置页总开关自动变为关闭发送测试通知点击测试通知按钮通知栏出现一条普通文本通知分类开关持久化关闭折扣提醒杀死 App 重启折扣提醒保持关闭其他开关不受影响这个清单也适合放到 CI 里做冒烟测试的人工入口我后来把测试通知按钮做进了开发菜单QA 每次提测前会先跑一遍。7.2 后续扩展从本地通知到服务端推送这篇文章的核心是本地通知闭环但对资讯类 App 来说最终形态一定是服务端推送。OpenHarmony 上的服务端推送属于独立的推送服务和本地通知走的不是一条链路。不过有一点可以提前规划页面 UI、开关配置、偏好持久化这些都不需要改只需要新增一个桥接方法用来注册推送 token以及处理服务端消息到达后的展示。也就是说本文的设置在架构上能平滑过渡到推送服务这是我在设计桥接层时特意预留的扩展点。富文本通知也是后续可以考虑的方向。OpenHarmony 的 NotificationRequest 里除了 BASIC_TEXT还有很多内容类型比如长文本、图片、进度条。如果你做的是游戏资讯类封面图通知的点击率通常明显高于纯文本。实现上是在桥接层增加一个新的 publish 方法接收 imageUri 等参数JS 侧包一层即可。7.3 一点个人体会做了几轮跨端适配给准备入坑的同学一个最直接的建议桥接层一定要做薄只放系统能力所有业务逻辑不要进 ArkTS。通知设置这个模块ArkTS 侧只做了三件事——查开关、请求授权、发通知其余状态管理、策略判断、引导逻辑全在 JS 侧。这么做的好处是就算以后整个壳工程换掉业务代码也能完整迁移。还有就是在开发前期就把各种异常返回路径想全。通知设置是最容易出看似没反应问题的模块授权被拒、系统开关被外部改变、时序错乱这些都属于常态而不属于意外。我在做这个模块的时候把能 catch 的异常都 catch 住并返回兜底值后来线上反馈反而少了很多。面对系统能力防御式编程不是保守是务实。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →