【HarmonyOS开发小实践】Node-API 开发流程:从创建工程到 ArkTS 调用 C++
Node-API 开发流程:从创建工程到 ArkTS 调用 C++
ArkTS 跑在虚拟机里,C++ 跑在 native 层,两者之间隔着一层抽象。要把图像编解码、加密、信号处理这类计算密集的活儿交给 C++ 干,又想让 ArkTS 调过去,就得靠 Node-API 这套桥。这篇就把从建工程到 ArkTS 调通 C++ 函数的整条链路走一遍,每一处该改什么文件、写什么代码都给出来。
Node-API 是什么
Node-API 是 ArkTS/JS 与 C/C++ 交互的桥梁,本质是一组 C 接口(napi_* 系列),由方舟运行时提供。Native 侧用 C++ 实现功能,通过 Node-API 把 C++ 函数注册成 ArkTS 可调用的方法;ArkTS 侧 import 一个 .so 库就能像调普通函数一样调过去。
整个交互的链路是这样的:
关键在于:so 加载时会自动调用一个用 __attribute__((constructor)) 修饰的函数,这个函数把模块注册到系统中;注册时指定的 Init 函数负责把 ArkTS 名字和 C++ 函数绑定起来。理解了这两步,剩下的就是填空。
创建 Native C++ 工程
在 DevEco Studio 里走 New > Create Project,选 Native C++ 模板,Next,选 API 版本,填工程名,Finish。模板会自动把 cpp 侧骨架和 ets 侧调用样例都生成好,不用从零搭。
建完之后工程结构分两块:
entry/src/main/
├── cpp/ # Native 侧
│ ├── napi_init.cpp # 模块注册 + 方法实现
│ ├── CMakeLists.txt # CMake 打包配置
│ └── types/libentry/
│ ├── index.d.ts # ArkTS 侧的类型声明
│ └── oh-package.json5 # 把 d.ts 和 cpp 关联起来
└── ets/pages/Index.ets # ArkTS 侧调用方
模板生成的 napi_init.cpp 里已经写好了模块注册的样板代码,开发者只需要补 Init 里的描述符和具体的 C++ 函数实现。
Native 侧的实现
模块注册
so 被加载时,第一个被调用的是 napi_module_register。它把一个 napi_module 结构体注册到系统里。这个结构体有两个关键字段:
nm_register_func:模块初始化函数,负责把 ArkTS 接口和 C++ 函数绑定起来nm_modname:模块名,决定了 ArkTS 侧 import 的 so 名
// entry/src/main/cpp/napi_init.cpp
// 准备模块加载相关信息
static napi_module demoModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init, // 模块初始化函数
.nm_modname = "entry", // 模块名,对应 libentry.so
.nm_priv = ((void*)0),
.reserved = {0},
};
// 加载 so 时该函数自动被调用,把 demoModule 注册到系统中
extern "C" __attribute__((constructor)) void RegisterDemoModule() {
napi_module_register(&demoModule);
}
__attribute__((constructor)) 这个 GCC 扩展告诉链接器:so 一被 dlopen 进来就先跑这个函数。所以注册是自动发生的,ArkTS 侧 import 时就触发了整条链路。
模块初始化
Init 函数拿到一个 napi_env(代表当前 ArkTS 环境)和一个 napi_value exports(导出对象),把 C++ 函数挂到 exports 上,ArkTS 侧就能调到。
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
// 描述符数组:每一行把一个 ArkTS 名字绑定到一个 C++ 函数
napi_property_descriptor desc[] = {
{"callNative", nullptr, CallNative, nullptr, nullptr, nullptr, napi_default, nullptr},
{"nativeCallArkTS", nullptr, NativeCallArkTS, nullptr, nullptr, nullptr, napi_default, nullptr}
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
EXTERN_C_END
napi_property_descriptor 这个结构体字段很多,但常用的就前三个:ArkTS 侧的方法名、C++ 侧的实现函数指针。其余的 setter/getter、属性特性这里用不上,填 nullptr 就行。
类型声明与包关联
ArkTS 侧 import 进来要有类型提示,靠 index.d.ts 提供:
// entry/src/main/cpp/types/libentry/index.d.ts
export const callNative: (a: number, b: number) => number;
export const nativeCallArkTS: (cb: (a: number) => number) => number;
再用 oh-package.json5 把这个 d.ts 和 so 关联起来:
// entry/src/main/cpp/types/libentry/oh-package.json5
{
"name": "libentry.so",
"types": "./index.d.ts",
"version": "",
"description": "Please describe the basic information."
}
CMakeLists.txt
CMake 配置决定 so 怎么编出来。模板生成的版本已经够用,关键是 add_library 那一行决定了 so 的名字,target_link_libraries 把 Node-API 的运行时库链进来:
# entry/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
project(MyApplication)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
include_directories(${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include)
# 添加名为 entry 的库 → 产物是 libentry.so
add_library(entry SHARED napi_init.cpp)
# 链接 Node-API 运行时
target_link_libraries(entry PUBLIC libace_napi.z.so)
add_library(entry SHARED ...) 的第一个参数 entry 就是 so 名的来源,最后产物是 libentry.so,必须和 napi_module.nm_modname 一致。
实现 C++ 函数
模板里两个示例函数刚好覆盖了两种典型用法:ArkTS 调 C++、C++ 调 ArkTS。
CallNative:ArkTS 调 C++,做加法
static napi_value CallNative(napi_env env, napi_callback_info info)
{
size_t argc = 2;
napi_value args[2] = {nullptr};
// 从 info 里取出 ArkTS 传进来的参数
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 把 napi_value 转成 C++ 的 double
double value0;
napi_get_value_double(env, args[0], &value0);
double value1;
napi_get_value_double(env, args[1], &value1);
// 算完,把结果包回 napi_value 返回
napi_value sum;
napi_create_double(env, value0 + value1, &sum);
return sum;
}
NativeCallArkTS:C++ 调 ArkTS 回调
static napi_value NativeCallArkTS(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);
// 构造一个 int32 作为调用 ArkTS callback 时的入参
napi_value argv = nullptr;
napi_create_int32(env, 2, &argv);
// 调用 ArkTS 传进来的 callback
napi_value result = nullptr;
napi_call_function(env, nullptr, args[0], 1, &argv, &result);
return result;
}
这里有个套路要记住:所有跨边界的数据都是 napi_value,C++ 这边不能直接当成 int/double 用,必须通过 napi_get_value_* 取出来,算完再用 napi_create_* 包回去。这一进一出的转换就是 Node-API 的主要开销所在。
ArkTS 侧调用
ArkTS 侧就一行 import,之后当普通模块用:
// entry/src/main/ets/pages/Index.ets
import nativeModule from 'libentry.so'
@Entry
@Component
struct Index {
@State message: string = 'Test Node-API callNative result: ';
@State message2: string = 'Test Node-API nativeCallArkTS result: ';
build() {
Row() {
Column() {
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(() => {
// 调 C++ 的 CallNative,做 2 + 3
this.message += nativeModule.callNative(2, 3);
})
Text(this.message2)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(() => {
// 把箭头函数传给 C++,C++ 内部再调回来
this.message2 += nativeModule.nativeCallArkTS((a: number) => {
return a * 2;
});
})
}
.width('100%')
}
.height('100%')
}
}
import nativeModule from 'libentry.so' 这一句背后发生的事情:dlopen 加载 libentry.so → 触发 RegisterDemoModule → 调用 Init → 把 callNative/nativeCallArkTS 挂到 exports 上 → ArkTS 拿到一个有这两个方法的对象。
完整数据流
把一次 callNative(2, 3) 的完整调用过程拆开看:
各文件职责一览
| 文件 | 作用 | 谁来写 |
|---|---|---|
| napi_init.cpp | 模块注册 + C++ 函数实现 | 开发者补 Init 描述符和函数体 |
| CMakeLists.txt | 决定 so 名、链接运行时库 | 模板生成,按需加源文件 |
| index.d.ts | ArkTS 侧类型声明 | 开发者按导出方法写 |
| oh-package.json5 | 关联 d.ts 和 so | 模板生成 |
| Index.ets | ArkTS 调用方 | 开发者写业务 |
案例:在 Native 侧做字符串拼接
加法例子太轻,来个稍微像样点的——ArkTS 传两个字符串进 C++,C++ 拼接后返回。重点看字符串类型怎么处理。
C++ 侧:
static napi_value ConcatStrings(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);
// 取字符串:先拿长度,再分配 buffer,再拷贝
size_t len1 = 0, len2 = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len1);
napi_get_value_string_utf8(env, args[1], nullptr, 0, &len2);
std::string s1(len1, '\0'), s2(len2, '\0');
napi_get_value_string_utf8(env, args[0], &s1[0], len1 + 1, &len1);
napi_get_value_string_utf8(env, args[1], &s2[0], len2 + 1, &len2);
// 拼接
std::string result = s1 + " " + s2;
// 包回 napi_value
napi_value ret;
napi_create_string_utf8(env, result.c_str(), result.size(), &ret);
return ret;
}
Init 里挂上:
{"concatStrings", nullptr, ConcatStrings, nullptr, nullptr, nullptr, napi_default, nullptr},
d.ts 里声明:
export const concatStrings: (a: string, b: string) => string;
ArkTS 调用:
const r = nativeModule.concatStrings('Hello', 'Node-API');
console.log(r); // "Hello Node-API"
字符串 API 的套路是"两次调用":第一次传 nullptr 拿长度,第二次分配好 buffer 再拷贝。这是 Node-API 里字符串处理的固定模式,写多了就形成肌肉记忆。
实践中要注意的
- so 名必须对齐:
add_library(entry ...)决定 so 名为libentry.so,napi_module.nm_modname必须是"entry",ArkTS 侧import必须写'libentry.so',三处不一致就加载不到。 - Init 函数加 static:防止和其他 so 里的同名函数符号冲突,多模块工程里这点容易漏。
- 注册入口函数名别重复:
__attribute__((constructor))修饰的函数名(如RegisterDemoModule)要保证全工程唯一,否则符号表会打架。 - napi_value 不能跨调用缓存:每次调用拿到的
napi_value只在本次调用上下文有效,想长期持有要用napi_create_reference创建引用。 - env 不能跨线程:
napi_env和创建它的 ArkTS 线程绑定,跨线程用会 crash。这条下一篇细讲。 - 预览器调不通 Native:DevEco 的预览器只渲染组件,不加载 so,调 native 会报
TypeError: undefined is not callable。功能调试得用模拟器或真机。
总结一下下
- 模板生成的代码已经把最繁琐的注册部分写好了,别复制粘贴网上的旧示例覆盖掉,容易把
EXTERN_C_START/EXTERN_C_END这些宏搞丢,导致 C++ 符号 name mangling 不对,加载时报符号找不到。 - 写完 C++ 改了 .cpp 不生效,多半是 CMake 缓存没刷新。DevEco 里 clean 一下再 build,或者直接删
entry/build重建。 - 类型声明
index.d.ts别偷懒不写。不写也能跑,但 ArkTS 侧 import 进来是any,IDE 不提示,传错类型 Native 侧取出来是垃圾值,排查很痛苦。 - 性能敏感的调用尽量批量传数据,别在循环里反复跨边界调 native。一次跨边界传一个 ArrayBuffer 进去做完再拿回来,比循环里调 N 次快得多。
更多推荐


所有评论(0)