引言
ArkTS 跑在 ArkVM 上,绝大多数业务代码用纯 ArkTS 就够了。但有三类场景绕不开 C/C++:复用已有的原生库(音视频编解码、加解密、图像算法)、追求极致性能的热点计算、以及必须直接调用硬件或系统底层接口的能力。NAPI(Native API)就是这两端之间的唯一桥梁。
NAPI 的难点不在 API 数量,而在两套内存模型与线程模型的对接。ArkVM 有自己的垃圾回收器,C++ 侧的内存完全由开发者手动管理;ArkVM 的 JS 线程只有一条,而原生库往往自带线程池。一旦引用计数没配平、或者从子线程直接回调 ArkTS 函数,程序会以「偶发崩溃」的形式表现出来,堆栈指向随机位置,极难复现。
本文按「注册、映射、调用、异步、打包」五步展开,示例基于 API 12 的 NAPI 接口。线程模型的前置知识可以看 鸿蒙并发模型 TaskPool 与 Worker ,本文只讲原生侧与 ArkTS 侧的交互边界。
目录
- 什么时候该用 NAPI
- NAPI 模块的注册方式
- napi_value 与 ArkTS 类型映射
- ArkTS 调用 C++:同步函数实现
- C++ 回调 ArkTS:napi_call_function
- 异步任务 napi_create_async_work
- Promise 化:napi_create_promise
- 线程安全函数 napi_threadsafe_function
- 引用计数与生命周期管理
- CMake 与 hvigor 集成原生库
- SO 打包与 ABI 选择
- 实战:图片哈希原生模块
- 权衡取舍
- 常见坑清单
- 小结
1. 什么时候该用 NAPI
NAPI 是成本最高的一种扩展方式:它引入 C++ 工具链、ABI 兼容问题、崩溃不可复现风险,还会让包体积增加几百 KB 到数 MB。因此第一件事是判断「值不值得」。
| 场景 | 是否值得用 NAPI | 原因 |
|---|---|---|
| 复用成熟 C/C++ 库 | 值得 | 重写成本远高于桥接成本 |
| 大量数值计算、图像处理 | 值得 | ArkTS 侧无法用 SIMD 与手动内存布局 |
| 加解密、编解码 | 值得 | 原生实现有硬件加速与成熟实现 |
| 简单字符串处理、JSON 解析 | 不值得 | 桥接开销可能超过收益 |
| 频繁的小函数调用 | 不值得 | 每次跨语言调用都有固定开销 |
| 一次性初始化逻辑 | 不值得 | 直接用 ArkTS 更简单 |
一个具体的量化标准:单次调用的计算量要能摊薄桥接开销。一次 NAPI 调用的固定成本在微秒级,如果原函数本身只跑几百纳秒,跨语言调用反而更慢。判断方法很简单:先用 ArkTS 写一版,用 Profiler 测出热点,再决定是否下沉到原生。
2. NAPI 模块的注册方式
NAPI 模块通过 napi_module_register 注册,最常见的写法是用 __attribute__((constructor)) 让模块在 SO 加载时自动注册。
// entry/src/main/cpp/napi_init.cpp
#include "napi/native_api.h"
#include <hilog/log.h>
static napi_value Add(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2] = { nullptr };
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
double a = 0.0;
double b = 0.0;
napi_get_value_double(env, args[0], &a);
napi_get_value_double(env, args[1], &b);
napi_value result = nullptr;
napi_create_double(env, a + b, &result);
return result;
}
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{ "add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr }
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
static napi_module demoModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "entry",
.nm_priv = nullptr,
.reserved = { 0 },
};
extern "C" __attribute__((constructor)) void RegisterModule(void) {
napi_module_register(&demoModule);
}
nm_modname 必须与 ArkTS 侧的导入名一致。ArkTS 侧用 import native from 'libentry.so' 导入,其中 libentry.so 的文件名由 CMake 的 add_library 决定,nm_modname 通常取去掉 lib 前缀与 .so 后缀的名字。两者不一致时,import 会拿到一个空对象,且没有任何报错。
3. napi_value 与 ArkTS 类型映射
NAPI 用 napi_value 表示所有 ArkTS 值,它是不透明的句柄,必须通过 napi_get_value_* 与 napi_create_* 转换。
| ArkTS 类型 | 创建 | 读取 | 备注 |
|---|---|---|---|
| number | napi_create_double / int32 | napi_get_value_double / int32 | 整数用 int32 更快 |
| string | napi_create_string_utf8 | napi_get_value_string_utf8 | 需要两段式调用取长度 |
| boolean | napi_get_boolean | napi_get_value_bool | 无独立 create |
| object | napi_create_object | napi_get_named_property | 属性名用 C 字符串 |
| array | napi_create_array | napi_get_element | 配合 length 遍历 |
| ArrayBuffer | napi_create_arraybuffer | napi_get_arraybuffer_info | 零拷贝传二进制 |
| function | napi_create_function | napi_call_function | 回调场景使用 |
字符串读取需要两段式:先传 nullptr 取长度,再分配缓冲区读内容。这是最高频的样板代码,值得封装成工具函数:
static std::string GetString(napi_env env, napi_value value) {
size_t len = 0;
napi_get_value_string_utf8(env, value, nullptr, 0, &len);
std::string result(len, '\0');
napi_get_value_string_utf8(env, value, &result[0], len + 1, &len);
return result;
}
类型映射最容易出错的地方是类型不校验。napi_get_value_double 传入一个 string 会返回 napi_string_expected 错误码,但如果你不检查返回值,a 会保持初值 0,表现为「计算结果莫名是 0」而不是崩溃。所有 napi_get_* 的返回值都必须检查。
4. ArkTS 调用 C++:同步函数实现
同步函数是 NAPI 的基本形态,napi_callback 的签名固定为 napi_value (*)(napi_env, napi_callback_info)。
static napi_value Md5(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1] = { nullptr };
if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc < 1) {
napi_throw_error(env, nullptr, "expect 1 argument");
return nullptr;
}
if (!IsString(env, args[0])) {
napi_throw_type_error(env, "E_TYPE", "argument must be a string");
return nullptr;
}
const std::string input = GetString(env, args[0]);
const std::string digest = ComputeMd5(input);
napi_value result = nullptr;
napi_create_string_utf8(env, digest.c_str(), digest.size(), &result);
return result;
}
两条约定必须遵守:其一,参数不合法时用 napi_throw_* 抛异常并返回 nullptr,ArkTS 侧会得到正常的 throw,而不是崩溃;其二,返回 nullptr 只在已抛异常时使用,正常返回必须给出有效的 napi_value。
同步函数的硬限制是不能阻塞太久。它跑在 ArkVM 的 JS 线程上,一旦超过一帧的时间预算(约 16ms),UI 就会掉帧;超过几秒还会触发系统的卡死检测。任何可能超过 5ms 的原生计算都应该改成异步。
5. C++ 回调 ArkTS:napi_call_function
原生侧主动通知 ArkTS 用 napi_call_function,前提是先持有目标函数的引用。
struct CallbackCtx {
napi_env env = nullptr;
napi_ref callbackRef = nullptr;
};
static void InvokeCallback(CallbackCtx* ctx, const std::string& message) {
napi_value global = nullptr;
napi_get_global(ctx->env, &global);
napi_value callback = nullptr;
napi_get_reference_value(ctx->env, ctx->callbackRef, &callback);
napi_value argv[1] = { nullptr };
napi_create_string_utf8(ctx->env, message.c_str(), message.size(), &argv[0]);
napi_value ignored = nullptr;
napi_call_function(ctx->env, global, callback, 1, argv, &ignored);
}
napi_call_function 的 this 参数通常传全局对象即可,因为 ArkTS 侧的回调一般写成箭头函数,不依赖 this。
关键限制是:napi_call_function 只能在创建该 napi_env 的线程上调用。如果原生库在自己的工作线程里回调,直接调用会导致崩溃或行为未定义。跨线程回调必须走下一节的线程安全函数。
6. 异步任务 napi_create_async_work
异步任务把耗时计算放到 NAPI 的工作线程池,执行完再回到 JS 线程触发回调。
struct AsyncCtx {
napi_env env = nullptr;
napi_async_work work = nullptr;
napi_ref callbackRef = nullptr;
std::string input;
std::string output;
};
static void ExecuteAsync(napi_env env, void* data) {
AsyncCtx* ctx = static_cast<AsyncCtx*>(data);
// 这个函数跑在 NAPI 工作线程,禁止调用任何 napi_* 接口
ctx->output = HeavyCompute(ctx->input);
}
static void CompleteAsync(napi_env env, napi_status status, void* data) {
AsyncCtx* ctx = static_cast<AsyncCtx*>(data);
// 回到 JS 线程,这里才可以创建值并调用回调
napi_value argv[2] = { nullptr, nullptr };
napi_get_undefined(env, &argv[0]);
napi_create_string_utf8(env, ctx->output.c_str(), ctx->output.size(), &argv[1]);
napi_value callback = nullptr;
napi_get_reference_value(env, ctx->callbackRef, &callback);
napi_value global = nullptr;
napi_get_global(env, &global);
napi_value ignored = nullptr;
napi_call_function(env, global, callback, 2, argv, &ignored);
napi_delete_reference(env, ctx->callbackRef);
napi_delete_async_work(env, ctx->work);
delete ctx;
}
static napi_value RunAsync(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2] = { nullptr };
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
AsyncCtx* ctx = new AsyncCtx();
ctx->env = env;
ctx->input = GetString(env, args[0]);
napi_create_reference(env, args[1], 1, &ctx->callbackRef);
napi_value resourceName = nullptr;
napi_create_string_utf8(env, "RunAsync", NAPI_AUTO_LENGTH, &resourceName);
napi_create_async_work(env, nullptr, resourceName, ExecuteAsync, CompleteAsync,
ctx, &ctx->work);
napi_queue_async_work(env, ctx->work);
return nullptr;
}
ExecuteAsync 与 CompleteAsync 的分工是硬约束:前者绝对不能调用任何 napi_* 接口,因为此时不在 JS 线程上;后者才回到 JS 线程。把 napi_create_string_utf8 写进 ExecuteAsync 是最经典的崩溃原因。
7. Promise 化:napi_create_promise
回调风格的接口在 ArkTS 侧用起来别扭,更现代的做法是返回 Promise。
static napi_value ComputeAsync(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);
napi_deferred deferred = nullptr;
napi_value promise = nullptr;
napi_create_promise(env, &deferred, &promise);
AsyncCtx* ctx = new AsyncCtx();
ctx->env = env;
ctx->input = GetString(env, args[0]);
ctx->deferred = deferred;
napi_value resourceName = nullptr;
napi_create_string_utf8(env, "ComputeAsync", NAPI_AUTO_LENGTH, &resourceName);
napi_create_async_work(env, nullptr, resourceName, ExecuteAsync, CompletePromise,
ctx, &ctx->work);
napi_queue_async_work(env, ctx->work);
return promise;
}
static void CompletePromise(napi_env env, napi_status status, void* data) {
AsyncCtx* ctx = static_cast<AsyncCtx*>(data);
napi_value value = nullptr;
napi_create_string_utf8(env, ctx->output.c_str(), ctx->output.size(), &value);
napi_resolve_deferred(env, ctx->deferred, value);
napi_delete_async_work(env, ctx->work);
delete ctx;
}
Promise 化之后,ArkTS 侧可以直接 await,与 TaskPool 的写法风格一致。注意 napi_deferred 必须在 CompletePromise 里恰好调用一次 napi_resolve_deferred 或 napi_reject_deferred,漏调会让 ArkTS 侧的 await 永远挂起,而且不会有任何报错。
8. 线程安全函数 napi_threadsafe_function
当原生库自己管理线程(例如回调来自解码器的工作线程),必须用线程安全函数把回调「投递」回 JS 线程。
static napi_value Subscribe(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);
napi_value resourceName = nullptr;
napi_create_string_utf8(env, "NativeEvent", NAPI_AUTO_LENGTH, &resourceName);
napi_threadsafe_function tsfn = nullptr;
napi_create_threadsafe_function(env, args[0], nullptr, resourceName,
0, 1, nullptr, nullptr, nullptr, OnJsThread, &tsfn);
// 交给原生库,在任意线程调用
NativeLib::Start([tsfn](const std::string& event) {
// 这一步是线程安全的,内部会把调用排到 JS 线程队列
napi_call_threadsafe_function(tsfn, strdup(event.c_str()), napi_tsfn_nonblocking);
});
return nullptr;
}
static void OnJsThread(napi_env env, napi_value jsCallback, void* context, void* data) {
// 这个函数一定在 JS 线程执行
char* text = static_cast<char*>(data);
if (env != nullptr && jsCallback != nullptr) {
napi_value arg = nullptr;
napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &arg);
napi_value global = nullptr;
napi_get_global(env, &global);
napi_value ignored = nullptr;
napi_call_function(env, global, jsCallback, 1, &arg, &ignored);
}
free(text);
}
napi_call_threadsafe_function 的第二个参数是数据指针,它的所有权转移给了回调,必须在 OnJsThread 里释放。这个指针的释放时机是内存泄漏与重复释放的高发点:漏释放会单调泄漏,释放两次会崩溃。
另外,线程安全函数必须显式关闭。业务结束时调用 napi_release_threadsafe_function(tsfn, napi_tsfn_release),否则 JS 线程会一直等待,进程无法正常退出。
9. 引用计数与生命周期管理
NAPI 用 napi_ref 持有 ArkTS 值的强引用,防止被 GC 回收。所有 napi_create_reference 都必须配对 napi_delete_reference。
| 引用类型 | 创建 | 释放 | 用途 |
|---|---|---|---|
| 强引用 | napi_create_reference(refCount=1) | napi_delete_reference | 长期持有回调函数 |
| 弱引用 | napi_create_reference(refCount=0) | napi_delete_reference | 缓存对象,允许被回收 |
| 立即引用 | napi_create_reference + 用完即删 | napi_delete_reference | 单次异步任务 |
| 全局引用 | napi_create_reference 保存在静态变量 | 模块卸载时删除 | 单例回调 |
引用计数最容易被忽略的一条是:napi_ref 保护的是 ArkTS 对象不被回收,但它不保护 C++ 侧的结构体。如果你的 AsyncCtx 被 delete 了,而引用还在,下次 napi_get_reference_value 会拿到一个悬空句柄。生命周期管理的正确姿势是让 C++ 结构体同时持有引用,两者同生共死。
一个实用的调试手段是在 napi_create_reference 与 napi_delete_reference 两侧打日志并计数,模块卸载时打印差值。差值为正就是泄漏,为负就是重复释放。
10. CMake 与 hvigor 集成原生库
原生库通过 CMakeLists.txt 描述构建规则,由 hvigor 在打包时自动调用。
cmake_minimum_required(VERSION 3.5.0)
project(hashmodule)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
include_directories(${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include)
add_library(entry SHARED
napi_init.cpp
hash.cpp)
target_link_libraries(entry PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
libcrypto.z.so)
{
"buildOption": {
"externalNativeOptions": {
"path": "./src/main/cpp/CMakeLists.txt",
"arguments": "-DCMAKE_BUILD_TYPE=Release",
"cppFlags": "-std=c++17",
"abiFilters": ["arm64-v8a", "x86_64"]
}
}
}
libace_napi.z.so 与 libhilog_ndk.z.so 是系统提供的 NAPI 与日志库,必须链接;其他系统库(如 libcrypto.z.so)按需添加。系统库用 .z.so 后缀引用,三方库用 target_link_libraries 指向预编译产物,把 .so 或 .a 放进 libs/<abi>/ 目录即可。
一个常见问题是调试符号:Release 构建会剥离符号,线上崩溃栈里只有地址。生产环境的做法是保留未剥离的 SO 用于符号还原,这与 鸿蒙原生应用性能优化与调试 里讲的混淆符号表上传是同一套思路。
11. SO 打包与 ABI 选择
鸿蒙设备的主流 ABI 是 arm64-v8a,模拟器通常用 x86_64。abiFilters 决定打包哪些架构。
| ABI | 目标设备 | 是否必选 | 包体积影响 |
|---|---|---|---|
| arm64-v8a | 真机(绝大多数) | 必选 | 基准 |
| armeabi-v7a | 老旧 32 位设备 | 可选 | 约增加 60% |
| x86_64 | 模拟器 | 仅开发期 | 约增加 60% |
实践建议很明确:发布包只保留 arm64-v8a,开发期再加 x86_64。同时打包三种 ABI 会让 HAP 体积增加一倍以上,而这些体积换来的兼容性收益极小。
另一个坑是三方预编译库的 ABI 覆盖。如果你的 .so 只提供了 arm64-v8a,但 abiFilters 里写了 armeabi-v7a,链接阶段会报找不到符号;反过来,如果预编译库带了 armeabi-v7a 而你的 abiFilters 没写,那个架构的产物会被直接丢掉而不报警告。构建产物落盘后用 unzip -l 检查 HAP 内的 libs/ 目录,是验证 ABI 是否正确的最快方式。
12. 实战:图片哈希原生模块
把上面的片段串成一个完整模块:对传入的图片字节流计算哈希,耗时计算走异步任务。
// hash.cpp
#include <openssl/sha.h>
#include <string>
std::string ComputeSha256(const unsigned char* data, size_t length) {
unsigned char digest[SHA256_DIGEST_LENGTH];
SHA256(data, length, digest);
static const char* hex = "0123456789abcdef";
std::string out(SHA256_DIGEST_LENGTH * 2, '\0');
for (size_t i = 0; i < SHA256_DIGEST_LENGTH; ++i) {
out[i * 2] = hex[digest[i] >> 4];
out[i * 2 + 1] = hex[digest[i] & 0x0F];
}
return out;
}
// index.ets
import nativeHash from 'libhashmodule.so';
export async function hashFile(bytes: ArrayBuffer): Promise<string> {
// 返回 Promise,ArkTS 侧直接 await
return nativeHash.sha256(bytes);
}
这个例子里有三处值得留意:ArrayBuffer 通过 napi_get_arraybuffer_info 拿到裸指针,是零拷贝的,不要先转成 string;哈希计算放在 ExecuteAsync 里,不占用 JS 线程;返回 Promise 而非回调,与 ArkTS 的异步风格一致。
原生库的单元测试无法用 Hypium 直接跑(Hypium 跑在 ArkTS 侧),推荐的做法是给 C++ 部分单独写一个可执行目标,用 hdc shell 推到设备上执行,或者用 CMake 的 CTest 在开发机上跑纯算法测试。跨语言边界只留一层薄薄的参数转换代码,把复杂逻辑都放在可独立测试的 C++ 层。
权衡取舍
NAPI 的取舍集中在「性能收益」与「工程成本」之间。
| 方案 | 性能 | 工程成本 | 适用场景 |
|---|---|---|---|
| 纯 ArkTS | 中等 | 低 | 绝大多数业务逻辑 |
| TaskPool 并行 ArkTS | 中高 | 中 | CPU 密集但无需原生库 |
| NAPI 同步调用 | 高(小计算量除外) | 高 | 短小计算、复用已有库 |
| NAPI 异步调用 | 高 | 高 | 耗时计算、大文件处理 |
| WASM 嵌入 | 中高 | 中 | 已有 C/C++ 且需要沙箱隔离 |
一个值得关注的替代路径是 WASM:如果原生库是纯算法、不依赖系统接口,编译成 WASM 后运行在 ArkTS 侧的沙箱里,既能复用 C/C++ 代码,又没有 ABI 与崩溃风险,代价是性能比原生低一档。选择依据是「是否需要调用系统能力」——需要就上 NAPI,不需要可以优先考虑 WASM。
常见坑清单
nm_modname与 ArkTS 导入名不一致。import拿到空对象且无报错,优先核对模块名。- 在
ExecuteAsync里调用napi_*接口。 该函数跑在工作线程,调用 NAPI 会随机崩溃。 - 子线程直接
napi_call_function。 必须在 JS 线程调用,跨线程要用napi_threadsafe_function。 napi_create_reference未配对napi_delete_reference。 引用泄漏导致 ArkTS 对象无法回收。napi_deferred未 resolve 或 reject。 ArkTS 侧await永久挂起,没有任何错误提示。- 不检查
napi_get_*的返回值。 类型不符时拿到初值 0 或空串,表现为计算结果错误而非报错。 - 线程安全函数未调用
napi_release_threadsafe_function。 JS 线程等待未完成的投递,进程无法退出。 - 投递给线程安全函数的堆指针重复释放或漏释放。 前者崩溃,后者单调泄漏。
- 发布包同时打包三种 ABI。 HAP 体积翻倍,收益极小;只保留 arm64-v8a。
- 三方预编译库缺少目标 ABI。 链接期报找不到符号,或产物被静默丢弃。
- 同步函数里做超过 5ms 的计算。 JS 线程被占满导致掉帧,严重时触发卡死检测。
小结
NAPI 的本质是两套运行时的对接,把它拆成三步就清楚了:注册阶段对齐模块名与导入名,映射阶段严格校验 napi_value 的类型,调用阶段守住线程边界——JS 线程才能碰 napi_*,工作线程只做纯计算,跨线程一律走线程安全函数。引用计数是贯穿全程的纪律:每一次 napi_create_reference 都要有对应的 napi_delete_reference,每一个 napi_deferred 都要被 resolve 或 reject。
工程侧的三条底线是:发布包只保留 arm64-v8a、耗时计算一律异步化、原生逻辑尽量下沉到可独立测试的 C++ 层。桥接代码越薄,崩溃面越小。想继续了解原生任务与 ArkTS 并发框架的配合方式,可以回看 鸿蒙并发模型 TaskPool 与 Worker ;把 C/C++ 编译到 WASM 的路线,可以参考 C++ 到 WebAssembly 的 Emscripten 实践 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。