HarmonyOS 7 PixelBridge 原生库适配实录 01:ArkTS × Node-API × CMake 打通 libyuv 首条调用链路【鸿蒙心迹】
上一条 ReconRoom 连载已经完整结束,这次我换了一个完全不同的工程问题:不再做 3D 重建,而是处理 HarmonyOS 应用里如何把一套成熟 C/C++ 三方库真正接进 ArkTS 工程。
项目叫 PixelBridge。第一阶段选 libyuv,不是因为要立刻做复杂的视频链路,而是它很适合暴露 Native 适配里那些平时容易被隐藏的问题:CMake 怎么组织、so 怎么加载、Node-API 模块怎么注册、ArkTS 怎么声明类型、错误到底在哪一层抛出,以及“编译成功”和“运行时真的能调到 C++”是不是同一件事。
这一轮只做一件事:先把最短链路打通。
ArkTS
→ libpixelbridge.so
→ Node-API
→ PixelBridge.cpp
→ libyuv
当前探测任务固定为 native_probe_20261002_01。真机侧最终状态是 READY,ABI 为 arm64-v8a,Native 模块 libpixelbridge.so 能正常加载,Node-API 暴露 2 个方法,模块加载耗时 14 ms,第一次 getNativeInfo() 调用耗时 0.42 ms。

一、我没有一上来就写图像转换
一开始如果直接把 PixelMap → libyuv → I420 全写进去,一旦报错,排查范围会非常大。
可能是:
CMake 没把 libyuv 编进来
so 名字不一致
Node-API 模块没有注册
index.d.ts 声明不对
ArkTS import 路径不对
C++ 参数解析失败
libyuv 真正的转换调用失败
这些问题最后在页面上很可能都只表现成一句“调用失败”。
所以 PixelBridge 的 01 没有急着处理图片,先做一个 Native Probe 页面。页面只显示五组值:
probeId: native_probe_20261002_01
module: libpixelbridge.so
abi: arm64-v8a
backend: libyuv
status: READY
如果这条链路稳定,后面所有图像逻辑都能在一个已经确认可用的 Native 桥上继续长。
这个做法也避免了一个常见误判:DevEco Studio 能编过,不等于应用启动时一定能把模块加载起来。
二、CMake 先把边界划清:三方库和桥接层不是一回事
PixelBridge 当前的 Native 目录没有把所有代码堆在一个 cpp 文件里。
entry/src/main/cpp/
├── CMakeLists.txt
├── napi_init.cpp
├── PixelBridge.cpp
├── PixelBridge.h
├── types/
│ └── libpixelbridge/
│ └── index.d.ts
└── third_party/
└── libyuv/
├── include/
└── source/
我把 libyuv 当成第三方实现,把 PixelBridge.cpp 当成自己的适配层。
这样后面无论升级 libyuv、换预编译静态库,还是把部分逻辑换成系统 Image_NativeModule,ArkTS 面向的模块名都不需要变化。
第一段代码解决的是“链接边界不清”的问题。示意 CMake 如下:
cmake_minimum_required(VERSION 3.5.0)
project(pixelbridge)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include_directories(
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/libyuv/include
)
add_library(yuv STATIC
third_party/libyuv/source/convert.cc
third_party/libyuv/source/convert_from.cc
third_party/libyuv/source/planar_functions.cc
third_party/libyuv/source/row_common.cc
)
add_library(pixelbridge SHARED
napi_init.cpp
PixelBridge.cpp
)
find_library(ace_napi libace_napi.z.so)
find_library(hilog_ndk libhilog_ndk.z.so)
target_link_libraries(pixelbridge
PUBLIC
yuv
${ace_napi}
${hilog_ndk}
)
真实项目里 libyuv 的源文件列表应以当前引入版本和自己的构建方式为准,我这里更在意的是结构:yuv 是内部静态目标,pixelbridge 才是应用真正加载的共享库。
这么做还有一个实际好处:后面如果 Native 侧 crash,符号栈更容易区分“桥接代码”和“三方库内部代码”。
三、Node-API 模块注册先保持极小
HarmonyOS Node-API 用于 ArkTS/JS 与 C/C++ 之间的交互。当前官方文档仍然明确把它定位为跨语言桥接机制,适合把性能敏感或已有 C/C++ 能力暴露给 ArkTS。
PixelBridge 第一版只导出两个方法:
getNativeInfo()
addPlaneChecksum()
getNativeInfo() 用来确认 Native 模块已经真正执行;addPlaneChecksum() 则只是一个轻量的字节累加测试,为第二篇传图像 Buffer 做准备。
这段代码解决的是“模块编译出来了,但 ArkTS 找不到方法”的问题:
#include <node_api.h>
#include "PixelBridge.h"
static napi_value Init(napi_env env, napi_value exports)
{
napi_property_descriptor methods[] = {
{
"getNativeInfo",
nullptr,
GetNativeInfo,
nullptr,
nullptr,
nullptr,
napi_default,
nullptr
},
{
"addPlaneChecksum",
nullptr,
AddPlaneChecksum,
nullptr,
nullptr,
nullptr,
napi_default,
nullptr
}
};
napi_status status = napi_define_properties(
env,
exports,
sizeof(methods) / sizeof(methods[0]),
methods
);
if (status != napi_ok) {
return nullptr;
}
return exports;
}
这里我没有把十几个未来可能用到的方法一次性全部注册进去。
接口面越早膨胀,后面越难改。01 只验证模块注册、调用和错误边界,图像相关方法等到第二篇真正需要时再加。
四、Native 返回值必须能证明“代码真的走到了 C++”
如果 getNativeInfo() 只返回一个固定字符串 ok,价值不大。
我让它返回运行时能辨认当前构建的字段:
napi_value GetNativeInfo(napi_env env, napi_callback_info info)
{
napi_value result = nullptr;
napi_create_object(env, &result);
napi_value module = nullptr;
napi_create_string_utf8(
env,
"libpixelbridge.so",
NAPI_AUTO_LENGTH,
&module
);
napi_value backend = nullptr;
napi_create_string_utf8(
env,
"libyuv",
NAPI_AUTO_LENGTH,
&backend
);
napi_value abi = nullptr;
napi_create_string_utf8(
env,
"arm64-v8a",
NAPI_AUTO_LENGTH,
&abi
);
napi_set_named_property(env, result, "module", module);
napi_set_named_property(env, result, "backend", backend);
napi_set_named_property(env, result, "abi", abi);
return result;
}
arm64-v8a 在当前 Demo 里是本轮构建目标,不应该理解成所有 HarmonyOS 设备只支持这一种 ABI。
后面做多 ABI 包装时,这个值会改成构建宏或运行时信息,而不是手写常量。第一期先保留它,是为了让页面和 HiLog 能快速确认“当前安装的到底是哪份构建”。
五、ArkTS 侧不直接写 any,类型声明就是桥的一部分
Native 方法能被调用以后,下一步是 index.d.ts。
一开始我图省事,ArkTS 侧直接把模块当 any 用,几分钟内确实能跑。真正改第二个接口时就开始麻烦:字段名拼错、返回值类型改了、页面编译期都看不出来。
所以当前声明写成:
export interface NativeInfo {
module: string
backend: string
abi: string
}
export const getNativeInfo: () => NativeInfo
export const addPlaneChecksum:
(buffer: ArrayBuffer) => number
页面里再通过:
import pixelBridge from 'libpixelbridge.so'
const start = performance.now()
const info = pixelBridge.getNativeInfo()
const cost = performance.now() - start
this.moduleName = info.module
this.backend = info.backend
this.abi = info.abi
this.firstCallCost = cost
this.status = 'READY'
这段代码解决的是“桥接接口变化没有编译期约束”的问题。
index.d.ts 不只是为了 IDE 自动补全,它其实是 ArkTS 和 Native 之间的一份接口契约。后面 PixelMap、转换结果和异步 Promise 都会继续从这里演进。
六、模块加载失败和 Native 方法失败要分开记录
01 里我专门把日志分成两层:
MODULE_LOAD
NAPI_CALL
如果 import pixelBridge from 'libpixelbridge.so' 根本失败,页面不会进入 Probe 逻辑;如果模块已经加载但 getNativeInfo() 报错,那是另一条路径。
当前 HiLog 记录如下:
PixelBridge module loaded: libpixelbridge.so
napi init success, methods=2
probeId=native_probe_20261002_01 abi=arm64-v8a backend=libyuv
getNativeInfo cost=0.42ms status=READY

这张开发图里我没有放任何图片转换代码,因为第一篇验收的重点不是“libyuv 能不能转格式”,而是 Native 模块能不能稳定被 ArkTS 加载和调用。
这一步如果不稳定,第二篇直接传 PixelMap 只会让排查更乱。
七、我刻意做了四组失败测试
成功截图只能证明一条路径。
为了确认桥接真的稳定,我又反向测了四种情况。
第一组,把 ArkTS import 名称改错。预期结果是模块加载阶段失败,而不是进入 getNativeInfo() 再报错。
第二组,注释掉 napi_define_properties() 中一个方法。预期结果是类型声明还在,但运行时调用明确失败。这个测试提醒我:index.d.ts 和 C++ 导出表必须一起维护,不能只改一边。
第三组,把 CMake 里 yuv 链接移除。虽然 Probe 方法本身暂时不调用转换函数,但后续 PixelBridge.cpp 一旦引用 libyuv 符号,链接阶段就应该直接失败,而不是把问题拖到手机运行。
第四组,连续进出 Native Probe 页面 20 次。模块不应该因为页面生命周期被重复注册,页面只重复调用方法,不重复创建所谓的“Native 单例”。
这几组测试结束以后,第一篇才算达到我想要的边界:编译、链接、模块加载、方法注册、ArkTS 调用分别有可观察证据。
八、手机页只展示桥接状态,不伪装成“已经完成图像能力”
运行图最终保持得很克制:

