ArkTs如何使用NApi调用C++代码

1,361 阅读10分钟

简介

👋NAPI是什么?

NAPI是Node-API曾用名。是HarmonyOS中提供ArkTS/JS与C/C++跨语言调用的接口,是NDK接口中的一部分。

NDK(Native Development Kit)是HarmonyOS SDK提供的Native API、相应编译脚本和编译工具链的集合,方便开发者使用C或C++语言实现应用的关键功能。

💡 简而言之,NAPI提供了ArkTs/js调用C/C++代码和C/C++调用ArkTs/js代码的能力。

为什么要用NAPI调用C++代码?

  • 性能敏感的场景,如游戏、物理模拟等计算密集型场景。
  • 需要复用已有C或C++库的场景。
  • 需要针对CPU特性进行专项定制库的场景,如Neon加速。

NAPI怎么用?

对于ArkTs侧来说,主要应该学习以下的四个部分:

  • napi_init.cpp
  • index.d.ts
  • CMakeLists.txt
  • 特殊的,图像绘制:XComponent对于napi的使用

用法剖析

📌 由一个小例子切入

🍒 1. 一个简单的C++类

这里只有声明没有实现。 C++的类,一般分为两部分,.h文件(头文件)负责声明,.cpp文件负责实现。

    // calculator.h
    class Calculator {
    public:
        int Add(int a, int b);
        int Sub(int a, int c);
        int Mul(int a, int c);
    };

🍒 2. C++插件类

对外暴露出api方法,既封装上述的Calculator类中需要对外暴露出来的方法。

🔑

这个类是ArkTs侧需要最看懂的C++类

其实,上述的C++类都是C++侧定义实现好的,其中的逻辑不需要过度关注。

一般为单例类

// plugin_calculator.h
class PluginCalculator:
public:
    // 下面三个方法是对Calculator类三个方法的封装
    static napi_value NapiAdd(napi_env env, napi_callback_info info);
    static napi_value NapiSub(napi_env env, napi_callback_info info);
    static napi_value NapiMul(napi_env env, napi_callback_info info);     


    // 在C++的插件类里面,一般都有一个Export方法。
    // 这个方法会定义需要暴露给napi的方法
    static void Export(napi_env env, napi_value exports);   
  

⭐ 简要分析一个NapiAdd这个函数的写法。其他更复杂的函数写法可以举一反三,触类旁通。

其中,第4步是逻辑核心~这里调用了对象持有的Calaulator实例的方法

// plugin_calculator.cpp

napi_value PluginCalculator::NapiAdd(napi_env env, napi_callback_info info) {

    size_t requireArgc = 2;

    size_t argc = 2;

    // 1. 定义参数列表
    napi_value args[2] = {nullptr};
    // 2. 校验参数个数是否正确
    if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok) {
        return nullptr;
    }

    // 3. 获取a,b参数的值
    int valueA, valueB;
    napi_get_value_int32(env, args[0], &valueA);
    napi_get_value_int32(env, args[1], &valueB);

    // 4. 调用api的方法计算结果,这里调用对象中的Calaulator实例的Add方法
    int res = this->calculator.Add(valueA, valueB);

    // 5. 定义返回值,并赋值
    napi_value sum;
    napi_create_int32(env, res, &sum);

    // 6. 返回结果
    return sum;
}

👉 再主要分析一个Export函数:

核心就是下面的一个数组的定义。

    // plugin_calculator.cpp
    void PluginCalculator::Export(napi_env env, napi_value value) {
        // ...

        // 描述需要暴露出来的api函数,并定义在ArkTs侧的函数名。
        napi_property_descriptor desc[] = {
            // 第一个参数是ArkTs中的函数名
            // 第三个参数是实际调用的C++的函数
            // 其他参数照写
            {"add", nullptr, PluginCalculator::NapiAdd, nullptr, nullptr, nullptr, napi_default, nullptr},
            {"sub", nullptr, PluginCalculator::NapiSub, nullptr, nullptr, nullptr, napi_default, nullptr},
            {"mul", nullptr, PluginCalculator::NapiMul, nullptr, nullptr, nullptr, napi_default, nullptr},
        };
        
        // ...
    }

🍒 3. 在napi_init文件中声明

将上述的定义的api接口暴露给ArkTs侧。

🔔 这里的napi_init.cpp文件名是自己自由定义的。只要写入CMakeLists.txt中即可。关于CMakeLists.txt的书写在随后会有详解。

📌 示例的napi_init.cpp

