鸿蒙 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 Init(napi_env env, napi_callback_info info)
{
    //...
}

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

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

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

  • napi_callback_info 用于获取调用栈信息,可用来打印异常;

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

然后动态注册 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() 前自动执行该函数。

处理参数和返回值

与 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;
    napi_status status = napi_get_value_bool(env, nv, &b);
    return b;
}

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

int

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

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

long

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

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

double

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

napi_value double2NapiValue(napi_env env, double d)
{
    napi_value v;
    napi_status 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)
{
    napi_status status;
    size_t len;
    status = napi_get_value_string_utf8(env, nv, NULL, 0, &len);
    char *cStr = reinterpret_cast<char *>(malloc(len + 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;
    napi_status 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;
    byte *byteArray = reinterpret_cast<byte *>(malloc(len * sizeof(const byte) + 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;
    int 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)
{
    napi_value *argv = readParams(env, info, 1);

    napi_value 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);
    }

    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;

    TSLogWorkData *tSLogWorkData = reinterpret_cast<TSLogWorkData *>(malloc(sizeof(TSLogWorkData)));
    tSLogWorkData->tsWork = NULL;
    tSLogWorkData->tsWorkFunc = NULL;
    tSLogWorkData->logLevel = level;
    tSLogWorkData->logContent = reinterpret_cast<char *>(malloc(strlen(content) + 1));
    strcpy(const_cast<char *>(tSLogWorkData->logContent), content);
    free(static_cast<void *>(const_cast<char *>(content)));

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

    napi_value vTsCallback;
    napi_status 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)
{
    TSLogWorkData *tSLogWorkData = (TSLogWorkData *)data;
    napi_status 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)
{
    TSLogWorkData *tSLogWorkData = (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;

    TSLogWorkData *tSLogWorkData = (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;
    napi_status 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);

    free(static_cast<void *>(const_cast<char *>(tSLogWorkData->logContent)));
    free(static_cast<void *>(tSLogWorkData));
}

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

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

参考: