为有牺牲多壮志,敢教日月换新天。

HarmonyOS:使用Node-API实现ArkTS与C/C++跨语言交互

★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★
➤博客园地址:为敢技术(https://www.cnblogs.com/strengthen/ 
➤GitHub地址:https://github.com/strengthen
➤原文地址:https://www.cnblogs.com/strengthen/p/18502733
➤如果链接不是为敢技术的博客园地址,则可能是爬取作者的文章。
➤原文已修改更新!强烈建议点击原文地址阅读!支持作者!支持原创!
★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★

一、Node-API简介
HarmonyOS Node-API是HarmonyOS提供的ArkTS/JS与C/C++跨语言调用的接口,是在Nodejs提供的Node-API基础上扩展而来,但与Nodejs中的Node-API不完全兼容。一般情况下HarmonyOS应用开发使用ArkTS/JS语言即可,但部分场景由于性能、效率等要求,比如游戏、物理模拟等,需要依赖使用现有的C/C++库。Node-API规范封装了I/O、CPU密集型、OS底层等能力,并对外暴露ArkTS/JS接口,从而实现ArkTS/JS与C/C++的交互。
主要应用场景如下:

(1)、系统可以将框架层丰富的模块功能通过ArkTS/JS接口开放给上层应用。

(2)、应用开发者也可以选择将一些对性能、底层系统调用有要求的核心功能用C/C++封装实现,再通过ArkTS/JS接口使用,提高应用本身的执行效率。

二、Node-API组成架构

在日常开发中,经常会有一些网络通信、串口访问、多媒体解码、传感器数据收集等模块,这些模块大部分都是用C++接口实现的,ArkTS侧如果想使用这些能力,就需要用Node-API这这样一套接口去桥接C++代码。下面这张图是Node-API整体的架构图。

1、ArkTS Native Module:是提供给开发者使用Node-API开发的模块,主要用于实现Native侧业务逻辑。

2、Node-API层:定义ArkTS与C/C++交互的逻辑。Node-API这部分接口是基于node.js的一个扩展,所以我们平常在开发中也可以参考node.js官网,像接口实现的功能,入参等都是类似的。

3、中间三个模块是Node-API的功能模块。

(1)、ModuleManager: 是管理对象的模块,这是比较重要的,当ArkTS侧调用C++时,会加载Native侧的模块到ModuleManager,并转化成ArkTS对象返回上层。

(2)、ScopeManager:用于管理 napi-value 生命周期,napi-value是Node-API独特的数据类型,类似于ArkTS中的number、string等各种数据类型的统一表示形式,在Native侧代码开发中不需要感知不同的数据类型,统一都是napi_value。

(3)、ReferenceManager:用于管理引用,开发时经常会有一些跨线程的场景,这个时候就需要创建引用(即napi_ref),否则就会被GC垃圾回收掉。napi_ref用于指向 napi_value,允许用户管理 napi_value 值的生命周期。

4、Native Engine:作用主要是统一ArkTS引擎在Node-API层的接口行为。Native引擎主要用于支撑Node-API接口定义实现,以及封装方舟运行时暴露的接口,以便Node-API不感知方舟运行时的差异变化。

5、ArkCompiler ArkTS Runtime:方舟运行时,也就是ArkTS引擎,整个Node-API模块都是跑在方舟运行时的。

三、Node-API交互流程
为什么ArkTS最终可以调用到C/C++方法?其实分为以下两步:
1、模块初始化:ArkTS侧在import一个so库的时候,会先找到ArkTS引擎,ArkTS引擎会加载模块信息到ModuleManager,其实对应的就是dlopen函数(注意:只在首次调用时加载、多次import时会去缓存里找,也就是说,导入的so库只要名字是一样的,在运行时只会被加载一次)。之后ModuleManager会把模块的信息返回给ArkTS引擎。ArkTS引擎拿到模块信息后,在native层触发模块注册,来初始化模块。注册完成之后通过底层框架返回给上层一个ArkTS对象,这个对象上挂载着C/C++侧的方法。我们拿到ArkTS对象之后,就可以去调用C/C++方法了。
2、函数调用:模块初始化完成之后,然后就是实际函数的调用,在调用C/C++暴露给ArkTS侧的方法时,会先走到ArkTS引擎,因为引擎里面已经包含了初始化阶段返回的native模块信息,就可以直接调用对应的C/C++方法,不用再走初始化过程了。即:当ArkTS侧通过上述import返回的对象调用方法时,ArkTS引擎会找到并调用对应的C/C++方法。最后在native侧完成业务逻辑之后,会将结果返回到ArkTS侧。

四、Node-API支持的数据类型和接口
1、Node-API数据类型:Node-API 将基本数据类型公开为各种API使用的抽象。这些API 应该被视为不透明的,只能通过其他Node-API调用进行自省。
(1)、napi_status: 枚举数据类型,表示Node-API接口返回的状态信息。每当调用一个Node-AP函数,都会返回该值,表示操作成功与否的相关信息。枚举信息如下图,这里列举了部分枚举类型,包括napi_ok等等,更加详细的枚举信息可以参考文档指南。napi_status具体使用方法可参考下图的举例说明,这里使用napi_ok与napi_get_cb_info函数的返回值进行对比,判断操作是否成功。

(2)、napi_value:Node-API独特的数据类型,在Native侧代码中,表示一个ArkTS类型值,类似于ArkTS中的number、string等各种数据类型的统一表示形式。在Native侧代码开发中不需要感知不同的数据类型,统一都是napi_value。

举例说明:声明一个napi_value类型的变量napiResult,然后通过调用napi_create_double函数,给napiResult赋值。

 

napi_value napiResult;
// 给napi_ value 类型参数赋值,这个赋了一个double类型数值2.0
napi_create_double(env,2.0, &napiResult);

 

(3)、napi_env:Native侧函数的入参,表示Node-API执行时的上下文,可以传递给函数中的Node-API接口。退出Native侧时,napi_env将失效。
(4)、napi_callback_info: Native侧函数的入参,保存了ArkTS侧的参数信息,用于传递给napi_get_cb_info()函数获取ArkTS侧入参信息。举例说明:这里定义了一个MyHypot()函数,包含两个参数,代表执行上下文的evn和保存了ArkTS侧的参数信息的info,这里假设ArkTS侧需要传递两个参数到Native侧,argc代表ArkTS侧传递到参数个数,args[]数组用于存储获取的ArkTS侧参数,通过napi_get_cb_info(),将info里面的ArkTS参数存储到napi_value类型的args[]数组中,这里的env被传递到Node-API接口napi_get_cb_info()中使用。

2、 Node-API接口:在Nodejs提供的原生模块基础上扩展,目前支持部分接口,以下仅对部分常用接口简单介绍。
(1)、napi_get_cb_info:从给定的napi_callback_info中获取有关调用的详细信息,如参数和this指针。下图示例中所示,即从info中获取两个ArkTS侧参数到args[]数组中。
(2)、napi_get_value_double:获取给定ArkTS 的number类型值。下图示例中所示,声明了一个double类型的valueX,用于存储从ArkTS侧获取的number类型值,从info中获取两个ArkTS侧参数到args[]数组中,通过napi_get_value_double接口,将args[0]中存储的ArkTS侧参数转换成double类型,存储到valueX变量的地址中。

(3)、napi_create_string_utf8:通过UTF8编码的C字符串数据创建ArkTS侧string类型的数据。下图示例中所示,先声明了一个string类型的字符串str,然后声明一个napi_value类型对象sum,用于存储转换后的ArkTS侧string类型数据,最后通过napi_create_string_utf8接口进行数据的转换,并将转换结果存到sum中,这个sum可以用于返回到ArkTS侧使用。napi_create_string_utf8的第三个参数传入的是str.length,表示字符串最大转换长度,当被转换的字符串长度超过这个最大转换长度时,字符串将会被截断。

五、使用Node-API实现跨语言交互开发流程

1、通过一个案例了解Node-API实现跨语言交互的开发流程。如下图,左边展示的是案例的界面显示,右侧是该案例的交互流程图,

案例介绍:用户输入两个number类型的数值X、Y。点击计算结果,按钮会在结果栏下显示一个计算结果。针对本案例,具体交互流程,如下图右侧所示:

当用户点击计算结果按钮时,会触发Native侧函数的调用。传递参数信息到Native侧,就是刚刚提到的用户输入的两个参数,Native侧获取到ArkTS侧传递过来的参数后,通过Node-API接口将参数转换成C++类型的参数,接着调用对应的C++业务代码进行数据处理,计算出结果,然后将结果转化为ArkTS侧类型的参数,最终将结果传递回ArkTS侧显示。

2、在DevEco Studio中File >New > Create Project,选择Native C++模板,点击Next,选择API版本,设置好工程名称,点击Finish,创建得到新工程。创建工程后工程结构可以分两部分,cpp目录部分和ets目录部分,具体可见下文的工程目录介绍。刚开始接触Node-API的时候,Native C++模版可以作为一个很好的切入点,一些开发Node-API所需要关注的点,或者说整体的代码框架已经帮开发者搭建好了。所需要的就是往里面填充业务逻辑。

 3、构建一个工程并做介绍:

(1)、在DevEco Studio中File >New > Create Project。

(2)、选择Native C++模板,点击Next。

(3)、选择API版本,设置好工程名称,点击Finish。

 (4)、创建工程后,可以看到工程结构可以分为两个部分:一部分在cpp目录下,一部分在ets目录下。

(5)、cpp目录里面,types目录下的文件介绍。

index.d.ets文件:里面定义了C++侧需要暴露在Ark TS侧的接口,后续调用C++侧函数时,其实就是调用这个里面定义的接口。

oh-package.json5文件:描述这个index.d.ts文件的。

CMakeList.txt文件:C++编译的配置文件,里面定义了编译的模块名,依赖的文件,包括最终生成的是静态库还是动态库,都是在CMakeList.txt文件里面配置的。

napi_init文件:最重要的文件,开发者要在里面做Native模块的注册、类型的转换,以及大部分业务逻辑,都是写在napi_init里面的。

(6)、ets目录,会默认创建一个index.ets的文件,这个文件里面会有一个ArkTS调用C++的示例。ArkTS侧的代码主要就在这个文件里面。

(7)、模块级build-profile.json5文件:主要是配置构建信息,其实主要就是配置了CMakeList文件的路径,构建的时候能找到CMakeList文件。

(8)、模块级的oh-package.json5文件:主要配置了一些模块本身的信息和依赖的工程。

4、如何逐步开发Node-API工程

(1)、基于Node-API开发业务功能:Native侧方法功能的实现,一般分为这几个步骤:参数提取,把ArkTS侧可用的对象类型napi_value转化为C++侧类型,比如number、string。然后执行C++侧业务逻辑,最后再把C++类型转化成ArkTS可用的anpi_value对象返回出去。

// hello.cpp
//
首先看一下参数,env表示Node-API执行时的上下文,info里面存储了ArkTS侧传递过来的参数信息。 static napi_value MyHypot(napi_env env, napi_callback_info info) { if ((nullptr == env) || (nullptr == info)) { OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_PRINT_DOMAIN, "MyHypot", "env or exports is null"); return nullptr; } // Number of parameters. // 案例是计算两个数的平方和的平方根,ArkTS侧需要传递两个参数到Native侧,所以这里需要接收两个参数,argc代表参数个数。 size_t argc = PARAMETER_COUNT; // Declare parameter array. // args[]数组是用于存储ArkTS参数的参数数组, napi_value args[PARAMETER_COUNT] = { nullptr }; // Gets the arguments passed in and puts them in the argument array. // 1、首先通过napi_get_cb_info函数,将info里面的参数信息获取到args[]数组中, if (napi_ok != napi_get_cb_info(env, info, &argc, args, nullptr, nullptr)) { OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_PRINT_DOMAIN, "MyHypot", "api_get_cb_info failed"); return nullptr; } // Converts arguments passed in to type double. // 2、然后定义两个double类型的参数valueX、valueY double valueX = 0.0; double valueY = 0.0; // 3、使用napi_get_value_double函数,将获取到args[]数组中的参数信息,转换成C++侧的double类型,并存储到valueX、valueY中。 if (napi_ok != napi_get_value_double(env, args[0], &valueX) || napi_ok != napi_get_value_double(env, args[1], &valueY)) { OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_PRINT_DOMAIN, "MyHypot", "napi_get_value_double failed"); return nullptr; } // The hypot method of the C standard library is called to perform the calculation. // 4、接着调用C标准库中的hypot()方法来执行计算。 double result = hypot(valueX, valueY); napi_value napiResult; // 5、最终通过napi_create_double函数,将计算结果存储到napi_value类型的参数napiResult中, if (napi_ok != napi_create_double(env, result, &napiResult)) { OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_PRINT_DOMAIN, "MyHypot", "napi_create_double failed"); return nullptr; } // 6、最后通过return napiResult,将结果返回到ArkTS侧使用。 return napiResult; }

(2)、接口映射:ArkTS接口与C/C++接口的绑定和映射,这一过程是在初始化函数里面完成的,

EXTERN_C_START
// 针对本案例就是Init方法
static napi_value Init(napi_env env, napi_value exports)
{
    if ((nullptr == env) || (nullptr == exports)) {
        OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_PRINT_DOMAIN, "Init", "env or exports is null");
        return exports;
    }

    // 当前只定义了一个方法myHypot,这里其实是做了一个绑定,
    napi_property_descriptor desc[] = {
        // 第一个参数:"myHypot"是在ArkTS侧暴露的接口名,是小写myHypot,基于命名规范命名。
        // 第三个参数是在C++侧对应的方法,是大写MyHypot,基于命名规范命名。
        //本案例中只定义了一个myHypot方法,实际开发时可以在这里定义更多的方法映射,以便在ArkTS侧可以调用更多的C++侧方法。
        { "myHypot", nullptr, MyHypot, nullptr, nullptr, nullptr, napi_default, nullptr }
    };
    if (napi_ok != napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc)) {
        OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_PRINT_DOMAIN, "Init", "napi_define_properties failed");
        return nullptr;
    }
    return exports;
}
EXTERN_C_END

(3)、模块注册:ArkTS侧导入模块时,会加载其对应的so,在加载so时,首先会调用napi_module_register方法,将Node-API模块注册到系统中,并调用模块初始化函数,即对应刚刚定义的Init方法。

static napi_module demoModule = {
    .nm_version = 1, // 版本
    .nm_flags = 0,
    .nm_filename = nullptr,
    // 重要,里面定义了模块初始化函数,这里定义的初始化函数就是上方的Init函数,初始化时会进行接口映射。
    .nm_register_func = Init, 
    // 重要,里面定义模块的名称,也就是ArkTS侧引入的so库的名称。模块系统会根据此名称来区分不同的so.
    // 例如此处定义的模块名称为hello。
    // 相对应需要导入的so库名称将会是libhello.so
    .nm_modname = "hello", 
    .nm_priv = ((void *)0),
    .reserved = { 0 }
};

extern "C" __attribute__((constructor)) void RegisterModule(void)
{
    // 在调用napi_module_register方法时,会传入一个demoModule对象,就是上面定义的demoModule结构体
    napi_module_register(&demoModule);
}

(4)、模块构建配置:需要在CMakeList文件中配置一下编译信息。即在CMakeList.txt文件中配置CMake打包参数。CMakeList是标准的CMake构建配置项。在比较复杂的项目中,CMakeList要配置的项就会比较多。

