鸿蒙 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_PLATFORM
和 DCMAKE_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_env
和 napi_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_function
和napi_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 本身是用于多线程异步执行耗时任务而设计的,严格的生命周期管理便于维护复杂业务逻辑。
参考: