鸿蒙 C/C++ 开发笔记

最近在做 C/C++ 跨平台开发,需要适配鸿蒙(Next),踩了不少坑(可能跟笔者之前对 Node-API 不熟悉有关);

鸿蒙 NAPI 虽然官方说跟 Node-API 有差异,但 API 基本一致;总体来说,跟 NodeJS C/C++ AddOn 开发模式很像,下面详细展开。

构建脚本

这点跟 Android NDK 差不多,直接利用官方 CMake 工具链:

#!/bin/sh
NDK_ROOT="~/Library/OpenHarmony/Sdk/13/native"

BUILD_DIR=../build.HarmonyOS
rm -rf ${BUILD_DIR}
mkdir -p ${BUILD_DIR}
cd ${BUILD_DIR}

function build4harmony {
  ABI=$1
  BUILD_TYPE=$2

  ROOT_DIR=$(pwd)
  ABI_BUILD_DIR=${ROOT_DIR}/${ABI}
  OUTPUT_DIR=${ROOT_DIR}/output/libs/${ABI}
  mkdir -p ${ABI_BUILD_DIR}
  mkdir -p ${OUTPUT_DIR}

  cmake .. \
    -DOHOS_PLATFORM=OHOS \
    -DOHOS_ARCH=${ABI} \
    -DCMAKE_RUNTIME_OUTPUT_DIRECTORY=${OUTPUT_DIR} \
    -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=${OUTPUT_DIR} \
    -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY=${OUTPUT_DIR} \
    -B${ABI_BUILD_DIR} \
    -DCMAKE_TOOLCHAIN_FILE=$NDK_ROOT/build/cmake/ohos.toolchain.cmake \

  cmake --build . --config ${BUILD_TYPE}

  pushd ${ABI_BUILD_DIR}
    make -j5
  popd

  rm -rf ${ABI_BUILD_DIR}
}

build4harmony armeabi-v7a Release

可以看到,除了 DOHOS_PLATFORMDCMAKE_TOOLCHAIN_FILE 以外,其他基本跟 Android 一致。

另外,CMakeLists.txt 的规则也跟其他 C/C++ 项目并无二致,这里不再赘述。

C 函数动态注册

定义 TS 接口

首先要在 src/main/cpp/types/xxx/Index.d.ts 里定义需要暴露给 TypeScript 的接口:

export const init: (root: string) => boolean;
export const release: () => void;

注册 C/C++ 接口

首先声明 C/C++ 接口实现:

static napi_value tsFunc(napi_env env, napi_callback_info info)
{
    //...
}

可以看到,参数和返回值都是一模一样:

  • napi_env 类似 JNIEnv,是 JS 虚拟机的句柄;

  • napi_callback_info 用于获取 TypeScript 传过来的参数;

  • napi_value 为 JS 数据类型的 C/C++ 封装,后面处理参数和返回值都会用到;

具体怎么读取参数和返回值稍后再展开;

然后动态注册 C 函数和 TS 接口的绑定:

