这套组合真正做起来之后,解决的是个非常实际的成本问题:一套业务代码不需要重写,就能触达一个全新的系统底座。最近我在做一个 Steam 资讯类 App 的 OpenHarmony 适配,最让我意外的不是复杂的列表页,反而是看起来不起眼的通知设置模块。这个模块要处理系统权限、通知渠道、本地持久化、后台限制,几乎把跨端开发的坑都踩了一遍。这篇文章不写广告,我把从工程搭建到真机调试的过程、代码和踩坑记录全部摊开,适合正在做 RNOH 适配,尤其是资讯工具类产品的开发者参考。
1. 项目全貌与设计拆解:通知设置不是三个开关那么简单
1.1 为什么是 React Native + OpenHarmony
先说结论:选 React Native for OpenHarmony(下面直接叫 RNOH),不是因为“OpenHarmony 没有原生方案”,而是因为团队里已经积累了一套完整的 React Native 业务组件库。如果每条产品线都在 OpenHarmony 上重新用 ArkTS 写一遍,维护成本会直接翻倍。RNOH 的价值在于把 JS 侧的逻辑、状态管理、UI 组件大部分搬到鸿蒙底座上,原生部分只需要做系统能力桥接。
这个项目的业务是 Steam 资讯聚合:数据来自 Steam 公开的新闻与商店接口,加上自己的运营配置。界面核心是信息流列表,配合搜索、收藏、详情页。整体来说 UI 不算复杂,但功能链路很长,需要联网、缓存、通知、设置,非常适合验证 RNOH 在真实产品里的完成度。通知设置就是其中链路最长的一块,从用户手指点开开关,到系统通知栏真的弹出内容,中间隔了权限、渠道、持久化三层,每一层都有独立的状态,这也是我把它单独拿出来写的原因。
1.2 通知设置这个模块要拆成几块
如果只是做一个“总开关”,这个模块半天就能写完。但资讯类 App 的通知如果没有细分,用户很快会被垃圾推送赶走。我的方案是按消息类型拆开关,每个开关对应一条链路。
通知类型分了三种:
- 每日摘要:每天固定时间汇总一次重要资讯,属于高价值低打扰。
- 促销活动:Steam 打折、专场上线提醒,价格敏感用户会喜欢。
- 关注更新:用户主动关注的游戏或应用有新动态,默认关闭,等用户建立了关注关系再引导打开。
每个开关背后都有两个状态:用户是否授权了系统通知权限,以及业务开关是否打开。业务开关由 App 自己持久化,系统权限由 OpenHarmony 管理。代码层面必须把两层状态分开处理,否则会出现系统权限关了、但 UI 上开关还亮着的尴尬场景。
1.3 完整链路长什么样
从用户点击开关开始,事件路径是这样的:
- 前端把新状态写入本地存储(AsyncStorage)。
- 前端调用原生模块,确保对应的通知渠道存在。
- 系统层面检查通知权限是否开启,若未开启则发起授权请求。
- 后台拉取资讯后,发布通知之前先读取业务开关状态,只有开关打开才真正 publish。
最容易出错的是最后一步。很多跨端开发者习惯把“设置项存下来了”等同于“通知已经按配置发送了”,实际不是。发布通知时一定要再读一次开关,因为用户可能在存储写入之后、通知发布之前改变了决定。我的做法是每次发布前都从 AsyncStorage 读一次,而不是把配置缓存在内存里。
2. 通知权限、渠道与持久化:先定规则再写代码
2.1 把权限弹窗当成一道状态机来设计
OpenHarmony 的通知授权模型和 Android 13 之后的 POST_NOTIFICATIONS 很像:不是安装时静态授权,而是运行时动态请求。应用需要调用notificationManager.requestEnableNotification()来弹出系统授权框,同理可以用notificationManager.isNotificationEnabled()查询是否已经授权。
这里容易踩的第一个坑是“弹窗时机选择”。如果一进设置页就立刻弹授权框,很容易被用户直接拒绝。正确做法是:先展示页面,等用户真正打开第一个开关时再触发请求。用户能理解“我要开开关,所以系统问我要权限”,这个因果关系顺序不能反。
我把权限状态设计成一个简单的状态机:
| 状态 | 判定方式 | 页面表现 |
|---|---|---|
| 未决定 | isNotificationEnabled()返回 false,且从未请求过 | 显示“开启后接收资讯提醒”说明按钮 |
| 已拒绝 | 请求后返回 false | 显示“去系统设置开启”的引导入口 |
| 已授权 | 返回 true | 开关可正常操作 |
有一个很重要的细节:requestEnableNotification()并不保证一定成功,而且拒绝之后再次调用不会重新弹窗,需要用户去系统设置里手动改。所以开发时不能只写“发起请求,然后静默等待回调成功”,必须在请求完成后主动查询一次状态,用查询结果刷新 UI。
2.2 用通知渠道承接不同通知类型
OpenHarmony 的通知渠道概念(系统里叫 NotificationSlot)和 Android 的 NotificationChannel 基本同构。每个 slot 有独立的名称、描述、级别(level),用户可以针对单个渠道单独调整通知行为。用类似“微信给不同的群分组设置提醒方式”的生活化类比就很好懂。
我在原生侧为三类通知建了三个渠道:news_digest、sale_active、follow_update。创建 slot 时设置了不同的 level:促销活动用默认级别,每日摘要也保持默认,后续如果想要更安静的模式可以调低。渠道创建是一个幂等操作,建议每次启动时都执行一次,因为用户可能在系统设置里把渠道删过。
渠道创建时要注意 slot ID 的稳定性。ID 一旦定下来就不要改,否则用户对某个渠道已有的偏好设置会全部丢失。开发期我改过一次 ID,结果测试真机上用户之前设置过的“静音”状态全部回滚到默认,这个教训很直接。
2.3 开关默认值:第一次安装就要想清楚
默认值策略会直接影响留存。刚开始我把三个开关全部打开,结果测试阶段就被用户吐槽推送太频繁。后来改成只默认开启“每日摘要”,这就是一个“高价值低打扰”的底线设置。
| 开关项 | 默认值 | 理由 |
|---|---|---|
| 每日摘要 | 开 | 资讯类产品的核心体验,一天一条不打扰 |
| 促销活动 | 关 | 激活的必要动作是用户主动打开,避免负反馈 |
| 关注更新 | 关 | 依赖关注关系,关系建立后再引导开启 |
持久化我用的是 AsyncStorage,因为@react-native-async-storage/async-storage在 RNOH 上可以直接跑。用户每次切换开关,就立即写入存储。注意写入是异步的,开关连点的情况下后一次写入可能覆盖前一次,我在代码里对 toggle 做了简单的防抖,两次点击间隔小于 200 毫秒时忽略第二次。
3. 代码实操:跑通通知设置全链路
3.1 工程初始化与原生模块注册
RNOH 工程的初始化通常是用社区模板。初始化完成后,工程里同时存在 HarmonyOS 原生部分和 React Native 的 JS 部分。原生模块是在 HarmonyOS 侧用 ArkTS 写的,JS 侧通过 React Native 的标准机制调用。
原生模块的注册过程在不同版本里有差异,但基本套路一致:写一个类继承 TurboModule,然后通过包注册导出给 JS。跳过的部分是脚手架细节,真正难的是模块内部怎么调用 OpenHarmony 的系统 API。下面这段示意代码表达核心意图,字段名以你当前 SDK 导出为准。
// NotificationModule.ets import notificationManager from '@ohos.notificationManager'; import { TurboModule } from 'RNOH/ts/TurboModule'; export class NotificationModule extends TurboModule { async isEnabled(): Promise<boolean> { return await notificationManager.isNotificationEnabled(); } async requestEnable(): Promise<boolean> { return await notificationManager.requestEnableNotification(); } async ensureChannels(): Promise<void> { await this.addSlot('news_digest', '每日摘要', '每天一次重要资讯汇总'); await this.addSlot('sale_active', '促销活动', '折扣与专场上线提醒'); await this.addSlot('follow_update', '关注更新', '关注的新内容动态'); } private async addSlot(id: string, name: string, desc: string) { const slot = new notificationManager.NotificationSlot(); (slot as any).slotId = id; (slot as any).name = name; (slot as any).description = desc; (slot as any).level = notificationManager.SlotLevel.LEVEL_DEFAULT; await notificationManager.addSlot(slot); } }这段代码的意图很明确:把系统能力封装成三个 JS 可以异步调用的方法。isEnabled负责权限状态查询,requestEnable负责发起授权,ensureChannels负责确保三个渠道都存在。
3.2 原生侧能力封装:查询、申请、建渠道
ArkTS 侧的代码写完之后,需要注册到 RNOH 的模块体系里。注册写法不同版本略有变化,重点是理解“JS 侧拿到的 NativeModules.NotificationModule 就是这边导出的对象”。
有一个很容易忽略的点:OpenHarmony 的addSlot是异步操作,真机上偶尔会失败。失败原因可能是重复添加同一 slot ID、参数校验不通过、系统通知服务没就绪。所以ensureChannels里每个 slot 都单独 try-catch,一个失败不影响另外两个。发布通知之前也会检查对应 slot 是否存在,不存在时先重建再发送。
顺便提一句渠道名称的问题。slot 的 name 和 description 会直接展示在系统设置里,用户看得到。我见过有些应用把渠道名称写成内部代码名,比如notify_type_01,用户完全看不懂这是干嘛的,结果只能全部关闭。名称一定要写成用户能理解的话。
3.3 JS 设置页与状态持久化
JS 侧设置页的核心逻辑是:初始化时读本地配置与系统状态,用户操作开关时更新内存、写入本地存储,并实时校验系统权限。
import React, { useEffect, useState } from 'react'; import { Text, View, Switch, StyleSheet, NativeModules, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; const NotificationModule = NativeModules.NotificationModule; const STORAGE_KEY = 'notify_settings'; const DEFAULT_SETTINGS = { digest: true, sale: false, follow: false, }; const LABELS = { digest: '每日摘要', sale: '促销活动', follow: '关注更新', }; export default function SettingsScreen() { const [systemEnabled, setSystemEnabled] = useState(true); const [settings, setSettings] = useState(DEFAULT_SETTINGS); useEffect(() => { loadInitialState(); }, []); const loadInitialState = async () => { const enabled = await NotificationModule.isEnabled(); setSystemEnabled(enabled); const raw = await AsyncStorage.getItem(STORAGE_KEY); if (raw) { setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(raw) }); } }; const toggle = async (key, value) => { if (!systemEnabled && value) { const granted = await NotificationModule.requestEnable(); if (!granted) { return; } setSystemEnabled(true); } const next = { ...settings, [key]: value }; setSettings(next); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); }; return ( <View style={styles.container}> <Text style={styles.title}>消息通知</Text> {Object.keys(LABELS).map((key) => ( <View key={key} style={styles.row}> <Text style={styles.label}>{LABELS[key]}</Text> <Switch value={settings[key]} onValueChange={(v) => toggle(key, v)} /> </View> ))} <Text style={styles.tip}> 系统通知权限当前{systemEnabled ? '已开启' : '未开启'} </Text> </View> ); } const styles = StyleSheet.create({ container: { padding: 16 }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, }, title: { fontSize: 18, fontWeight: '600', marginBottom: 12 }, label: { fontSize: 16 }, tip: { marginTop: 16, color: '#888', fontSize: 12 }, });这段代码有两点值得展开说说。第一,loadInitialState用 Promise 并行触发更好,但要注意 AsyncStorage 的读取可能在原生模块返回值之后才回来,两个 setState 不要在同一个时序里合并,否则配置会被覆盖。第二,toggle里如果系统权限是关闭的,用户又打开新开关,就先请求权限,请求成功后才写入配置,这个顺序不能反,否则会出现“业务开关开了,但系统不允许发通知”的残留状态。
3.4 启动白屏:RNOH 联调的第一道坎
热词里“react native 启动白屏”出现频率很高,我在这个项目里也真实遇到了。现象是 App 启动后屏幕一直空白,没有 JS 报错,也没有崩溃日志。
排查顺序很重要。我整理的 checklist 如下:
| 现象 | 优先检查 | 处理方式 |
|---|---|---|
| 启动一直白屏 | Metro bundler 是否运行 | 启动npm start,确认 bundle URL 指向正确 |
| 白屏且 hilog 无 JS 日志 | 原生模块注册失败 | 过滤RNOH/JS关键日志,查看模块加载流程 |
| 只有特定页面白屏 | 页面组件或字体下载失败 | 移除自定义字体,逐个页面二分定位 |
| Release 包白屏 | bundle 未内置 | 将 bundle 打包到rawfile目录并更新路径 |
| 偶发白屏 | RN 实例初始化竞态 | 延迟加载页面,或统一走启动路由加载完成回调 |
排到最后发现,我的问题出在自定义字体的加载顺序上。RNOH 在字体加载完成之前就渲染了页面,字体资源比较大时首帧容易被卡住。解决办法是把字体加载封装成独立模块,渲染主界面之前先 await 完成。这种问题在 Android 上不明显,到了 OpenHarmony 才暴露,也算跨端开发的一种常态。
4. 真机上线前的疑难杂症
4.1 权限被拒后怎么引导
用户拒绝权限弹窗之后,应用再次调用requestEnableNotification是不会有弹窗的。如果 UI 上还继续展示一个“开启权限”按钮,用户点了没反应,就会认为应用坏了。我的处理方式是在开关行下面出现一行浅色提示:去系统设置 > 通知管理 > 找到本应用,手动打开“允许通知”。
系统设置页的跳转在 OpenHarmony 上可以通过能力拉起,具体接口以 SDK 为准,但设计上要记住一个原则:不要反复诱导用户去设置页。第一次拒绝后给引导入口,第二次拒绝后就不主动提示了,除非用户自己进入设置页触发查询。
另外我遇到过一个诡异现象:用户已经在系统设置里手动打开了通知权限,但回 App 里开关还是旧的。原因是我没有监听权限变化事件。解决办法是每次页面从后台回到前台时重新查一次isNotificationEnabled(),用AppState监听做刷新。这个细节在测试阶段不容易暴露,但真实用户很容易触发。
4.2 后台通知被系统拦截
OpenHarmony 对后台任务有严格的管控策略,这是所有 Android 开发者迁过来时最先不适应的点。App 退到后台之后,系统可能不会让普通的定时器持续运行,如果你依赖 JS 侧的setTimeout来定时拉取资讯,很快就会发现推送“睡着”了。
资讯类产品如果要定时推送,应该走系统的延迟任务能力,在 HarmonyOS 侧申请任务调度,而不是让 JS 侧自己循环。更务实的方案是:第一天先做本地通知链路,后台拉取交给后续版本接系统推送服务,这样能保证通知设置模块的闭环逻辑先被验证。
我在这个项目里对后台策略做了降级:App 在前台时正常拉取并发布本地通知;退到后台超过五分钟就不再尝试拉取,等下次启动或进入前台时补拉。产品上看起来是“通知不吵了”,技术上其实是主动避开后台管控,效果反而更好。
4.3 网络报错先切分,再查代码
开发期最常见的报错是连接不上目标服务器,现象可以总结为“服务连接失败”。这类问题我踩过最多次的坑是:把网络问题当成了代码问题,反复改接口逻辑,最后发现根本不是代码的锅。排查路径应该按三层切分:
第一层是网络连通性。先用终端测一下目标域名能否连通。第二层是证书问题,测试环境如果用自签名证书,真机默认不信任,会直接握手失败。第三层是业务层错误,服务器已经连上了,但接口返回了错误码。
有一个很隐蔽的原因:真机系统时间不正确,导致 TLS 证书有效期校验不通过。这个判断起来很容易,看一眼系统时间是否和当前时间一致就行。做跨端适配时,这个经验帮我省了很多时间。
4.4 常见问题速查表
到这里,把整个项目过程中遇到的典型问题整理成一张表,方便后面的人直接对照:
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 权限弹窗不出现 | 请求时机在 UI 渲染前或在后台 | 放到页面可交互后的用户点击回调里 |
| 通知发了但没显示 | 渠道不存在或 slot level 过低 | 发布前调用 ensureChannels,检查 level |
| 权限开了但开关仍显示关 | 本地存储未读到或写入失败 | 查看 AsyncStorage 键值,确认 JSON 格式 |
| 设置被恢复默认 | 测试期间改了渠道 ID | 渠道 ID 保持稳定,不要随便改 |
| 通知发送重复 | 前台拉取和快速退回前台重复触发 | 通过时间戳去重,同一消息 5 分钟内只发一次 |
| 页面白屏 | 字体加载、bundle 路径、原生模块注册 | 按 3.4 的 checklist 逐项排除 |
5. 复盘与建议
5.1 如果重做一次,改动最大的地方
如果这个项目能重来,我会在第一天就把“系统权限”和“业务开关”两层状态彻底分离建模,而不是先写着看看。当时图省事,在设置页里用一个布尔值同时代表两层状态,结果权限被拒绝和开关关闭混在一起,页面状态变得非常被动。后来花了一整个下午重构才理清楚。
另外,通知渠道的设计应该由产品经理提前参与。我当时自己拍板定了三种通知类型,做完后运营说还需要“预约上架提醒”和“好友动态”,核心架构没问题,但渠道数量得后续补。如果一开始定义好扩展规则,这步会更顺。
5.2 给同样在填坑的人三条建议
第一条,先跑通最小闭环:一个开关 + 一个渠道 + 一条本地通知。能跑通再往里面加类型,否则一次引入过多变量,出了问题你根本不知道是哪一层挂了。
第二条,善用 hilog。RNOH 联调时,hilog里带RNOH、JS、NotificationManager关键字的日志会帮你快速定位问题边界。别只盯着屏幕看白屏,日志才是第一现场。
第三条,真机调试比模拟器重要得多。权限弹窗、后台限制、通知渠道这些能力在模拟器上往往表现正常,只有真机才会暴露真实策略差异。这个项目如果只在模拟器上跑,大概一半的坑都不会被发现,上线前一定会炸。
说到底,通知设置模块本身不是什么高科技,但它是一面照妖镜,能把你对系统能力的理解、对边界状态的把握、对异常路径的处理能力照得清清楚楚。做完了这个模块,我对 RNOH 的信心反而比之前更足了。