# the minimum version of CMake.
# CMake最低版本
cmake_minimum_required(VERSION 3.4.1)
# project名称
project(NativeTemplateDemo)

# 就是把后面这个变量的值赋值给前面,就相当于起了个别名。
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

# 需要放整个项目编译所用到的头文件目录。一般用默认配置即可。
# 会配置整个cpp目录和cpp下的include目录。
include_directories(
    ${NATIVERENDER_ROOT_PATH}
    ${NATIVERENDER_ROOT_PATH}/include
)

# find_library是查找hilog_ndk.z库的位置。会将其信息存储到变量hilog-lib中。以便后续添加依赖模块时使用。
# hilog-lib命令适用于开发者不清楚需要的依赖库的具体路径时
find_library(
    # Sets the name of the path variable.
    hilog-lib
    # Specifies the name of the NDK library that
    # you want CMake to locate.
    hilog_ndk.z
)

# add_library命令,构建编译产物。
# 第一个参数是编译的模块名称,这里写了hello,最后编译出来的就叫做libhello.so。
# 第二个参数SHARED表示是动态库。
# 第三个参数需要配置参与编译的所有cpp文件。
# 也支持编译静态库,如果把SHARED改成STSTIC, 编译出来的就是静态库文件。
add_library(hello SHARED hello.cpp)

# target_link_libraries定义开发者需要编译当前模块需要依赖哪些模块。
# 比如当前案例需要依赖NAPI的能力和一些底层C++能力。就需要制定NAPI的库和libc++的库。
target_link_libraries(hello PUBLIC ${hilog-lib} libace_napi.z.so libc++.a)

上面已经配置了CMake参数信息,为什么构建工程的时候会编译C/C++模块呢?原因是在module级别的build-profile.json5文件中,指定了CMakeList文件的位置。也就是说CMakeList文件可以被放在任何目录。只要我们在build-profile.json5文件中配置的路径能够被找到即可。

(5)、导出Native接口:

// index.d.ts
//
标准的TS接口定义文件,定义C/C++侧暴露给ArkTS侧的接口,并将其导出。 // 本案例定义了一个myHypot方法,即上文中映射到ArkTS侧的方法。 // 该方法需要传递两个number类型的参数,并且最终会返回一个number类型的数据。 export const myHypot: (a: number, b: number) => number;

接口声明完成后,需要在模块级oh-package.json5文件中指定dependencies。就是ArkTS侧所依赖的C++侧的index.d.ts文件的路径。这里一般不需要开发者操作,在创建Native工程时会自动生成。

5、回到ArkTS侧,首选需要import一个so。

import libHello from 'libhello.so';

需要强调的是,'libhello.so'的这个so的名称,和上述Init方法里面的名称,以及CMakeList里面addlibrary的名称,这三处需要保持一致,否则会出现一些不可避免的问题。导入完so库,就可以调用C++侧的代码了。通过libHello调用myHypot方法,该方法已经做过与Native侧的MyHpot方法的映射,所以可以直接调用到Native侧的MyHpot方法进行结果的计算。  

 

// entry/src/main/ets/pages/Index.ets
// 通过import的方式,引入Native能力。
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() {
        // 第一个按钮,调用add方法,对应到Native侧的CallNative方法,进行两数相加。
        Text(this.message)
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
          .onClick(() => {
            this.message += nativeModule.callNative(2, 3);
            })
        // 第二个按钮,调用nativeCallArkTS方法,对应到Native的NativeCallArkTS,在Native调用ArkTS function。
        Text(this.message2)
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
          .onClick(() => {
            this.message2 += nativeModule.nativeCallArkTS((a: number)=> {
                return a * 2;
            });
          })
      }
      .width('100%')
    }
    .height('100%')
  }
}

 

结果演示,源码下载:NativeTemplate.zip

 
posted @ 2024-10-25 15:57  为敢技术  阅读(0)  评论(0编辑  收藏  举报