EXTERN_C_START
static napi_value RegisterFuncs(napi_env env, napi_value exports)
{
    napi_property_descriptor desc[] = {
        {"init", nullptr, Init, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"release", nullptr, Release, nullptr, nullptr, nullptr, napi_default, nullptr},
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

static napi_module xxxModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = RegisterFuncs,
    .nm_modname = "xxx",
    .nm_priv = (reinterpret_cast<void *>(0)),
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterXXXModule(void) { napi_module_register(&xxxModule); }

可以看到,和 Java/Lua 动态批量注册 C 接口很类似:都是声明一个接口签名数组,然后和函数指针绑定。

其中 __attribute__((constructor)) 告诉编译器在进入 main() 前自动执行该函数。

读取参数列表

前面我们提到 napi_callback_info 可用于获取 TypeScript 传过来的参数,其实主要就是通过 napi_get_cb_info 这个接口:

  • 第一次通过 argc 指针获取参数个数(argvNULL);
  • 第二次传入 argv 指针读取参数列表。
using Params = std::tuple<size_t, napi_value*>;

Params readParams(napi_env env, napi_callback_info info)
{
    size_t argc = 0;
    auto status = napi_get_cb_info(env, info, &argc, nullptr, nullptr, nullptr);
    
    auto argvLen = sizeof(napi_value) * argc;
    auto argv = reinterpret_cast<napi_value *>(std::malloc(argvLen + 1));
    std::memset(argv, 0, argvLen + 1);
    status = napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
    
    return {argc, argv};
}

static napi_value tsFunc(napi_env env, napi_callback_info info)
{
    auto [argc, argv] = readParams(env, info);
    //...
}

参数解析及返回值构造

与 JNI 里面定义各种乱七八糟的类型(jstring/jlong/jobject)不同,这里只有一种核心的数据类型:napi_value

  • 读取参数,TS 类型转 C 类型就是 napi_get_value_xxx

  • 返回值,C 类型转 TS 类型就是 napi_create_xxx

下面对常用 C 类型 和 TS 的互转做下封装:

bool

bool napiValue2bool(napi_env env, napi_value nv)
{
    bool b;
    auto status = napi_get_value_bool(env, nv, &b);
    return b;
}

napi_value bool2NapiValue(napi_env env, bool b)
{
    napi_value v;
    auto status = napi_get_boolean(env, b, &v);
    return v;
}

int

int napiValue2int(napi_env env, napi_value nv)
{
    int i;
    auto status = napi_get_value_int32(env, nv, &i);
    return i;
}

napi_value int2NapiValue(napi_env env, int i)
{
    napi_value v;
    auto status = napi_create_int32(env, i, &v);
    return v;
}

long

long napiValue2long(napi_env env, napi_value nv)
{
    long l;
    auto status = napi_get_value_int64(env, nv, &l);
    return l;
}

napi_value long2NapiValue(napi_env env, long l)
{
    napi_value v;
    auto status = napi_create_int64(env, l, &v);
    return v;
}

double

double napiValue2double(napi_env env, napi_value nv)
{
    double d;
    auto status = napi_get_value_double(env, nv, &d);
    return d;
}

napi_value double2NapiValue(napi_env env, double d)
{
    napi_value v;
    auto status = napi_create_double(env, d, &v);
    return v;
}

string

这里有点特殊,由于 C 没有内置的 string 类型,只有 char 数组,必须指定数组长度; 所以 TS 转 C 类型需要调用两次:

  • 首先 char * 参数传 NULL,先读取长度;

  • 根据长度动态分配内存,然后用该指针接收转换后的 C 字符串。

const char *napiValue2chars(napi_env env, napi_value nv)
{
    size_t len;
    auto status = napi_get_value_string_utf8(env, nv, NULL, 0, &len);
    auto cStrLen = len * sizeof(char);
    auto cStr = reinterpret_cast<char *>(std::malloc(cStrLen + 1));
    std::memset(cStr, 0, cStrLen + 1);
    status = napi_get_value_string_utf8(env, nv, cStr, len + 1, &len);
    return cStr;
}

napi_value chars2NapiValue(napi_env env, const char *c)
{
    if (c == NULL) {
        c = "";
    }
    napi_value v;
    auto status = napi_create_string_utf8(env, c, strlen(c), &v);
    return v;
}

数组

我们知道 C 的数组涉及到动态分配内存,需事先知道长度:

  • napi_get_array_length 可以读取 TS 数组长度:

  • napi_create_array_with_length 可以创建指定长度的 TS 数组;

知道长度后,就可以遍历数组,逐个做转换:

  • napi_get_element 用于读取 TS 数组指定位置的 napi_value

  • napi_set_element 用于设置 TS 数组指定位置的元素;

  • 最后再利用上面已有基本数据类型转换方法处理;

下面以 byte 数组 为例:

const byte *napiValue2byteArray(napi_env env, napi_value nv, size_t len)
{
    if (len <= 0) {
        return NULL;
    }
    auto byteArrayLen = len * sizeof(byte);
    auto byteArray = reinterpret_cast<byte *>(std::malloc(byteArrayLen + 1));
    std::memset(byteArray, 0, byteArrayLen + 1);
    int status;
    for (int i = 0; i < len; i++)
    {
        napi_value vByte;
        status = napi_get_element(env, nv, i, &vByte);
        byteArray[i] = napiValue2int(env, vByte);
    }
    return byteArray;
}

napi_value byteArray2NapiValue(napi_env env, const byte *byteArray, size_t len)
{
    napi_value v;
    auto status = napi_create_array_with_length(env, len, &v);
    for (int i = 0; i < len; i++)
    {
        status = napi_set_element(env, v, i, int2NapiValue(env, byteArray[i]));
    }
    return v;
}

跨线程数据回调

熟悉 JNI 的应该知道,JNIEnv 是跟线程相关联的,JNI 中跨线程回调需要先 attach 到当前线程,否则可能 crash:

static inline void *runInCurrentEnv(JavaVM *vm, const std::function<void *(JNIEnv *env)> &task)
{
    if (vm == nullptr) {
        return nullptr;
    }
    JNIEnv *env;
    vm->AttachCurrentThread(&env, nullptr);
    if (env == nullptr) {
        return nullptr;
    }
    void *t = task(env);
    return t;
}

同理,鸿蒙 native 开发(NodeJS 一样)也有类似问题:

TS/JS 默认是单线程模型,而 JS 支持 worker(一个 worker 对应一个 VM 上下文),通过它可实现跨线程交互。

鸿蒙 NAPI(NodeJS)提供了丰富的 worker 相关 API,先看几个概念:

  • AsyncWorker:可以类比为 Android 的 AsyncTask,是对 JS 后台异步任务的抽象,提供了任务执行开始和完成的回调;

  • ThreadSafeFunction: 普通 JS 函数只能主线程执行,而这个可以跨线程执行;

下面我们以日志回调为例详细展开。

数据结构定义

我们首先定义一个结构体,持有 worker 对象 napi_async_work 和需要跨线程回调的函数对象 napi_threadsafe_function

typedef struct
{
    napi_async_work tsWork;
    napi_threadsafe_function tsWorkFunc;
    int logLevel;
    const char *logContent;
} TSLogWorkData;

同时还持有相关业务字段(日志级别和内容)。

另外,跟 JNI 类似,为方便后续跨线程调用相关 napi 接口,我们声明一个 napi_envnapi_ref 的静态成员,分别持有 VM 对象和 TS 层的回调:

static napi_env sNapiEnv;
static napi_ref sTsLogCallbackRef;

持有 TS 层回调

首先是在 C 入口函数内创建对 TS 层回调的引用(跟 JNI 引用管理类似),并转发到另外的 C 回调函数:

static napi_value LogSetCallback(napi_env env, napi_callback_info info)
{
    auto [argc, argv] = readParams(env, info);

    auto vLogCallback = argv[0];
    int status;
    if (vLogCallback == NULL)
    {
        sNapiEnv = NULL;
        status = napi_delete_reference(env, sTsLogCallbackRef);
        xxx_log_set_callback(NULL);
    }
    else
    {
        sNapiEnv = env;
        status = napi_create_reference(env, vLogCallback, 1, &sTsLogCallbackRef);
        xxx_log_set_callback(engineLogCallback);
    }

    std::free(static_cast<void *>(argv));
    return int2NapiValue(env, napi_ok);
}

初始化 worker 线程

在上面指定的 C 回调中,初始化 worker:

  • 组装结构体;

  • 通过 napi_get_reference_value 创建对 TS 层回调的引用(跟 JNI 类似);

  • 通过 napi_create_threadsafe_function 创建 worker 回调对象(通过它最终触发 TS 回调);

  • 通过 napi_create_async_work 创建 worker,类似 Android 的 AsyncTask,这里会指定任务执行各个阶段的回调函数;

  • 通过 napi_queue_async_work 将刚创建的 worker 丢到队列;

static void engineLogCallback(int level, const char *content)
{
    if (sNapiEnv == NULL || content == NULL) {
        return;
    }

    auto tSLogWorkData = reinterpret_cast<TSLogWorkData *>(std::malloc(sizeof(TSLogWorkData)));
    tSLogWorkData->tsWork = NULL;
    tSLogWorkData->tsWorkFunc = NULL;
    tSLogWorkData->logLevel = level;
    auto len = strlen(content);
    tSLogWorkData->logContent = reinterpret_cast<char *>(std::malloc(len + 1));
    std::memset(static_cast<void *>(std::decay_t<char *>(tSLogWorkData->logContent)), 0, len + 1);
    std::strncpy(std::decay_t<char *>(tSLogWorkData->logContent), content, len);
    std::free(static_cast<void *>(std::decay_t<char *>(content)));

    auto vWorkName = chars2NapiValue(sNapiEnv, "NAPI_LOG_CALLBACK_WORK");

    napi_value vTsCallback;
    auto status = napi_get_reference_value(sNapiEnv, sTsLogCallbackRef, &vTsCallback);

    status = napi_create_threadsafe_function(sNapiEnv, vTsCallback, NULL, vWorkName, 0, 1, NULL, NULL, NULL,
                                             OnLogWorkCallTS, &(tSLogWorkData->tsWorkFunc));
    status = napi_create_async_work(sNapiEnv, NULL, vWorkName, OnLogWorkExecute, OnLogWorkComplete, tSLogWorkData,
                                    &(tSLogWorkData->tsWork));
    status = napi_queue_async_work(sNapiEnv, tSLogWorkData->tsWork);
}

通过 worker 回调 TS

首先处理上面创建 worker 传入的两个回调。

既然是跨线程操作,肯定少不了锁的操作,所以这里看似繁琐的操作其实可以理解为在 worker 中安全(同步)地调用我们前面创建的 worker function 对象:

  • napi_acquire_threadsafe_function 类似加锁;

  • napi_call_threadsafe_function 通过线程安全的方式调用 function;

  • 最后通过 napi_release_threadsafe_functionnapi_delete_async_work 释放资源;

static void OnLogWorkExecute(napi_env env, void *data)
{
    auto tSLogWorkData = reinterpret_cast<TSLogWorkData *>(data);
    auto status = napi_acquire_threadsafe_function(tSLogWorkData->tsWorkFunc);
    status = napi_call_threadsafe_function(tSLogWorkData->tsWorkFunc, tSLogWorkData, napi_tsfn_blocking);
}

static void OnLogWorkComplete(napi_env env, napi_status status, void *data)
{
    auto tSLogWorkData = reinterpret_cast<TSLogWorkData *>(data);
    status = napi_release_threadsafe_function(tSLogWorkData->tsWorkFunc, napi_tsfn_release);
    status = napi_delete_async_work(env, tSLogWorkData->tsWork);
}

调用 napi_call_threadsafe_function 后,最终会触发起初 napi_create_threadsafe_function 指定的回调,这里即为最终 C 回调 TS 的地方:

  • 首先将结构体透传的 C 类型业务数据转换为 TS 类型;

  • 通过 napi_get_reference_value 拿到 TS 层回调的引用;

  • 通过 napi_get_global 拿到 TS 的 global 对象,然后通过 napi_call_function 调用 TS 层函数。

static void OnLogWorkCallTS(napi_env env, napi_value ts_callback, void *context, void *data)
{
    if (env == NULL || ts_callback == NULL || data == NULL) {
        return;
    }

    auto tSLogWorkData = reinterpret_cast<TSLogWorkData *>(data);

    size_t argc = 2;
    napi_value argv[2];
    argv[0] = int2NapiValue(env, tSLogWorkData->logLevel);
    argv[1] = chars2NapiValue(env, tSLogWorkData->logContent);

    napi_value vGlobal;
    auto status = napi_get_global(env, &vGlobal);

    napi_get_reference_value(sNapiEnv, sTsLogCallbackRef, &ts_callback);

    status = napi_call_function(env, vGlobal, ts_callback, argc, argv, NULL);

    std::free(static_cast<void *>(std::decay_t<char *>(tSLogWorkData->logContent)));
    std::free(static_cast<void *>(tSLogWorkData));
}

可以看到,虽然过程稍显繁琐,但其实跟我们的业务过于简单也有关系(只有透传数据逻辑);

因为 worker 本身是用于多线程异步执行耗时任务而设计的,严格的生命周期管理便于维护复杂业务逻辑。

参考: