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// 准备模块加载相关信息staticnapi_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))voidRegisterDemoModule(){napi_module_register(&demoModule);}__attribute__((constructor))这个 GCC 扩展告诉链接器:so 一被 dlopen 进来就先跑这个函数。所以注册是自动发生的,ArkTS 侧 import 时就触发了整条链路。
模块初始化
Init函数拿到一个napi_env(代表当前 ArkTS 环境)和一个napi_value exports(导出对象),把 C++ 函数挂到 exports 上,ArkTS 侧就能调到。
EXTERN_C_STARTstaticnapi_valueInit(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);returnexports;}EXTERN_C_ENDnapi_property_descriptor这个结构体字段很多,但常用的就前三个:ArkTS 侧的方法名、C++ 侧的实现函数指针。其余的 setter/getter、属性特性这里用不上,填nullptr就行。
类型声明与包关联
ArkTS 侧 import 进来要有类型提示,靠index.d.ts提供:
// entry/src/main/cpp/types/libentry/index.d.tsexportconstcallNative:(a:number,b:number)=>number;exportconstnativeCallArkTS:(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++,做加法
staticnapi_valueCallNative(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++ 的 doubledoublevalue0;napi_get_value_double(env,args[0],&value0);doublevalue1;napi_get_value_double(env,args[1],&value1);// 算完,把结果包回 napi_value 返回napi_value sum;napi_create_double(env,value0+value1,&sum);returnsum;}NativeCallArkTS:C++ 调 ArkTS 回调
staticnapi_valueNativeCallArkTS(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 传进来的 callbacknapi_value result=nullptr;napi_call_function(env,nullptr,args[0],1,&argv,&result);returnresult;}这里有个套路要记住:所有跨边界的数据都是napi_value,C++ 这边不能直接当成int/double用,必须通过napi_get_value_*取出来,算完再用napi_create_*包回去。这一进一出的转换就是 Node-API 的主要开销所在。
ArkTS 侧调用
ArkTS 侧就一行 import,之后当普通模块用:
// entry/src/main/ets/pages/Index.etsimportnativeModulefrom'libentry.so'@Entry@Componentstruct Index{@Statemessage:string='Test Node-API callNative result: ';@Statemessage2:string='Test Node-API nativeCallArkTS result: ';build(){Row(){Column(){Text(this.message).fontSize(50).fontWeight(FontWeight.Bold).onClick(()=>{// 调 C++ 的 CallNative,做 2 + 3this.message+=nativeModule.callNative(2,3);})Text(this.message2).fontSize(50).fontWeight(FontWeight.Bold).onClick(()=>{// 把箭头函数传给 C++,C++ 内部再调回来this.message2+=nativeModule.nativeCallArkTS((a:number)=>{returna*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++ 侧:
staticnapi_valueConcatStrings(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::strings1(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_valuenapi_value ret;napi_create_string_utf8(env,result.c_str(),result.size(),&ret);returnret;}Init 里挂上:
{"concatStrings",nullptr,ConcatStrings,nullptr,nullptr,nullptr,napi_default,nullptr},d.ts 里声明:
exportconstconcatStrings:(a:string,b:string)=>string;ArkTS 调用:
constr=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 次快得多。