上一条 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/
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