统一数据是:
probeId: native_probe_20261002_01
status: READY
module: libpixelbridge.so
abi: arm64-v8a
backend: libyuv
Node-API methods: 2
module load: 14 ms
first call: 0.42 ms
这个页面没有放“图片转换成功”的绿色对勾,因为当前还没有真正传 PixelMap。
我更愿意让每一期只证明它自己解决的工程问题。
下一篇会在这条桥上继续往前走:从 Image Kit 拿到 PixelMap,通过 OH_PixelmapNative_ConvertPixelmapNativeFromNapi() 进入 Native,读取 width / height / pixelFormat / rowStride,再把 RGBA 数据送给 libyuv。
真正麻烦的地方也会从“模块能不能调用”变成“内存里的像素到底是什么布局”。
九、把“编译成功”拆成四个检查点,定位速度会快很多
Native 适配最容易浪费时间的地方,是把所有失败都叫作“编译问题”。
这一轮我最后把链路拆成四层:
COMPILE
→ LINK
→ MODULE_LOAD
→ NAPI_CALL
COMPILE 只回答头文件和语法能不能过;LINK 负责确认 libpixelbridge.so 能不能把依赖符号解析完整;MODULE_LOAD 才是应用启动以后系统是否能把 so 装载进进程;最后 NAPI_CALL 才代表 ArkTS 真正调用到了导出的 C++ 方法。
这四层的错误处理也不一样。
头文件路径错了,DevEco 构建阶段就应该失败;漏链接 libyuv,应该在链接阶段暴露;模块名不一致,运行时 import 才会出现问题;参数解析错误,则必须进入方法以后再返回业务错误。
我给每一层都加了独立日志前缀,避免后面只看到一句 PixelBridge failed:
[BUILD] target=pixelbridge abi=arm64-v8a
[LOAD] module=libpixelbridge.so
[NAPI] methods=2
[CALL] getNativeInfo cost=0.42ms
这样做以后,排查路径变得非常机械:先看失败发生在哪一层,再决定去看 CMake、包结构还是 NAPI 代码。真正做三方库迁移时,这种分层比多写几个 try/catch 更有价值。
十、模块名、目标名和 ArkTS import 必须统一管理
第一版里我曾经出现过一个很低级但很真实的问题:CMake 目标叫 pixelbridge,生成的是 libpixelbridge.so,Node-API 模块的 nm_modname 却写成了另一个名字,ArkTS 侧 import 又复制了一份字符串。
三处字符串都能单独修改,意味着三处都可能漂移。
现在工程把“对外模块名”当成一个稳定契约处理:
CMake target: pixelbridge
shared library: libpixelbridge.so
NAPI module: pixelbridge
ArkTS import: libpixelbridge.so
其中 so 文件名和 Node-API 模块名不是同一个概念,但它们在工程里有明确映射,不能由页面开发者随意改。
发布包里如果后面同时出现 libpixelbridge.so、libpixelbridge_debug.so、libpixelbridge_v2.so 这类名字,业务层很快会失控。我的做法是保持外部入口固定,把版本和调试信息放进 getNativeInfo() 返回值以及构建配置里,而不是通过改 so 名做版本管理。
这对连续升级三方库很重要。ArkTS 永远 import 同一个模块,Native 内部到底链接哪一个 libyuv revision,由构建层决定。
十一、错误返回不要把 napi_status 直接扔给页面
Node-API 自己有一套 napi_status,libyuv 也有自己的返回值,后面 Image_NativeModule 还会再带一套 Image_ErrorCode。
如果三种错误直接透传给 ArkTS,页面很快会出现这种代码:
if code == napi_xxx ...
else if code == IMAGE_xxx ...
else if code == -1 ...
所以第一篇就先定了一层 PixelBridge 自己的错误语义:
PB_MODULE_NOT_READY
PB_INVALID_ARGUMENT
PB_NATIVE_CALL_FAILED
PB_BACKEND_UNAVAILABLE
底层原始错误仍然写进 HiLog,便于工程排查;ArkTS 只收到稳定的桥接层错误。
这个分层看起来有点“提前设计”,但第二篇开始就会立刻用上。比如 PixelMap 转换失败时,页面不需要知道 OH_PixelmapNative_ReadPixels() 返回了哪一个枚举值,只需要知道是“像素读取失败”还是“libyuv 转换失败”。
真正需要原始 code 的地方是日志和诊断页,不是普通业务 UI。
十二、页面销毁不会卸载 Native 模块,状态不能绑在组件上
我还专门验证了一件很容易被误解的事:从 Native Probe 页面返回,并不等于 libpixelbridge.so 被卸载。
Node-API 模块跟进程模块生命周期有关,而 ArkUI 页面只是一个业务组件。页面反复创建、销毁,不应该触发一次又一次模块注册。
因此页面里只保留展示状态:
moduleName
abi
backend
firstCallCost
status
真正属于 Native 模块的初始化状态不存进 @State。页面再次进入时只重新执行 Probe,拿一份新的运行快照。
这一点对后面的 Native Buffer 尤其重要。如果把 Buffer 所有权跟页面绑死,页面销毁时释放一遍、异步任务回调时再释放一遍,就会变成双重释放风险。
第一期还没有 Buffer,但生命周期边界已经先划出来了:UI 可以短命,Native 资源必须有独立所有者。
十三、14 ms 和 0.42 ms 我怎么测的
最后说一下截图里的两个耗时。
14 ms 是当前安装包冷启动后,从业务初始化开始到 PixelBridge 模块准备完成的观测值;0.42 ms 是第一次 getNativeInfo() 方法调用的业务计时。
这两个数字都不是平台性能结论,也不是 libyuv 性能数据。
我没有把它们写成“Node-API 只有 0.42 ms 开销”,因为里面混合了当前设备、Debug 构建、计时位置和对象创建成本。它们的用途只是建立 PixelBridge 自己的回归基线。
后面每一期继续扩展 Native 方法时,我会保留同一组 Probe。如果某次改动以后模块加载突然从十几毫秒涨到几百毫秒,或者一个不做实际计算的方法开始明显变慢,至少能第一时间知道桥接层发生了变化。
这也是我没有把 Probe 页面删掉的原因。它不属于最终产品功能,却是这个 Native 适配系列很实用的一张工程体检表。
十四、第一篇暂时不做的三件事
这一轮我还主动留了三个空白:不做 PixelMap 传递、不做异步任务、不做 Native Buffer 长时间持有。
这不是功能没写完,而是为了让边界可验证。三方库适配很容易一上来把构建、跨语言、线程和内存全部揉在一起,最后任何一层出错都只能重新猜。现在 01 只把构建和桥接固定下来,下一期再进入像素内存,后续再进入异步和资源管理。每一期都保留一条可独立回归的基线,这个系列才不会变成一次性 Demo。
参考资料
- HarmonyOS Node-API 跨语言调用:https://developer.huawei.com/consumer/cn/doc/doccenter-games/games-universal-using-napi-interaction-0000002411166425
- DevEco Studio 26.0.0 新特性:https://developer.huawei.com/consumer/en/doc/harmonyos-releases/deveco-studio-new-features-2600
- libyuv 上游项目:https://chromium.googlesource.com/libyuv/libyuv/
更多推荐



所有评论(0)