
Node-API 开发流程从创建工程到 ArkTS 调用 CArkTS 跑在虚拟机里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 库就能像调普通函数一样调过去。整个交互的链路是这样的ArkTS 侧import libentry.so加载 so触发 napi_module_register调用 Init注册 napi_property_descriptorArkTS 调 callNativeNative 侧 CallNative 执行返回 napi_value回 ArkTS关键在于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_version1,.nm_flags0,.nm_filenamenullptr,.nm_register_funcInit,// 模块初始化函数.nm_modnameentry,// 模块名对应 libentry.so.nm_priv((void*)0),.reserved{0},};// 加载 so 时该函数自动被调用把 demoModule 注册到系统中externC__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.txtCMake 配置决定 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。CallNativeArkTS 调 C做加法staticnapi_valueCallNative(napi_env env,napi_callback_info info){size_t argc2;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,value0value1,sum);returnsum;}NativeCallArkTSC 调 ArkTS 回调staticnapi_valueNativeCallArkTS(napi_env env,napi_callback_info info){size_t argc1;napi_value args[1]{nullptr};napi_get_cb_info(env,info,argc,args,nullptr,nullptr);// 构造一个 int32 作为调用 ArkTS callback 时的入参napi_value argvnullptr;napi_create_int32(env,2,argv);// 调用 ArkTS 传进来的 callbacknapi_value resultnullptr;napi_call_function(env,nullptr,args[0],1,argv,result);returnresult;}这里有个套路要记住所有跨边界的数据都是napi_valueC 这边不能直接当成int/double用必须通过napi_get_value_*取出来算完再用napi_create_*包回去。这一进一出的转换就是 Node-API 的主要开销所在。ArkTS 侧调用ArkTS 侧就一行 import之后当普通模块用// entry/src/main/ets/pages/Index.etsimportnativeModulefromlibentry.soEntryComponentstruct Index{Statemessage:stringTest Node-API callNative result: ;Statemessage2:stringTest Node-API nativeCallArkTS result: ;build(){Row(){Column(){Text(this.message).fontSize(50).fontWeight(FontWeight.Bold).onClick((){// 调 C 的 CallNative做 2 3this.messagenativeModule.callNative(2,3);})Text(this.message2).fontSize(50).fontWeight(FontWeight.Bold).onClick((){// 把箭头函数传给 CC 内部再调回来this.message2nativeModule.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)的完整调用过程拆开看CallNative(C)libentry.soArkTSCallNative(C)libentry.soArkTSnapi_get_cb_info 取参napi_get_value_double ×2value0 value1import libentry.sodlopen → RegisterDemoModulenapi_module_register(demoModule)Init(env, exports)返回 exports 对象nativeModule.callNative(2, 3)napi_create_double → 返回 5各文件职责一览文件作用谁来写napi_init.cpp模块注册 C 函数实现开发者补 Init 描述符和函数体CMakeLists.txt决定 so 名、链接运行时库模板生成按需加源文件index.d.tsArkTS 侧类型声明开发者按导出方法写oh-package.json5关联 d.ts 和 so模板生成Index.etsArkTS 调用方开发者写业务案例在 Native 侧做字符串拼接加法例子太轻来个稍微像样点的——ArkTS 传两个字符串进 CC 拼接后返回。重点看字符串类型怎么处理。C 侧staticnapi_valueConcatStrings(napi_env env,napi_callback_info info){size_t argc2;napi_value args[2]{nullptr};napi_get_cb_info(env,info,argc,args,nullptr,nullptr);// 取字符串先拿长度再分配 buffer再拷贝size_t len10,len20;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],len11,len1);napi_get_value_string_utf8(env,args[1],s2[0],len21,len2);// 拼接std::string results1 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 调用constrnativeModule.concatStrings(Hello,Node-API);console.log(r);// Hello Node-API字符串 API 的套路是两次调用第一次传nullptr拿长度第二次分配好 buffer 再拷贝。这是 Node-API 里字符串处理的固定模式写多了就形成肌肉记忆。实践中要注意的so 名必须对齐add_library(entry ...)决定 so 名为libentry.sonapi_module.nm_modname必须是entryArkTS 侧import必须写libentry.so三处不一致就加载不到。Init 函数加 static防止和其他 so 里的同名函数符号冲突多模块工程里这点容易漏。注册入口函数名别重复__attribute__((constructor))修饰的函数名如RegisterDemoModule要保证全工程唯一否则符号表会打架。napi_value 不能跨调用缓存每次调用拿到的napi_value只在本次调用上下文有效想长期持有要用napi_create_reference创建引用。env 不能跨线程napi_env和创建它的 ArkTS 线程绑定跨线程用会 crash。这条下一篇细讲。预览器调不通 NativeDevEco 的预览器只渲染组件不加载 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 进来是anyIDE 不提示传错类型 Native 侧取出来是垃圾值排查很痛苦。性能敏感的调用尽量批量传数据别在循环里反复跨边界调 native。一次跨边界传一个 ArrayBuffer 进去做完再拿回来比循环里调 N 次快得多。