核心也只是Init函数的一行代码。

    // napi_int.cpp
    // ...
    EXTERN_C_START
    static napi_value Init(napi_env env, napi_value exports) {
        napi_property_descriptor desc[] = {/*...*/};

        if (napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc) != napi_ok) {
            OH_LOG_ERROR(LOG_APP, "Init", "napi_define_properties failed");
            return nullptr;
        }
        
        // ...
        
        PluginCalculator::Export(env, exports);    // 关键也就是这一行
                                                   // 把刚才写好的Export方法调用一下就行了
        // p->EXport()
        // ...
        return exports;
    }
    EXTERN_C_END
    // ...

⭐ 其实很简单,把刚才写好的Export方法调用一下就行了~

🍒 4. 编写index.d.ts文件——关键的一步

👋

刚才我们把CPP接口定义实现,并且暴露出来了。

但是ArkTs怎么调用呢?这是关键的一步——ArkTs函数定义

在ArkTs侧直接调用的接口,就是在这一步声明的。

其实很简单~在cpp/types/index.d.ts文件中定义

    // index.d.ts
    export const add: (a: number, b: number) => number;

    export const sub: (a: number, b: number) => number;

    export const mul: (a: number, b: number) => number;

❗需要注意的是: 这里定义的函数名,比如addsub等,必须是在上面napi_init文件中定义过的

在我们这个例子里面,就是在PluginCalculator::Export函数的列表中定义过的函数。

⚠️ 函数名一定要完全一样。参数类型和参数个数,以及返回值类型一定要对应上。否则会编译失败。

🍒 5. ArkTs使用

⚠️ 注意,cpp/types/.../目录下面(即index.d.ts的同一级目录)的oh-package.json5

    {
      "name": "libcal.so",
      "types": "./index.d.ts",
      "version": "",
      "description": "Please describe the basic information."
    }

👉 定义了:

  1. ArkTs类型定义文件,既index.d.ts

  2. 编译后的so库名。比如这里叫libcal.so

📌 使用方法:

    import { add, sub, mul } from 'libcal.so';    // 导入

    // ...

    const res1 = add(1, 2);    // 然后直接使用即可
    const res2 = sub(2, 1);
    const res3 = mul(2, 2);

    // ...

💡 深入思考

📌 在napi中我们需要暴露出C++的函数作为ArkTs调用的接口。

那么这个函数就有以下几种情况:

  • 普通函数
  • 类实例方法
  • 类静态方法
  • 异步函数?

考虑一下更加复杂的情况,函数的参数类型、返回值类型为:

  • 基本类型
  • 对象
  • 函数
  • 异步函数

方法function

普通方法和类静态方法

👋关于普通方法和类静态方法,转换调用都是一样的

     // 普通方法
    void foo(string str);

    // 类静态方法
    class FooClass:
    public:
        static void Foo(string str);

👉在上述小例子的第2步的Export方法中,更改为

      napi_property_descriptor desc[] = {
            // ...
            // 普通方法
            {"foo1", nullptr, foo, nullptr, nullptr, nullptr, napi_default, nullptr},
            // 类静态方法
            {"foo2", nullptr, FooClass::Foo, nullptr, nullptr, nullptr, napi_default, nullptr},
    };

📌 XComponent

🔔 是什么?

XComponent组件是绘制的载体。通常用于满足开发者较为复杂的自定义绘制需求,例如相机预览流的显示和游戏画面的绘制。

Native XComponent是XComponent组件提供在Native层的实例,可作为JS层和Native层XComponent绑定的桥梁。XComponent所提供的的NDK接口都依赖于该实例。

怎么用?

ArkTs 做什么?

  • 定义XComponent组件UI。
  • 在合适的时机,通过napi调用其他C++的接口。

C++ 做什么?

  • 注册XComponent的生命周期回调、注册XComponent的触摸、鼠标、按键等事件回调。

  • 在这些回调中进行初始化环境、获取当前状态、响应各类事件的开发。

  • 利用Native Window和EGL接口开发自定义绘制内容以及申请和提交Buffer到图形队列。

🎉 ArkTs侧

📌 ArkTs侧关于XComponent的使用很简单~

因为核心绘制的逻辑都在C++侧实现了!

我们需要做的事情就只是定义好napi接口,定义XComponent组件的UI,在合适的时机调用C++接口即可。极大地简化了前端的开发,前端甚至可以不用管绘制、手势之类的操作。

ArkTs侧在定义XComponent组件时,只需要定义一个id,指定正确的C++的so库名称即可。

只要C++侧代码没有错误,绘制等操作就不会有问题。

👇 下面是一个简单的在XComponent上绘制图形的例子,ArkTs侧:

@Component
export struct DrawingDemo {
  // ...
  @State xComponentContext: XComponentContext | undefined = undefined;

  build() {
        // ...
        XComponent({ 
            id: 'xcomponentId1',        // 自定义的id        
            type: 'surface',                        
            libraryname: 'librender',   // Native XComponent所在的模块编译出的so库名
          })
          .onLoad((xComponentContext: XComponentContext) => {
            this.xComponentContext = xComponentContext
          })
          .onDestroy(() => {})
          
        // ...
        Button('绘制')
          .onClick(() => {
            this.xComponentContext?.drawPattern() // cpp编写好的,只管调用,无需关心其逻辑
          })
        
        // ...
  }
}

关于XComponentContext接口

📌 XComponent提供了调用napi接口更加简便的一种方法

不需要在index.d.ts中定义导出的函数

可以直接定义一个ts的接口。在XComponent在onLoad时会自动在so库中寻找定义的函数

⚠️ 如果so库中不存在context接口定义的函数名或者函数的参数或返回值不对,都会导致编译时错误。这个请参考用法剖析的小例子。

👇 示例的接口定义如下:

export interface XComponentContext {
  drawPattern: () => void; // 对应napi导出的函数,与第2章例子index.d.ts中类似
}

C++侧

💡

C++侧的代码不多阐述了

这里只简要解释一下关于XComponent的生命周期和事件的代码。

在Native XComponent中,即C++侧关于XComponent的代码中,会定义多个生命周期函数以及各类事件监听

// 定义事件和监听
void OnSurfaceCreatedCB(OH_NativeXComponent *component, void *window) {
    // ...
}

void OnSurfaceChangedCB(OH_NativeXComponent *component, void *window) {
    // ...
}

void OnSurfaceDestroyedCB(OH_NativeXComponent *component, void *window) {
    // ...
}

void DispatchTouchEventCB(OH_NativeXComponent *component, void *window) {
    // ...
}

void DispatchMouseEventCB(OH_NativeXComponent *component, void *window) {
    // ...
}

void DispatchHoverEventCB(OH_NativeXComponent *component, bool isHover) {
   // ...
}

void OnFocusEventCB(OH_NativeXComponent *component, void *window) {
    // ...
}

void OnBlurEventCB(OH_NativeXComponent *component, void *window) {
   // ...
}

void OnKeyEventCB(OH_NativeXComponent *component, void *window) {
    // ...
}
// 注册事件和监听
void KMPPluginManager::RegisterCallback(OH_NativeXComponent * nativeXComponent) {
    mRenderCallback.OnSurfaceCreated = OnSurfaceCreatedCB;
    mRenderCallback.OnSurfaceChanged = OnSurfaceChangedCB;
    mRenderCallback.OnSurfaceDestroyed = OnSurfaceDestroyedCB;
    mRenderCallback.DispatchTouchEvent = DispatchTouchEventCB;
    OH_NativeXComponent_RegisterCallback(nativeXComponent, &mRenderCallback);

    mMouseCallback.DispatchMouseEvent = DispatchMouseEventCB;
    mMouseCallback.DispatchHoverEvent = DispatchHoverEventCB;
    OH_NativeXComponent_RegisterMouseEventCallback(nativeXComponent, &mMouseCallback);

    OH_NativeXComponent_RegisterFocusEventCallback(nativeXComponent, OnFocusEventCB);
    OH_NativeXComponent_RegisterKeyEventCallback(nativeXComponent, OnKeyEventCB);
    OH_NativeXComponent_RegisterBlurEventCallback(nativeXComponent, OnBlurEventCB);
}

🔔

这些生命周期函数和事件监听函数,其内部实现我们无需关注

只需要知道,在每个XComponet组件onLoad时,会自动执行对应的so库的api_init.cpp文件,并且注册这些函数。

此后的事情都是在C++代码里面发生的,我们只需管好前端的UI就好。

CMake

是什么?

💡

CMake是什么?

CMake是C++一个跨平台的构建工具,用于管理和构建软件项目。

类似前端的webpack和vite,Java的Maven和Gradle。

CMakeLists.txt是什么?

CMakeList是CMake构建系统中的文件,用于描述项目的结构和构建规则。

它通过编写一系列的命令和指令来定义项目的构建过程,实现项目的模块化管理和可扩展构建。

💡 怎么用?

👋

怎么用?写CMakeLists.txt就行了~

CMakeLists.txt 的一个例子

# the minimum version of CMake.
cmake_minimum_required(VERSION 3.4.1)
project(harmony_demo_napi_drawing)

# 路径别名
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

set(WPS_CODING_DIR "D:/HarmonyWPS_Core_Code/wps/Coding")

# 指定头文件目录
include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include
                    ${NATIVERENDER_ROOT_PATH}/plugin)

# 本地cpp源码
add_library(drawing SHARED
    napi_init.cpp
    plugin/plugin_manager.cpp
    plugin/plugin_render.cpp
    plugin/sample_bitMap.cpp
)

# harmony官方库
target_link_libraries(drawing PUBLIC
    libace_napi.z.so # 都是官方NDK的so库
    libace_ndk.z.so
    libnative_window.so
    libnative_drawing.so
    hilog_ndk.z.so
)

👻 链接so库

⭐ 官方so库

👋

如果需要打印日志,要使用这个函数OH_LOG_Print

那就要添加鸿蒙的官方的 so库了~

先添加hilog这个so库,一共有3个步骤。

👇步骤如下:

👉 1. 直接在CMakeList.txt文件中的target_link_libraries添加

# harmony官方库
target_link_libraries(drawing PUBLIC # 注意这个drawing是你自己命名的库名,要保持上下文一致
    # ...
    hilog_ndk.z.so                   # 在官方文档中查询,需要这个so库,直接添加之~
    # ...
)

或者,先起个别名 再链接

# ...
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         # 在官方文档中查询,需要这个so库,直接添加之,注意后缀去掉.so
)
# ...

# harmony官方库
target_link_libraries(drawing PUBLIC
    # ...
    ${hilog-lib}        # 添加这个别名
    # ...
)

👉 2. 检查是否导入了官方的头文件目录,即是否有下面的两行

# ...
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
# ...
include_directories(${NATIVERENDER_ROOT_PATH}
# ...
)

👉 3. 在需要使用的地方,添加hilog的头文件

#include <hilog/log.h>

🎉 就可以使用打印日志函数了~

OH_LOG_Print(LOG_APP, LOG_INFO, LOG_DOMAIN, TAG, "OnSurfaceCreated");
⭐ 本地so库

💡

如果别人写了C++代码,编译好后给你一个so库,你拷贝到你项目目录下

怎么使用这个so库里面的函数呢?

📢 首先,

假设你把这个so库放在这里 ~/Project/lib/mylib.so

假设你把头文件放在~/Project/include/mylib下面

📌 写一个CMakeList.txt的例子:

# ...
# 1. 给本地so库的目录和头文件目录起个别名
set(MY_LIB "~/Project/lib")
set(MY_INCLUDE "~/Project/include")


# ...
# 2. 指定头文件目录
include_directories(
    # ...
    ${MY_INCLUDE}
    # ...
)


# add_library(drawing SHARED init.cpp)

# 3. 链接到这个so库的目录
target_link_directories(drawing PUBLIC 
    # ...
    ${MY_LIB}
    # ...
)


# ...
# 4. 链接到这个库
target_link_libraries(drawing PUBLIC
    # ...
     mylib.so # 那么程序会在你链接的目录下自动寻找这个库 
    # ...
)

🎉 然后就可以使用这个so库里面的方法了~

#include<mylib/api.h>

Foo(); // 假设是api.h里面的一个函数

👻 编译本地Cpp代码

📌

怎么用CMake编译你写好的cpp代码呢?

核心是365.kdocs.cn/l/crGCtIOAg…方法

👇步骤如下:

📢 首先

假设你的鸿蒙项目cpp代码放在目录 -> src/main/cpp/plugin

而CMakeList.txt文件在 -> src/main/cpp/CMakeList.txt

📌 编写CMakeList.txt文件

# ...
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
# ...

# 1. 首先,把你的.h头文件目录加进来
include_directories(
    # ...
    ${NATIVERENDER_ROOT_PATH}/plugin # 头文件所在的目录
    # ...
)

# ...
# 2. 把cpp文件加进来编译,哪些要用就加哪些
add_library(drawing SHARED           # 打包后叫做libdrawing.so
    napi_init.cpp                    # 这是napi的入口文件,名字不用管

    plugin/plugin_manager.cpp        # 假设是plugin下面的cpp文件
    plugin/plugin_render.cpp         # 同上
) 

# 3. 最后记得把代码里面所用到的so库,链接进来(包括官方so库和本地so库)
target_link_libraries(drawing PUBLIC # 保持drawing名字与上面一样
    libace_napi.z.so
    libnative_window.so
    # ...
)

🎉 现在就可以编译了~

编译完就会有一个libdrawing.so文件,.so为linux下的动态链接库

windows下的动态链接库后缀为.ddl