【鸿蒙心迹】三方库移植(一)——NAPI 把 C/C++ 库桥接到 ArkTS 的完整链路(HarmonyOS 7.x)
摘要: 公司有一套跑了六年的 C 语言校验和/加解密库,要搬进鸿蒙 App。我带着 JNI 的经验上手,以为 NAPI 就是"换个名字的 JNI",结果第一天就卡在模块加载失败——
.so名、注册名、import路径三处对不上,日志里只有一句module not found。本文把这条桥接链路完整走一遍:工程骨架怎么搭(CMakeLists + 类型声明 + 模块注册)、ArkTS 与 C 的类型怎么映射、耗时操作怎么走异步不卡 UI、5 个真实卡点的定位过程。文中代码基于官方 Native 模板与 Node-API 标准接口,性能数据标注"待实测",不做数字编造。
适用版本: HarmonyOS NEXT 7.x / API 14+ / DevEco Studio 5.x(Native 开发,以当前 SDK 文档为准)
环境说明: 本文工程结构与接口签名基于官方 Native 模板和 Node-API 标准整理,机型性能数据、批量耗时均标注"待实测",实测后回补。
文章目录
开篇:带着 JNI 经验上手,第一天卡在模块加载
公司那套 C 库是六年前写的,纯 C89 风格,几千行,跑在服务端和 Android 上。鸿蒙项目启动后,第一个要解决的问题就是:这堆 C 代码怎么让 ArkTS 调得到。
我当时的判断是"不就是 JNI 换皮"。毕竟概念上很像:Java 有 JNI,ArkTS 有 NAPI;Android 编译出 .so 打进 APK,鸿蒙也是编译出 .so 打进 HAP。命名都差不多,napi_ 前缀对应 JNI 的 (*env)->。
真正动手才发现,最容易翻车的不是写代码,是"三处名字对不上"。第一天的报错只有一句:
Error: Cannot find module 'libentry.so' or its corresponding type declarations.
我把这个报错贴到搜索框里,答案五花八门。最后自己把链路捋了一遍才明白:.so 的文件名、napi_module 的注册名、ArkTS 里 import 的模块名、以及类型声明目录的命名,这四处必须严丝合缝,缺一个就报上面这句——而报错信息完全不会告诉你到底是哪一处错了。
这篇文章就是把这个链路按"能跑通"的顺序写清楚,顺便把后面踩到的 5 个坑一起说掉。
一、先看清 NAPI 在鸿蒙里的位置
NAPI 是 Node-API 标准在鸿蒙上的实现,接口签名与 Node.js 的 Node-API 基本一致(基于 Node 18 的 ABI 稳定接口)。它的作用就是给 ArkTS 和 C/C++ 之间划一条受控的边界:ArkTS 侧不直接操作 native 内存,所有数据都要经过 napi_value 装箱转换。
和 JNI 的差异,按我实际踩过的点整理成表:
| 对比项 | JNI(Android) | NAPI(HarmonyOS) |
|---|---|---|
| 接口风格 | (*env)->GetStringUTFChars(env, ...) | napi_get_value_string_utf8(env, ...) |
| 模块注册 | JNI_OnLoad / RegisterNatives | napi_module 结构体 + constructor 注册 |
| 加载方式 | System.loadLibrary | import xxx from 'libxxx.so' |
| 类型声明 | 手写 native 方法签名 | index.d.ts + oh-package.json5 的 types 字段 |
| 异常机制 | ThrowNew + ExceptionCheck | napi_throw_error + 返回值 napi_status |
| 异步 | 自己开线程 + AttachCurrentThread | napi_create_async_work / threadsafe_function |
| 日志 | __android_log_print | OH_LOG_Print(libhilog_ndk.z.so) |
一句话总结差异:JNI 是"自己管一切",线程、引用、异常都要手动处理;NAPI 提供了受控的异步与引用管理机制,但也因此多了 async_work、reference、threadsafe_function 这些必须理解的概念。
二、工程骨架:native 模块怎么搭
2.1 目录结构
一个最小可用的 native 模块长这样(以模块名 entry 为例):
entry/src/main/cpp/
├── CMakeLists.txt # 编译配置
├── napi_init.cpp # 模块注册 + 导出函数
└── types/libentry/ # 类型声明目录,目录名必须与 .so 名一致
├── index.d.ts
└── oh-package.json5
2.2 CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
project(NativeBridge)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
include_directories(
${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include # 三方库头文件
)
# 三方库源码(也可以用预编译 .so + IMPORTED 方式引入)
add_library(legacy STATIC src/legacy_checksum.c)
# native 模块,输出 libentry.so
add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC
legacy
libace_napi.z.so
libhilog_ndk.z.so
)
2.3 模块注册与函数导出
#include <napi/native_api.h>
#include <hilog/log.h>
#include "legacy_checksum.h"
// 实际导出给 ArkTS 的函数
static napi_value Checksum(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1] = {nullptr};
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 字符串长度要"先取长度、再取内容"两步
size_t len = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len);
char* buf = new char[len + 1];
napi_get_value_string_utf8(env, args[0], buf, len + 1, &len);
uint32_t result = legacy_checksum(buf);
delete[] buf;
napi_value out;
napi_create_uint32(env, result, &out);
return out;
}
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"checksum", nullptr, Checksum, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
static napi_module bridgeModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "entry", // 必须与 .so 名一致(libentry.so → entry)
.nm_priv = ((void*)0),
.reserved = {0},
};
extern "C" __attribute__((constructor)) void RegisterBridgeModule(void) {
napi_module_register(&bridgeModule);
}
2.4 类型声明(容易漏的一步)
types/libentry/index.d.ts:
export const checksum: (input: string) => number;
types/libentry/oh-package.json5:
{
"name": "libentry.so",
"types": "./index.d.ts",
"version": "1.0.0",
"description": "native bridge"
}
ArkTS 侧就可以直接用了:
import nativeBridge from 'libentry.so';
const result = nativeBridge.checksum('hello harmony');
三、类型转换:ArkTS 与 C 的边界
所有跨边界的数据都要过一次转换。常用的映射关系:
| ArkTS 类型 | NAPI 取值 | NAPI 构造 | 注意点 |
|---|---|---|---|
| string | napi_get_value_string_utf8 | napi_create_string_utf8 | 必须两次调用,第一次取长度 |
| number | napi_get_value_int32/uint32/double | napi_create_int32/double | 注意整型溢出 |
| boolean | napi_get_value_bool | napi_get_boolean | |
| ArrayBuffer | napi_get_arraybuffer_info | napi_create_arraybuffer | 大数据首选,避免拷贝 |
| Array | napi_get_array_length + napi_get_element | napi_create_array | 逐元素操作,量大时慢 |
| Object | napi_get_named_property | napi_create_object | 深层嵌套建议改 JSON 字符串 |
| Function | napi_get_cb_info 取出 | — | 跨线程回调要用 threadsafe_function |
经验是:大数据一律走 ArrayBuffer 或文件 Uri,别用 Array 逐元素搬。我第一次把 2000 个点的坐标用数组传过去,转换本身就吃掉几十毫秒(待实测具体量级),换成 ArrayBuffer 一次传完就顺了。
四、异步:别让 native 卡住 UI
NAPI 的导出函数运行在 JS 线程上。如果直接在里面跑耗时计算,UI 就会卡住。官方给的机制是 napi_create_async_work:execute 在工作线程跑,complete 回到 JS 线程拿结果。
struct AsyncCtx {
napi_async_work work = nullptr;
napi_ref callbackRef = nullptr;
char* input = nullptr;
uint32_t result = 0;
};
static void Execute(napi_env env, void* data) {
auto* ctx = static_cast<AsyncCtx*>(data);
ctx->result = legacy_checksum(ctx->input); // 工作线程,不能调 napi_* 接口
}
static void Complete(napi_env env, napi_status status, void* data) {
auto* ctx = static_cast<AsyncCtx*>(data);
napi_value callback = nullptr;
napi_get_reference_value(env, ctx->callbackRef, &callback);
napi_value argv[1];
napi_create_uint32(env, ctx->result, &argv[0]);
napi_value undefined = nullptr;
napi_get_undefined(env, &undefined);
napi_call_function(env, undefined, callback, 1, argv, nullptr);
napi_delete_reference(env, ctx->callbackRef);
napi_delete_async_work(env, ctx->work);
delete[] ctx->input;
delete ctx;
}
两条硬规则,违反必崩:
execute里不能调用任何napi_*接口(除了少数线程安全的),它不在 JS 线程上。complete里才能把结果转成napi_value并回调 ArkTS。
五、5 个真实卡点与定位过程
1. libentry.so 找不到
现象:编译通过,运行报 Cannot find module 'libentry.so'。
定位:逐项比对四处命名——CMakeLists 里 add_library(entry SHARED ...) 产出 libentry.so;napi_module 的 nm_modname 必须是 "entry";types/ 下的目录名必须是 libentry;ArkTS 里 import 的必须是 'libentry.so'。
解决:我当时错在 nm_modname 写成了 "libentry"(多加了 lib 前缀)。去掉前缀后正常。
教训:这四处命名没有任何编译期校验,只能靠人工对齐。建议建模块时直接复制模板,别手敲。
2. 类型声明与实现不一致
现象:ArkTS 侧调用返回 undefined,编译期无报错。
定位:index.d.ts 里声明了 checksum(input: string): number,但 native 侧 napi_define_properties 导出的名字写成了 checkSum(大写 S)。ArkTS 按声明认为函数存在,运行时找不到导出项。
解决:统一命名;并在 index.d.ts 与 C 代码之间建立一一对应关系,改一处必须同步改另一处。
教训:.d.ts 是承诺不是校验。命名不一致只在运行期暴露,且报错信息没有指向性。
3. 字符串被截断
现象:传入长字符串,计算结果偶尔不对。
定位:只调了一次 napi_get_value_string_utf8,用固定大小的栈上 buffer 接收。超过 buffer 长度的部分被丢掉,且不报错。
解决:改成标准的两步调用——第一次传 nullptr 和长度 0 拿到真实长度,再按长度分配内存取内容。
教训:NAPI 的字符串接口设计就是两步,想省一次调用必然踩坑。
4. 工作线程里调 NAPI 接口直接崩
现象:把耗时计算放进 execute 后,偶发崩溃,日志只有信号号。
定位:在 execute 里调用了 napi_create_string_utf8 想把中间结果转字符串打日志。这个接口必须在 JS 线程调用。
解决:日志改用 OH_LOG_Print(hilog 是线程安全的),napi_* 调用全部挪到 complete。
教训:execute 与 complete 的边界是硬性的,不是"建议"。
5. 引用没释放,内存只涨
现象:反复调用异步方法,内存持续增长。
定位:保存 callback 用了 napi_create_reference,但异常分支提前 return,没走 napi_delete_reference。
解决:所有退出路径都要释放;napi_async_work 同理,complete 里必须 napi_delete_async_work。
教训:NAPI 的引用不是 GC 管理的,创建与删除必须成对,异常分支最容易漏。
六、构建产物与调试
配置 build-profile.json5 指定 CMake 与 ABI:
{
"buildOption": {
"externalNativeOptions": {
"path": "./src/main/cpp/CMakeLists.txt",
"arguments": "",
"cppFlags": "",
"abiFilters": ["arm64-v8a", "x86_64"]
}
}
}
调试 native 问题时,我常用的三步:
- 确认 .so 是否打进包:解压 HAP 看
libs/目录下有没有对应 ABI 的产物。 - native 日志:用
OH_LOG_Print打点,hdc shell hilog过滤 domain。 - 崩溃定位:native 崩溃日志只有地址,需要用工具链里的
addr2line配合带符号的.so还原堆栈。
七、边界与已知限制
| 限制项 | 具体表现 | 规避方式 |
|---|---|---|
| 实测缺口 | 文中性能数据(转换耗时、批量吞吐)标注"待实测" | 实测后回补,不做数字编造 |
| 线程约束 | execute 回调内不能调用 napi_* 接口 | 只用 OH_LOG_Print,结果回 complete 处理 |
| 类型边界 | 复杂嵌套对象转换成本高、易出错 | 大数据走 ArrayBuffer,复杂结构走 JSON 字符串 |
| ABI 产物 | 目标设备架构不匹配会安装失败或直接崩 | abiFilters 按目标设备配置齐全 |
| 调试成本 | native 崩溃只有地址,没有可读堆栈 | 保留带符号 .so,用 addr2line 还原 |
| 包体增量 | 引入 native 库会明显增大包体 | 裁剪不需要的模块与符号 |
| 许可证 | 三方库 LICENSE 可能带来合规风险 | 引入前核对许可证与声明要求 |
| 版本演进 | NAPI 接口随版本演进,旧示例可能失效 | 以当前 SDK 文档为准 |
小结
把 C/C++ 库搬进鸿蒙,第一步不是写代码,是把四处命名对齐:add_library 名、nm_modname、类型声明目录名、import 名。这四处没有编译期校验,错一处就只有一句没有指向性的 module not found。
之后真正决定成败的是两个边界:类型转换用两步式(尤其字符串),异步严格区分 execute 与 complete(前者不能碰 napi_*)。把这两条守住,剩下的就是常规 C 代码问题。
下一篇写移植的另一半:CMake 交叉编译配置,以及链接期符号冲突怎么定位。
评论区交流: 你在鸿蒙上桥接 C/C++ 库时卡在哪一步?是模块加载、类型转换,还是异步回调?评论区说说你的场景和报错,我按链路帮你定位。
更多推荐



所有评论(0)