摘要: 公司有一套跑了六年的 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 装箱转换。

Native 层

桥接层

ArkTS 层

ArkTS 业务代码

import xxx from 'libxxx.so'

index.d.ts 类型声明

napi_module 注册表

Init 中 napi_define_properties

C/C++ 实现函数

三方库源码 / 预编译 .so

libace_napi.z.so
libhilog_ndk.z.so

和 JNI 的差异,按我实际踩过的点整理成表:

对比项JNI(Android)NAPI(HarmonyOS)
接口风格(*env)->GetStringUTFChars(env, ...)napi_get_value_string_utf8(env, ...)
模块注册JNI_OnLoad / RegisterNativesnapi_module 结构体 + constructor 注册
加载方式System.loadLibraryimport xxx from 'libxxx.so'
类型声明手写 native 方法签名index.d.ts + oh-package.json5 的 types 字段
异常机制ThrowNew + ExceptionChecknapi_throw_error + 返回值 napi_status
异步自己开线程 + AttachCurrentThreadnapi_create_async_work / threadsafe_function
日志__android_log_printOH_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 构造注意点
stringnapi_get_value_string_utf8napi_create_string_utf8必须两次调用,第一次取长度
numbernapi_get_value_int32/uint32/doublenapi_create_int32/double注意整型溢出
booleannapi_get_value_boolnapi_get_boolean
ArrayBuffernapi_get_arraybuffer_infonapi_create_arraybuffer大数据首选,避免拷贝
Arraynapi_get_array_length + napi_get_elementnapi_create_array逐元素操作,量大时慢
Objectnapi_get_named_propertynapi_create_object深层嵌套建议改 JSON 字符串
Functionnapi_get_cb_info 取出—跨线程回调要用 threadsafe_function

经验是:大数据一律走 ArrayBuffer 或文件 Uri,别用 Array 逐元素搬。我第一次把 2000 个点的坐标用数组传过去,转换本身就吃掉几十毫秒(待实测具体量级),换成 ArrayBuffer 一次传完就顺了。

四、异步:别让 native 卡住 UI

NAPI 的导出函数运行在 JS 线程上。如果直接在里面跑耗时计算,UI 就会卡住。官方给的机制是 napi_create_async_work:execute 在工作线程跑,complete 回到 JS 线程拿结果。

工作线程 NAPI 运行时 ArkTS(JS 线程) 工作线程 NAPI 运行时 ArkTS(JS 线程) 调用 native 异步方法(传入 callback) 把入参拷进上下文结构体 napi_create_async_work + queue execute 回调(禁止访问 napi_env) 执行 C 库耗时计算 完成,写回上下文 complete 回调(回到 JS 线程) 调用 ArkTS callback 更新状态
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;
}

两条硬规则,违反必崩:

  1. execute 里不能调用任何 napi_* 接口(除了少数线程安全的),它不在 JS 线程上。
  2. 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 管理的,创建与删除必须成对,异常分支最容易漏。

六、构建产物与调试

无

有

否

是

否

是

否

是

运行报 module not found
或 native crash

HAP 内是否有
对应 ABI 的 .so

检查 abiFilters
与 CMake 产物路径

nm_modname 是否
等于去掉 lib 前缀的 so 名

修正 nm_modname

types 目录名
是否与 so 名一致

重命名 types 目录
并核对 oh-package.json5

index.d.ts 导出名
与 define_properties 是否一致

同步两处命名

看 hilog native 日志
必要时 addr2line 还原堆栈

配置 build-profile.json5 指定 CMake 与 ABI:

{
  "buildOption": {
    "externalNativeOptions": {
      "path": "./src/main/cpp/CMakeLists.txt",
      "arguments": "",
      "cppFlags": "",
      "abiFilters": ["arm64-v8a", "x86_64"]
    }
  }
}

调试 native 问题时,我常用的三步:

  1. 确认 .so 是否打进包:解压 HAP 看 libs/ 目录下有没有对应 ABI 的产物。
  2. native 日志:用 OH_LOG_Print 打点,hdc shell hilog 过滤 domain。
  3. 崩溃定位: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++ 库时卡在哪一步?是模块加载、类型转换,还是异步回调?评论区说说你的场景和报错,我按链路帮你定位。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