上一篇踩坑日记停在了一个让我既兴奋又心虚的位置:基础组件能渲染了、页面跳转也通了,RN for OpenHarmony 的整体框架算是跑起来了。我以为最难的阶段已经过去,直到开始封装第一个自定义原生模块,才意识到前面那些页面渲染问题只是序章。这篇踩坑日记(五)要聊的,就是我从"能渲染页面"到"能调通原生能力"这段完全不一样的经历。
标题里的 RN for OpenHarmony 指的是 React Native 在开源鸿蒙系统上的适配分支,这个词我会反复提到。如果你已经跟着系列走到这一步,大概率和我当时一样:Hello World 能跑、基础组件能显示、路由跳转没问题,正打算开始接入设备硬件能力或者某个本地 SDK。如果你还没到这一步,也没关系,这篇文章里的排查思路和 N-API 桥接原则是通用的,能帮你绕开一批我实打实踩过的大坑。
先说结论:这阶段的核心难点不是"怎么写原生代码",而是"怎么让 JS 层和 ArkTS 层在类型、线程、生命周期三个方面达成一致"。这三个词会在后面反复出现,每一个都对应一个让我卡了半天的真实事故。
1. 进入原生桥接前的检查清单:五件事没确认先别动手
写这一节之前,我要先交代一下背景。我在做的是一个设备数据采集类的模拟项目 X,需要从一个下位机设备读取串口数据,在 RN 页面里实时展示。这就绕不开两个问题:第一,串口能力 OpenHarmony 原生侧有,JS 侧没有;第二,我需要在一个现成的 RN 工程里把原生侧数据传回 JS 侧。
在动手写第一个原生模块之前,我给自己列了一份检查清单,这里直接分享给大家,顺序就是我当时确认的顺序。
第一,版本对应关系。RN for OpenHarmony 不是官方主线的 RN,而是社区维护的适配分支,它对 OpenHarmony SDK 版本、DevEco Studio 版本都有对应的组合。我一开始没仔细核对,拿着旧教程的版本映射关系直接搭,结果编译期各种 N-API 符号找不到。这个版本矩阵在不同发行版里不太一样,我的建议是直接以你当前使用的适配分支仓库 README 里标注的组合为准,别信老文章,也别信本地缓存。
第二,原生模块的注册入口。不同的适配分支对原生模块的扫描方式可能不同,有的走自动注册,有的需要在指定文件中手动声明。这个入口如果找不对,后面所有模块都白写。判断方法很简单:在你当前工程里搜索现有模块的注册位置,照着那个位置加,而不是自己另起一个注册流程。
第三,工具链状态。确认 DevEco Studio、hvigor、CMake 这三者版本是否在你当前工程的预期范围内。这个检查项看起来基础,实际上坑很深,后面第五节我会专门讲一讲这个环节遇到的事故。
第四,测试设备的系统版本。模拟器和真机在原生能力上差异很大,尤其是串口、蓝牙、传感器这类硬件能力。我当时在模拟器上调得好好的代码,上了真机直接行为不一致,后来才发现模拟器的服务接口返回的数据和真机不完全一样。
第五,最小复现工程。这条是我这次踩坑过程中最大的收获。桥接层出问题,表现往往不是"报错",而是返回 undefined、回调没触发、偶发崩溃这一类"软故障"。如果工程里挂着一堆业务逻辑,你根本分不清是桥接问题还是业务代码干扰。我后来单独建了一个只包含一个测试页面和一个目标原生模块的精简工程,所有桥接问题都在这个工程里先验证清楚了,才往业务工程里迁移。
| 检查项 | 确认内容 | 最容易忽略的点 |
|---|---|---|
| 版本对应 | RN 适配分支版本与 OpenHarmony SDK 版本 | 大小版本不匹配会导致 N-API 符号解析失败 |
| 模块注册入口 | 现有原生模块的注册位置 | 不同发行版注册机制可能不同 |
| 工具链 | DevEco Studio / hvigor / CMake 版本 | 升级工具后旧缓存会干扰构建 |
| 测试设备 | 真机与模拟器的 API 能力差异 | 硬件类能力模拟器支持不完整 |
| 最小复现工程 | 只含目标模块的精简工程 | 业务代码会污染故障定位过程 |
这条清单看起来琐碎,但每一条都是真实事故换来的。尤其是最后一条最小复现工程,如果时间只够做一件事,就做这个。
2. 第一处卡点:原生模块注册成功,JS 侧却永远拿到 undefined
现在进入第一个真正让我挠头的坑。我想做的第一个能力非常简单:从原生侧读取一个设备序列号。原生侧用一个静态方法返回一个字符串,理论上这是 N-API 里最简单的一种场景了。
我在 ArkTS 侧写了一个模块类,注册进去,然后在 JS 侧这么调用:
import { NativeModules } from 'react-native'; const DeviceInfo = NativeModules.DeviceInfoModule; const serialNumber = DeviceInfo.getSerialNumber();结果在页面里serialNumber打了半天始终是undefined。没有报错、没有红屏、没有任何异常提示,就是空值。
这种"无报错但结果不对"的问题是最恶心的,因为报错会告诉你哪里坏了,而 undefined 只告诉你结果不对,但原因可以从头到尾有十几个可能。我当时大概花了四个小时才彻底定位,这里我按排查链路把每一步写清楚,大家可以照着走一遍。
第一步:确认模块是否真的注册成功。很多人会跳过这一步直接怀疑代码逻辑,但桥接层最常见的失败点恰恰是模块压根没进去。判断方法是在原生侧注册逻辑里打日志,观察应用启动时是否会走到模块注册的代码路径。我当时在这里就发现了问题的一个分支:模块确实注册了,但是注册时机比 JS bundle 加载要晚。
第二步:核对导出模块名与 JS 侧引用名。桥接层的模块名匹配通常不是模糊匹配,大小写和驼峰写法必须完全一致。这段看起来是新手才会犯的错,但我身边确实有人因为DeviceInfoModule写成了DeviceinfoModule排查了一整天。检查要素包括:模块导出名、方法名、JS 侧引用路径。
第三步:检查同步/异步声明是否一致。这一步是真正的深水区。RN 在较新的架构里把原生模块分成了同步方法和异步方法,同步方法在 JS 侧可以直接拿到返回值,异步方法则需要通过回调或 Promise 获取。问题就出在:如果你的原生方法实际是异步执行的,但 JS 侧把它当同步方法来用,拿到的自然就是 undefined。
在这个具体场景里,我的问题其实更隐蔽。我封装的原生方法本身是同步返回的,但它在 ArkTS 侧的初始化流程中依赖了一个尚未完全准备好的单例对象,导致方法执行到一半返回了一个空对象。JS 侧试图把空对象当字符串用的时候,得到的就是undefined。
第四步:检查 N-API 返回值类型。即使原生方法正确执行了,如果返回值类型转换不对,JS 侧同样会收到undefined。比如 C++ 侧返回了nullptr,JS 侧按 string 来收,那结果就是 undefined 而不是null或空字符串,因为两层之间对"没有值"的表达方式不一样。
正确写法应该是这样的:
// device_info_module.cpp #include "napi/native_api.h" static napi_value GetSerialNumber(napi_env env, napi_callback_info info) { napi_value result; std::string serial = "TEST-SERIAL-20240601"; // 把一个 std::string 转成 napi_value 返回给 JS 侧 napi_create_string_utf8(env, serial.c_str(), serial.size(), &result); return result; }这段代码本身没有问题,但它只是桥接的最后一环。真正要警惕的是前面几步:模块加载时序、同步异步声明、返回类型。尤其是"模块加载时序"这一点,RN for OpenHarmony 的 ArkTS 运行时初始化是分阶段的,模块如果在某个阶段注册,而 JS 侧在更早的阶段就开始调用,拿到的一定是空代理对象。
所以遇到 undefined 的时候,不要急着改代码,先把模块注册日志打出来,看 JS 侧调用发生时原生侧模块准备好了没有。这一步排查清楚了,后面会省很多时间。
3. 第二处卡点:回调"时灵时不灵",问题藏在线程切换与引用回收上
刚刚那个坑解决之后,我以为桥接就是一层窗户纸,直到我开始做串口数据上行回调,才体会到什么叫"时灵时不灵"。
场景是这样的:原生侧注册了一个监听方法,当串口有数据到达时,通过回调把数据传给 JS 侧。在原生侧的日志里,每次数据到达都打印了日志,说明数据源源不断到了原生层。但 JS 侧的回调偶尔触发、偶尔不触发,有时连续触发几次后就彻底静止了,好像监听被关掉了一样。
这个现象非常典型,它同时涉及桥接层的两个核心问题:线程切换和引用生命周期。
先说线程切换。N-API 里有一个铁律般的原则:napi_call_function必须在 JS 引擎绑定的线程上调用,不能在其他线程里直接调用。原因是 JS 引擎的运行时上下文不是线程安全的,你在工作线程里调napi_call_function,轻则回调丢失,重则直接崩溃。
我在初始化的时候踩过一次非常狠的:在原生 SDK 的回调线程里直接调用了napi_call_function,结果应用跑了几分钟,随机崩溃。这个崩溃日志还不是每次都一样,有时候是 SIGSEGV,有时候是"undefined symbol"。如果经验的底子不厚,看到这种日志可能一辈子都想不到是线程问题。
错误示范大概是这样的:
// 错误示范:在非 JS 线程里直接调用 napi_call_function void OnSerialDataReceived(const std::string& data) { // 这里的 env 是模块初始化时缓存的 // 但当前线程是串口 SDK 的内部工作线程,不是 JS 线程 napi_value argv[1]; napi_create_string_utf8(env, data.c_str(), data.size(), &argv[0]); napi_call_function(env, global, jsCallback, 1, argv, nullptr); // 极不稳定 }这种写法在某些平台上可能没问题,因为那些平台做过线程保护,或者调用时机恰好落在 JS 线程上。但 RN for OpenHarmony 的适配分支不会帮你做这层兜底,该出问题一定会出问题。
正确的处理方式是把工作线程的结果先存起来,然后通过napi_create_async_work把任务切回 JS 线程再执行回调。这里给一个简化可用的骨架:
// 正确做法:通过 async_work 切回 JS 线程 struct AsyncCallbackData { std::string result; napi_ref callbackRef; }; void ExecuteCallback(napi_env env, void* data) { // 这个函数在原生工作线程执行,不能碰 JS 对象 // 一般在这里做耗时操作 } void CompleteCallback(napi_env env, napi_status status, void* data) { // 这个函数在 JS 线程执行,可以安全调用 napi_call_function AsyncCallbackData* cbData = static_cast<AsyncCallbackData*>(data); napi_value callback; napi_get_reference_value(env, cbData->callbackRef, &callback); napi_value argv[1]; napi_create_string_utf8(env, cbData->result.c_str(), cbData->result.size(), &argv[0]); napi_value global; napi_get_global(env, &global); napi_call_function(env, global, callback, 1, argv, nullptr); delete cbData; }再说引用生命周期。JS 侧传过来的回调函数是一个对象,JS 引擎会管理它的生命周期。如果在原生侧只是简单地用一个局部变量存了回调,那么当 JS 侧不再持有这个回调引用时,垃圾回收就可能把它收掉。收了之后原生侧再拿着一个"已经失效的引用"去调用,结果就是回调彻底不触发。
正确的做法是在拿到回调用时,用napi_create_reference创建一个强引用,把回调的生命周期延长到原生侧显式释放为止。这有点像一个锁:你不想让 JS 引擎把这个回调当垃圾收走,就把它锁在原生侧。
| 错误做法 | 现象 | 原因 |
|---|---|---|
| 在工作线程直接调用 napi_call_function | 偶发崩溃 / 回调丢失 | 未切换到 JS 线程 |
| 回调用普通变量保存 | 一段时间后回调不再触发 | 被 JS 垃圾回收机制回收 |
| 多次注册监听时覆盖回调引用 | 回调指向错误对象 | 旧引用未释放,新引用未建立 |
第二个坑的排查过程让我真正明白了"线程""引用"这两个词在桥接层的分量。从 Android/iOS 移植过来的原生模块最容易死在这儿,因为其他平台的桥接框架帮你做了线程池和引用管理的兜底,而 RN for OpenHarmony 的适配层没有这层包装,必须自己在原生侧全部处理好。
4. 第三处卡点:三方 SDK 适配时的类型转换和字段丢失
串口通信打通之后,我又尝试把一个本地缓存 SDK 接进来。这个 SDK 以二进制库的形式提供,C++ 接口返回的是一个 JSON 结构的数据。我当时天真地想:JSON 嘛,直接转成字符串扔给 JS 层JSON.parse不就行了?
现实狠狠教育了我。数据是传回来了,但 JS 侧解析之后发现嵌套对象里的字段丢了,只剩一层空壳。外层有 key,内层全不见了。
这又是 N-API 类型转换的坑。
先说结论:N-API 里的napi_value不是"一个通用对象",它是一个需要按类型逐层构建的递归结构。当你拿到一个 JSON 字符串,最稳妥的方案是直接用napi_json_parse来转换,而不是自己手工构建。手工构建的时候,你很容易只处理了最外层,内层对象没有被正确递归,结果就是字段丢失。
这里的对比不仅仅是"能不能用",还涉及排查的难度。手工构建的对象如果少了一层,JS 侧看到的结果是 undefined 或者空对象,但原生侧完全不知道问题出在哪儿,因为你打印原始 JSON 字符串时一切正常。
正确的转换逻辑:
// 推荐的 JSON 转 napi_value 方式 static napi_value ParseJsonToObject(napi_env env, const std::string& jsonStr) { napi_value jsonVal; napi_create_string_utf8(env, jsonStr.c_str(), jsonStr.size(), &jsonVal); napi_value result; napi_json_parse(env, jsonVal, &result); // 交给 N-API 完成递归解析 return result; }在处理这个问题的过程中,我还整理了一份常用的类型映射参考,这里贴出来给大家,排查类型相关问题时对照着看效率会高很多:
| JS 侧类型 | N-API 类型 | 原生侧对应 |
|---|---|---|
| string | napi_string | std::string / ArkTS string |
| number | napi_number | int32_t / double / float |
| boolean | napi_boolean | bool |
| object | napi_object | JSON 对象 / 自定义结构体 |
| array | napi_array | 数组容器 |
| function | napi_function | 回调函数引用 |
类型转换这块还有一个大坑来自ArkTS 的严格模式。我从 npm 上拉了一个现成的 RN 原生插件想直接迁移,结果编译都过不了:源码里到处是any类型、动态删除对象属性(delete obj.field)、隐式类型转换。这些在 TypeScript 里合法的写法,在 ArkTS 严格模式下全部被标记为错误。
所以如果你想迁移一个已有的 RN 插件到 OpenHarmony 环境,不要只改包名和配置文件,必须逐文件过一遍 ArkTS 规范。尤其是any这个类型,几乎可以断言"见到一个删一个"。这种工作量比想象中大很多,我当时只改了一个中等规模的插件,就花了整整一天,大部分时间都耗在类型收敛上。
这一阶段给我的体感是:桥接层的数据类型问题,比纯逻辑问题更难发现。纯逻辑问题会报错,类型问题往往表现为"数据少了""值变了""结构不对",不仔细对比根本看不出是转换环节出了问题。
5. 构建侧扫尾:hvigor 与 CMake 里那两行容易漏掉的配置
如果说前面几个坑是"代码逻辑问题",那第五个坑纯粹是"构建配置问题"。当我把三个原生模块都写好,在精简工程里反复验证通过之后,一搬到业务工程,启动直接崩溃,日志里写着UnsatisfiedLinkError。
这个错误的意思是:运行时找不到符号对应的共享库文件。其实要表达的意思很直接——某个原生 .so 库根本没有打进最终的 hap 安装包里。
先说一下排查路径。UnsatisfiedLinkError这类问题不能靠修改代码解决,必须回到构建产物里去反查。我当时按这个顺序查了一遍:
第一步,检查 hvigor 配置文件里是否把原生构建结果正确关联到应用工程。这个配置如果漏了,构建流程压根不会触发 CMake,原生 .so 一个都不会打包。
第二步,打开 CMakeLists.txt,检查target_link_libraries里是否把依赖的三方库明确列出来。比较典型的错误是只写了主库,没写主库依赖的附属库,链接的时候出了一个共享库缺失,运行时才会炸。
第三步,确认第三方 .so 的路径是否正确。很多 SDK 的 .so 文件存放在 SDK 包内的特定目录,如果 CMake 没有通过变量透传这个路径,链接阶段只能找到部分库,或者全部找不到。
我当时的问题就出在第二步——漏了附属库。串口 SDK 除了主 so 之外还有一个负责协议解析的 so,我只在target_link_libraries里写了主库,结果编译能过,因为编译期不强制要求所有运行期依赖都存在;但到运行时直接就UnsatisfiedLinkError了。
这里给一个参考性质的 CMakeLists 片段,注意看全路径透传和依赖库列举:
cmake_minimum_required(VERSION 3.5.0) project(my_native_modules) # 三方 SDK 提供的主库和附属库 add_library(serial_sdk SHARED IMPORTED) set_target_properties(serial_sdk PROPERTIES IMPORTED_LOCATION ${SERIAL_SDK_PATH}/libs/${OHOS_ARCH}/libserial_sdk.so ) add_library(serial_protocol SHARED IMPORTED) set_target_properties(serial_protocol PROPERTIES IMPORTED_LOCATION ${SERIAL_SDK_PATH}/libs/${OHOS_ARCH}/libserial_protocol.so ) # 你自己的原生模块 add_library(my_native_modules SHARED device_info_module.cpp serial_listener.cpp ) target_link_libraries(my_native_modules libace_napi.z.so serial_sdk serial_protocol # 这一行漏了就等着 UnsatisfiedLinkError )构建配置还有一个很容易被忽略的点:升级 DevEco Studio 之后必须 clean 再重新构建。如果你是从老版本升级上来的,工具链缓存里还留着旧版本的 CMake 配置,新版本的工具根本不会去刷新它。我升级完工具之后没有 clean,跑了一个多小时愣是没找到问题,最后 clean 重建一切恢复正常了。
还有一个验证方法很有用:hap 包解包后检查 libs 目录里到底放了哪些 .so。如果你发现某个库没在包里面,那问题就一定出在构建配置,而不是代码逻辑。
在真机上看 so 库是否齐全,可以通过调试工具查一下应用沙箱里的 lib 目录(不同系统版本路径有差异),确认主库和依赖库都在。这一步做扎实了,UnsatisfiedLinkError基本没有藏身之处。
6. 经验沉淀:三个能让你在后面少踩坑的小习惯
第五篇踩坑日记写到这里,照惯例聊聊这段经历沉淀下来的三个小习惯。它们已经融进我后面所有桥接相关的开发流程里了,现在几乎成了本能。
第一个习惯:JS 侧和原生侧日志必须带同一个 requestId。我在排查回调问题时最大的困难就是两边日志对不上号。原生侧打了一条消息,JS 侧没有对应记录,但你不知道这条消息到底有没有被发出去。后来我在每次桥接调用的入参里加了一个 requestId,原生侧收到请求时打一条带 requestId 的日志,回调回来时再打一条,JS 侧同理。从那以后,任何"时灵时不灵"的问题都能在五分钟之内定位到是哪一环断了。
第二个习惯:先短代码验证桥接通断,再搬正式逻辑。这个习惯是被第二个坑教育出来的。当时我直接在一个大工程里调试串口回调,代码几千行,回调链路经过了三个模块,排查起来格外吃力。后来我把回调逻辑精简成一个只打印日志的测试方法,跑通了再逐步加业务逻辑。每加一层逻辑就验证一次,问题就永远限定在"新加的那段代码"里。
第三个习惯:每个桥接能力保留一个最小复现工程。这不是说只要一个精简工程就够了,而是每个能力模块都保留一个只包含自身的最小工程。原生模块更新、RN 版本升级、OpenHarmony SDK 升级时,先用这个最小工程做回归测试,确认没问题再往业务工程里合。我后来在升级一次 SDK 时,就是在最小复现工程里第一时间发现了线程切换问题,业务工程完全没受影响。
这三个习惯看起来都很朴素,但每一个都是真金白银换来的经验。如果你在桥接层也遇到了"查不出原因的 bug",建议先停下来看看自己有没有做到这三条。很多时候,问题不是出在你不懂原理,而是出在你的调试手段本身不够精确,让问题藏在了干扰信息下面。