鸿蒙 NAPI 与 C/C++ 原生互操作

本文讲解 ArkTS 与 C/C++ 的原生互操作:NAPI 模块注册方式、napi_value 与 ArkTS 类型的映射规则、同步与异步函数实现、线程安全函数跨线程回调、引用计数与内存陷阱,以及 CMake 与 hvigor 集成原生库、SO 打包与 ABI 选择的完整工程配置。

引言

ArkTS 跑在 ArkVM 上,绝大多数业务代码用纯 ArkTS 就够了。但有三类场景绕不开 C/C++:复用已有的原生库(音视频编解码、加解密、图像算法)、追求极致性能的热点计算、以及必须直接调用硬件或系统底层接口的能力。NAPI(Native API)就是这两端之间的唯一桥梁。

NAPI 的难点不在 API 数量,而在两套内存模型与线程模型的对接。ArkVM 有自己的垃圾回收器,C++ 侧的内存完全由开发者手动管理;ArkVM 的 JS 线程只有一条,而原生库往往自带线程池。一旦引用计数没配平、或者从子线程直接回调 ArkTS 函数,程序会以「偶发崩溃」的形式表现出来,堆栈指向随机位置,极难复现。

本文按「注册、映射、调用、异步、打包」五步展开,示例基于 API 12 的 NAPI 接口。线程模型的前置知识可以看 鸿蒙并发模型 TaskPool 与 Worker ,本文只讲原生侧与 ArkTS 侧的交互边界。

目录

  1. 什么时候该用 NAPI
  2. NAPI 模块的注册方式
  3. napi_value 与 ArkTS 类型映射
  4. ArkTS 调用 C++:同步函数实现
  5. C++ 回调 ArkTS:napi_call_function
  6. 异步任务 napi_create_async_work
  7. Promise 化:napi_create_promise
  8. 线程安全函数 napi_threadsafe_function
  9. 引用计数与生命周期管理
  10. CMake 与 hvigor 集成原生库
  11. SO 打包与 ABI 选择
  12. 实战:图片哈希原生模块
  13. 权衡取舍
  14. 常见坑清单
  15. 小结

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 类型创建读取备注
numbernapi_create_double / int32napi_get_value_double / int32整数用 int32 更快
stringnapi_create_string_utf8napi_get_value_string_utf8需要两段式调用取长度
booleannapi_get_booleannapi_get_value_bool无独立 create
objectnapi_create_objectnapi_get_named_property属性名用 C 字符串
arraynapi_create_arraynapi_get_element配合 length 遍历
ArrayBuffernapi_create_arraybuffernapi_get_arraybuffer_info零拷贝传二进制
functionnapi_create_functionnapi_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。

常见坑清单

  1. nm_modname 与 ArkTS 导入名不一致。 import 拿到空对象且无报错,优先核对模块名。
  2. 在 ExecuteAsync 里调用 napi_* 接口。 该函数跑在工作线程,调用 NAPI 会随机崩溃。
  3. 子线程直接 napi_call_function。 必须在 JS 线程调用,跨线程要用 napi_threadsafe_function。
  4. napi_create_reference 未配对 napi_delete_reference。 引用泄漏导致 ArkTS 对象无法回收。
  5. napi_deferred 未 resolve 或 reject。 ArkTS 侧 await 永久挂起,没有任何错误提示。
  6. 不检查 napi_get_* 的返回值。 类型不符时拿到初值 0 或空串,表现为计算结果错误而非报错。
  7. 线程安全函数未调用 napi_release_threadsafe_function。 JS 线程等待未完成的投递,进程无法退出。
  8. 投递给线程安全函数的堆指针重复释放或漏释放。 前者崩溃,后者单调泄漏。
  9. 发布包同时打包三种 ABI。 HAP 体积翻倍,收益极小;只保留 arm64-v8a。
  10. 三方预编译库缺少目标 ABI。 链接期报找不到符号,或产物被静默丢弃。
  11. 同步函数里做超过 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 实践 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理