几家医疗健康类的App团队这两年都在做同一件事:把原本只在iOS和Android上跑的业务,快速落到鸿蒙生态里。我手头这个项目就是其中典型的一例——用React Native做跨平台框架,给口腔护理产品线做一个“今日任务”模块。这个模块看着不大,无非是每日打卡、任务清单、积分激励,但真要把RN这一套在鸿蒙上跑顺,水比想象中深。
先说结论:React Native在鸿蒙上能跑,而且跑到能上线状态完全可行,但过程不是“npm install然后build”这么简单。这篇就把我踩过的坑、填过的洞、以及最终跑通的口腔护理今日任务模块的完整方案拆开聊。
1. 项目背景与整体方案选型:为什么是React Native + 鸿蒙 + 口腔护理今日任务
1.1 业务需求拆解:口腔护理的今日任务到底要做什么
口腔护理App里的“今日任务”模块,本质上是一个用户留存和习惯养成工具。产品经理给的需求一句话就能说完:用户打开App,能看到今天要完成的口腔护理动作(比如刷牙满2分钟、使用牙线、漱口水漱口),完成一个就打卡一个,打卡积累积分,积分兑换权益。
但这句话落到技术侧,拆出来的是这么一堆东西:
- 任务模板管理:后台配置每天的任务项,可能按用户画像动态下发。今天可能是“早晚刷牙各一次”,明天可能加一条“使用牙线”。
- 打卡状态管理:每个任务有未完成、已完成、已过期三种状态,状态变更要实时反映在UI上。
- 计时与提醒:刷牙任务需要计时器,提醒用户刷满2分钟;还有本地通知,到点提醒该做口腔护理了。
- 积分系统:任务完成后发放积分,积分流水要可查。
- 数据同步:用户可能在平板上看任务,在手机上打卡,数据要跨端同步。
这个模块在iOS和Android上已经跑了一年多,业务逻辑稳定,唯一的问题就是要不要让它在鸿蒙上也能跑。
1.2 为什么选React Native做鸿蒙跨平台
团队内部讨论过三条路:Flutter、uni-app、React Native。
Flutter的鸿蒙适配当时在社区里刚起步,不少底层组件要靠自绘,Canvas性能和原生体验差距还在收窄中。uni-app对鸿蒙的支持走的是小程序容器思路,业务代码要往DSL上靠,对我们这种已有RN代码库的团队来说等于重写。
React Native的路线看着最顺,原因有三。第一,我们的业务代码全是React/RN纯JS层写的,原生桥接模块占比不高。第二,社区里已经出现了react-native-harmony这类的桥接方案,能把RN的渲染层接到鸿蒙的ArkUI上,视频、图片、网络这些核心组件都有对应实现。第三,团队里没有人想重新学一套ArkTS再去写一遍业务UI,RN允许我们用Javascript代码包去适配多端,对现有生产力是最大保护。
我们要做的口腔护理“今日任务”,不是那种强依赖原生能力的重模块,主要交互是列表、弹窗、计时器、本地通知、网络请求。这些在RN鸿蒙化的渲染层里基本能全覆盖。所以方案定了:保留RN业务代码不动,把原生桥接层做鸿蒙适配,JS Bundle包由鸿蒙运行时加载。
1.3 鸿蒙跨平台的整体架构思路
整体分三层:
- JS业务层:口腔护理任务模块的所有UI和业务逻辑,用RN写,双端共用一份代码。状态管理用Zustand,网络请求用axios封装,本地存储用async-storage。
- Bridge桥接层:RN在鸿蒙端需要把原生渲染能力桥接给JS。这部分的工作是把iOS/Android上已经写好的原生Module,用ArkTS语言在鸿蒙侧重新实现一遍,保持JS调用接口完全一致。
- 鸿蒙原生层:负责承载RN运行时、渲染JS组件、提供原生能力(通知、计时器、文件存储等)给Bridge层调用。
这个分层的核心优点是:业务代码零修改,只动桥接层。我们把协议定好,JS层的代码完全不知道底下跑的是Android还是HarmonyOS。
2. React Native鸿蒙化的工程搭建与适配真相
2.1 开发环境与工程结构
鸿蒙侧的开发工具是DevEco Studio,这个没得选。我们要做的是在鸿蒙工程里集成RN运行时。
工程结构大概长这样:
HarmonyApp/ ├── entry/ │ ├── src/main/ │ │ ├── ets/ # ArkTS层代码 │ │ │ ├── entryability/ │ │ │ ├── pages/ │ │ │ └── rn/ # RN宿主页面 │ │ ├── resources/ │ │ └── module.json5 ├── hvigor/ ├── oh_modules/ # 鸿蒙依赖 ├── node_modules/ # RN依赖 └── package.json关键点是,鸿蒙工程里要嵌入一个RN的Bundle加载器。我们的做法是:用DevEco建一个原生工程壳,在页面生命周期里加载RN的JS Bundle。JS Bundle构建产物放在entry/src/main/resources/rawfile/下面,这样打包进HAP后,RN代码作为资源随App发布。
集成的时候会用到react-native-harmony这个仓库提供的runtime适配层。它把RN的AppRegistry跑起来后,用ArkUI的Component去承载RN渲染出来的原生组件树。这意味着RN的View、Text、Image在鸿蒙上不是画在WebView里,也不是自绘的,是真的对应到ArkUI组件上,性能表现和原生App差距不大。
2.2 关键适配点:启动白屏、原生模块桥接与事件机制
RN鸿蒙化最经典的坑就是启动白屏。RN的构建产物是JS Bundle,运行时需要先加载和执行JS,JS又可能通过网络请求远程获取Bundle。中间任何一个环节慢,你看到的就是一片空白。
我排查白屏问题总结出三个高发原因:
- Bundle加载路径错误:鸿蒙的
rawfile路径读取方式跟Android的assets不一样,如果路径写错就会直接加载失败,页面白屏并且控制台无输出。 - JS引擎初始化未完成:鸿蒙的RN适配层要求先初始化JS引擎,再加载Bundle。如果代码在引擎初始化前就调用
loadBundle,会静默失败。我就踩过:iOS和Android上能正常跑的代码,到鸿蒙上白屏,加一行生命周期等待就解决了。 - 远程Bundle调试开启:开发模式下如果用debug server,鸿蒙模拟器需要能连到宿主机。连不上就一直白屏,俗称“加载了个寂寞”。
桥接层另一大块是原生模块注册。口腔护理任务模块需要原生计时(后台计时不准的问题)、本地通知、以及震动反馈。这些能力在RN里通过NativeModules暴露给JS。鸿蒙侧要用ArkTS写对应的实现类,并在OnCreate里注册。
以本地通知为例,iOS上用UNUserNotificationCenter,Android上用AlarmManager,鸿蒙上用reminderAgentManager。三端实现完全不同,但JS层只认一个接口:
import { NativeModules } from 'react-native'; const { TaskReminderModule } = NativeModules; // 在JS层调用的接口,三端实现各自不同 TaskReminderModule.scheduleDailyReminder({ taskId: 'morning_brushing', hour: 8, minute: 0, title: '今日口腔任务提醒', message: '该刷牙啦,记得刷满2分钟', });这就是桥接层存在的意义:接口一致,实现隔离。
2.3 构建流程优化:如何缩短鸿蒙端的Bundle加载时间
白屏问题排查完了,还得优化性能。RN在鸿蒙上启动速度比Android慢一点,主要慢在JS引擎启动和Bundle解析。这块我用了几招:
Bundle拆分:把口腔护理模块代码从一个大的bundle里拆出来,用require.context按需加载。首屏只需要加载任务列表主页的代码,打卡详情页的代码等用户点进去再拉。Android上Bundle拆分会增加网络请求,但在鸿蒙的本地加载场景下,直接按路由拆包就行。
开启Hermes引擎:鸿蒙RN适配层对Hermes的支持在近几个版本已经成熟,用Hermes后JS的解析时长能砍掉30%到40%。这个升级是纯收益。
本地图片处理:口腔护理的任务图标列表动辄几十张,如果都打包进Bundle体积会很肥。我们的方案是把图标资源放到rawfile目录,JS里用require引用资源ID,让原生去加载,不走JS层解码。
3. 口腔护理今日任务模块的核心实现细节
3.1 任务数据模型与状态管理
任务模块的数据结构,得先想清楚。我们最终定的是这张表:
| 字段 | 类型 | 说明 |
|---|---|---|
| taskId | string | 任务唯一ID |
| taskType | string | 枚举:brushing / flossing / mouthwash |
| title | string | 任务名称,如“早晚刷牙” |
| description | string | 任务描述 |
| targetValue | number | 目标值,如刷牙目标120秒 |
| progressValue | number | 当前进度,如已刷45秒 |
| status | string | 枚举:pending / completed / expired |
| deadline | string | 任务截止时间 |
| rewardPoints | number | 完成奖励积分 |
| repeatType | string | daily / once |
状态管理选的是Zustand,因为跟Redux比它样板代码少得多,适合React Native环境,而且它对超轻量任务列表场景来说性能开销接近零。
import { create } from 'zustand'; const useTaskStore = create((set, get) => ({ tasks: [], todayScore: 0, setTasks: (tasks) => set({ tasks }), updateTaskProgress: (taskId, progress) => set((state) => ({ tasks: state.tasks.map((task) => task.taskId === taskId ? { ...task, progressValue: progress } : task ), })), completeTask: (taskId, rewardPoints) => set((state) => ({ tasks: state.tasks.map((task) => task.taskId === taskId ? { ...task, status: 'completed' } : task ), todayScore: state.todayScore + rewardPoints, })), }));这里有个细节,就是打卡完成的任务要从任务列表保留在视图上,但置灰显示。很多新手会把已完成任务过滤掉,导致用户看不到自己今天做了什么,成就感降低,次日留存率明显下降。这个不做过滤的决策是产品侧和研发侧拉通后定的,务必在代码注释里写清楚。
3.2 任务列表与打卡交互实现
任务列表的UI结构是:进度条 + 任务名 + 状态图标 + 打卡按钮。RN实现起来很直接,用FlatList渲染。但要注意鸿蒙端的渲染性能,任务列表如果超过20项,且每天都变,就会涉及大量列表项的重渲染。我们的优化是给FlatList强制设置keyExtractor,并在任务状态变更时只更新变更的那一行。
打卡交互是高频操作。打卡按钮点击后,理想情况是按钮立即变成已打卡,视觉反馈要反馈给用户,然后异步请求后端同步状态。如果等接口返回再更新UI,在弱网环境下体验会灾难。我们的实现是:先更新本地状态,再发API请求,请求失败则回滚并提示。
const handleCheckIn = async (task) => { // 乐观更新:先改UI const previousTasks = useTaskStore.getState().tasks; useTaskStore.getState().completeTask(task.taskId, task.rewardPoints); try { await api.submitTaskProgress({ taskId: task.taskId, completedAt: new Date().toISOString(), }); } catch (error) { // 回滚 useTaskStore.setState({ tasks: previousTasks }); Toast.show('网络异常,打卡失败,请重试'); } };3.3 计时器与刷牙计时功能实现
刷牙计时是“今日任务”模块里最有技术含量的部分。用户点击“开始刷牙”,要有一个计时器在跑,记录刷牙时长,刷满2分钟自动打卡成功。
RN端实现计时器,最大的障碍是App退到后台计时器会挂起。iOS和Android都还好,鸿蒙端的后台策略更严格,如果计时器完全靠JS层跑,用户按Home键回来,时间可能就停在原地了。
解决方案是计时源分开。UI层用JS计时器做秒表显示,数据层用原生时间来算已刷时长。也就是每次tick的时候,比较本地时间戳和开始时间戳,差值就是实际经过的秒数。这样就算JS线程被冻结,回来之后时间戳一减,进度依然是对的。
const startTimeRef = useRef(Date.now()); useEffect(() => { const interval = setInterval(() => { const elapsedSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000); setElapsed(elapsedSeconds); }, 250); // 250ms刷新一次,UI更顺滑 return () => clearInterval(interval); }, []);另外,刷牙计时结束后要触发打卡成功。这个结束判定不能依赖JS层的setInterval,因为在低功耗场景里,JS计时可能被延迟或合并。我们用了一个原生模块来做精确的定时回调:
import { NativeModules } from 'react-native'; const { BrushingTimerModule } = NativeModules; BrushingTimerModule.startTimer(120, () => { // 原生定时器触发,回到JS层执行打卡逻辑 completeBrushingTask(); });4. 跨平台联调与数据同步:鸿蒙端的最佳实践
4.1 与口腔健康服务的API对接
网络层是所有跨平台方案里最容易踩坑的环节。RN自带fetch,但实际项目里我们统一用axios做封装,加上拦截器做统一鉴权、错误码映射、埋点上报。
鸿蒙端和Android/iOS在API对接上最大的差异是网络权限和网络安全配置。鸿蒙的网络权限默认是关闭的,需要在module.json5里声明:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET" }, { "name": "ohos.permission.GET_NETWORK_INFO" } ] } }漏配INTERNET权限,表现就是所有请求都失败,而且错误信息不一定直观,可能只是显示超时。我一开始还以为是RN网络层在鸿蒙上有Bug,排查了半天,最后发现是权限没开。
另一个大坑是证书校验。我们的后端接口有一部分是自签名证书(测试环境),iOS和Android都能配置绕过,鸿蒙端的网络安全配置没走RN层,直接受系统管理。测试环境联调的时候,鸿蒙端会直接报证书错误。最简单的方案是测试环境用http明文(仅限内网),生产环境走正规HTTPS。
4.2 多端数据一致性与离线能力
口腔护理任务有一个典型场景:用户在地铁上打卡,网络断了,打卡操作卡在本地,恢复网络后要能自动同步。这个场景要求本地必须有完整的数据副本,且同步逻辑要能处理冲突。
我们方案的核心是本地数据库 + 同步队列。本地用SQLite存任务数据,每次打卡操作先落库,然后往同步队列推一条“待同步事件”。联网成功后,同步队列逐条推给服务端,服务端幂等处理后返回确认,队列里的事件再标记为完成。
// 同步事件队列里的一条数据 interface SyncEvent { eventId: string; action: 'CHECK_IN' | 'UPDATE_PROGRESS'; taskId: string; completedAt: string; createdAt: string; } // 恢复网络后触发同步 NetInfo.addEventListener((state) => { if (state.isConnected) { flushSyncQueue(); } });服务端要做好幂等。用户可能在手机上打卡,在鸿蒙平板上又打卡一次,两个设备各自在离线状态下都记了已完成。同步上来之后,服务端要对同一个taskId + date做去重,否则用户积分会被重复发放。我们的方案是服务端用taskId + date + action作为唯一键,CheckIn事件重复提交直接返回成功且积分只发一次。
4.3 鸿蒙端本地存储的方案选型
RN项目里,本地存储最常见的是@react-native-async-storage/async-storage。这个库在鸿蒙上有社区实现。但是它的性能在大数据量场景下有瓶颈,口腔护理的任务数据每天几十条,累积一年就是几千条,全量读出来做运算不合理。
所以我们的数据落地分了两层:轻量KV数据(用户配置、登录态)用AsyncStorage,结构化任务数据用SQLite。鸿蒙端没有现成的RN SQLite库,但可以用ArkTS的RelationalStore封装一个Bridge Module,给JS层提供query/insert/update接口,JS层再包一层Repository,业务代码完全感知不到底层是SQLite还是别的存储引擎。
分层的效果是:任务列表启动时只有SQLite做一次本地查询,速度在十几毫秒级别;状态变更时也只做单行更新,不会阻塞UI线程。
5. 常见问题与排查实录:鸿蒙适配避坑清单
5.1 启动白屏与首屏优化
鸿蒙RN启动白屏,前面提过三个原因。这里再补充一个隐藏的:模拟器和真机的行为不一致。开发时用模拟器调通了,上真机白屏,最常见是本地Bundle服务地址写死了localhost。真机访问localhost指向的是它自己,肯定加载不到宿主机上的Bundle。解决办法是把Bundle内置进App包,或配置为局域网IP。
首屏优化的顺序建议是:
- 先确认Bundle加载成功(log里能看到RN bundle loaded)。
- 再优化JS执行效率,拆大包、开Hermes。
- 最后优化原生到JS的渲染链路,比如让首屏组件不依赖异步数据。
实测下来,这三个顺序不能乱。很多人一上来就想优化渲染链路,结果发现Bundle压根加载失败,白忙活。
5.2 鸿蒙端网络请求异常
鸿蒙端的RN网络请求失败,错误类型五花八门。我整理过一张速查表,留着排查用:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 所有请求超时 | 没配INTERNET权限 | 检查module.json5 |
| HTTPS证书报错 | 自签名证书不被信任 | 换正规证书或内网HTTP |
| 请求返回2300056 | 网络层底层映射异常 | 检查网络代理/抓包工具是否干扰 |
| 高并发请求丢包 | 原生层连接池未复用 | 封装单例网络模块,复用Session |
有一条尤其值得说:鸿蒙系统版本差异导致的网络行为不一致。早期鸿蒙版本对HTTP协议族的支持不如新版本完整,某些HTTP/2特性可能降级。RN的axios库fetch实现是基于系统的网络库,如果遇到诡异的不通问题,先用系统浏览器访问相同接口做对照测试。
5.3 本地通知权限与后台任务
口腔护理App的“今日任务”模块最依赖本地通知,提醒用户按时刷牙。iOS和Android都在权限弹窗、系统通知栏管理上做过适配,鸿蒙这块有它自己的脾气。
鸿蒙的本地通知需要申请ohos.permission.NOTIFICATION_CONTROL相关权限,而且通知能力是按服务分配的。集成reminderAgentManager后,要确保通知通道已创建,否则通知发不出去但不报错,这个“静默失败”很坑。
后台任务的保活,口腔护理不需要像地图导航那样激进,但定时提醒必须可靠。我们的方案是:短期提醒(几分钟内)走setTimeout转发给原生层调度;长期提醒(每天早8点)走reminderAgentManager的日历闹钟式调度,这个由系统接管,App进程被杀也能触发。
5.4 低端鸿蒙机型的性能适配
鸿蒙生态的设备性能跨度很大,上到旗舰手机,下到百元机。RN本身在低端Android上的表现就吃紧,鸿蒙低端机上更要注意。
我在低端鸿蒙机上遇到过两个典型的性能问题:
FlatList滑动卡顿:当任务列表项比较多,且每项都渲染进度条和动画时,低端机会掉帧。解决办法是给
FlatList的renderItem包一层React.memo,并且用getItemLayout指定行高,让FlatList免去动态测量。任务列表的行高是固定的,这是很大的性能红利。原生模块调用频繁崩溃:刷牙计时器如果每250ms调一次原生模块取时间,在低端机上会造成频繁的JSI bridge调用,偶发崩溃。最终我们改成每1秒调一次原生时间,UI秒表显示用本地累计值,视觉上完全无感知,崩溃率降到了零。
6. 从RN到鸿蒙的一次实战总结:一些真实经验
口腔护理“今日任务”这个模块,从立项到鸿蒙版本跑通,前后大概花了三周。如果只看表面,工作量和在Android上加一个机型适配差不多,但实际投入的精力大头都花在**排查环境的“静默问题”**上。
我不建议任何团队从零开始在鸿蒙上玩RN。最稳的路径是:拿到一个已经验证过的RN鸿蒙适配模板工程,先跑通一个只有Hello World的页面,再逐步把业务代码迁移过来。一次迁移一个模块,每迁移完一个模块就在真机上跑回归,不要想着一次性把整个App搬过来,那样出问题的排查成本会高到怀疑人生。
还有一点经验:给鸿蒙版本配一个专门的测试机。模拟器和真机行为差异太大了,模拟器上优雅运行的代码,真机上可能白屏;真机上正常执行的接口,模拟器上报2300056这种奇怪错误码。没有真机,很多问题会反复横跳,浪费时间。
最后一句话总结这个项目的核心:React Native开发鸿蒙跨平台应用的难点,从来不在React和JavaScript这一侧,而在原生桥接层的鸿蒙化改造。把桥接层做稳,业务代码的跨端复用的优势才能在鸿蒙上兑现。希望这篇东西能帮你少踩几个坑。