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 库就能像调普通函数一样调过去。

整个交互的链路是这样的:

ArkTS 侧
import libentry.so

加载 so
触发 napi_module_register

调用 Init
注册 napi_property_descriptor

ArkTS 调 callNative

Native 侧 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

// 准备模块加载相关信息
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) 的完整调用过程拆开看:

CallNative(C++)libentry.soArkTSCallNative(C++)libentry.soArkTSnapi_get_cb_info 取参napi_get_value_double ×2value0 + value1import 'libentry.so'dlopen → 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 传两个字符串进 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 次快得多。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