我在前一篇里梳理了自定义人脸识别模型的基础流程,这次把焦点放到一个比较硬核的点:如何通过 OH_NativeXComponent 在 HarmonyOS 6 里自己动手画画面。人脸识别跑通了模型还不够,识别结果怎么实时显示、怎么在原生代码里高效绘制,这中间隔着一道必须迈过去的坎,就是 XComponent 的开发实战。
如果你正在做摄像头实时人脸检测、识别框叠加、或者任何需要在原生层做高频绘制的场景,这篇文章值得花时间看完。我会从 XComponent 的设计思路说起,把 OH_NativeXComponent 的注册、回调、EGL 环境创建、绘制链路一步步拆开讲,最后再把自己踩过的坑和排查思路一并记录下来。
1. 内容整体设计与思路拆解
1.1 为什么人脸识别非要走 XComponent
很多刚接触 HarmonyOS 开发的朋友会问:不就画个框,ArkUI 里 Canvas 也能画,为什么非要绕到原生层用 XComponent?
这个问题问得其实挺好的,答案也很直白:因为性能不够。人脸识别链路里,Camera 拿到的每一帧图像要先送到模型做推理,推理完成后要把识别结果(比如人脸框、关键点坐标)画到屏幕上。这个过程是持续的、高频的,如果每一帧都从原生层把数据拷贝到 JS 层,再通过 ArkUI 的组件去绘制,光是内存拷贝和跨语言调用的开销就足够把帧率打到没法看。
我之前在一个模拟项目 X 里做过一个实验:同样的摄像头预览和识别框绘制逻辑,用 ArkUI Canvas 画,帧率在 12 到 15 之间波动;切换到 XComponent 加原生绘制后,稳定在 30 到 35 帧。差距非常明显。原因是 XComponent 在鸿蒙里就是把一块原生的绘制区域直接嵌进 ArkUI 的页面里,这块区域可以由原生代码直接操作,不经过 ArkUI 的渲染管线。本质上它就是一个可以在 ArkUI 里占位的后端容器,你自己负责在容器里画任何东西。
所以如果你做的是实时性要求较高的视觉应用,XComponent 几乎是绕不开的选择。它同时支持两种使用方式:一种是 ArkTS 侧通过 Canvas API 去绘制(相对简单,适合不需要高性能的场景),另一种就是本文要展开的 OH_NativeXComponent,通过 C++ 侧接口拿到原生窗口,然后用 EGL 加 OpenGL ES 做绘制。后者是真正意义上的高性能路。
1.2 OH_NativeXComponent 在整个链路中的位置
先看一条完整的人脸识别数据流:
Camera 采集画面 → 帧数据回调 → 送入推理引擎 → 得到检测结果 → 将 RGB 图像和结果传给绘制层 → 在屏幕上呈现
这里的"绘制层"如果要用高性能方式实现,就需要 OH_NativeXComponent。它承担的是 OpenGL ES 渲染窗口的载体,同时负责将渲染结果 compose 到 ArkUI 页面里。在 HarmonyOS 6 的架构下,XComponent 作为 ArkUI 组件被声明在页面里,但它的 buffer 管理、surface 创建、回调注册全都在 C++ 侧完成。
整个链路可以简化为三层:
第一层是声明层,ArkTS 页面里放一个 XComponent 组件,指定 type 为 TEXTURE,并设置好 id 和回调注册方法。第二层是桥接层,通过 C++ 侧的 OH_NativeXComponent API 注册回调,拿到 surface 的宽高和窗口句柄。第三层是渲染层,基于 EGL 创建 OpenGL ES 上下文,利用纹理或直绘方式把图像画到 FBO 或直接画到默认 framebuffer。
理解这个分层非常重要,因为后续所有代码都是围绕这三层来组织的。你只要理解 XComponent 的本质就是"把一个原生渲染表面塞进 ArkUI 页面",后面的事情就顺理成章了。
2. 核心机制解析:OH_NativeXComponent 工作原理与回调体系
2.1 OH_NativeXComponent 的核心接口
OH_NativeXComponent 这一套接口,核心目标是让 C++ 侧能感知到 XComponent 组件的生命周期变化,并在合适的时机初始化渲染环境。它挂在头文件 native_xcomponent.h 里,几个关键结构体必须吃透:
第一个是 OH_NativeXComponent_Callback,这个结构体里放的是各个生命周期回调的函数指针。包括 OnSurfaceCreated、OnSurfaceChanged、OnSurfaceDestroyed 以及触摸事件回调 DispatchTouchEvent。RegisterXComponentCallback 方法用于把回调注册进去。
第二个是 OH_NativeXComponent 本身,你可以把它理解成一个不透明的对象指针,所有操作都围绕它展开。通过 OH_NativeXComponent_GetXComponentId 可以拿回组件 id,通过 OH_NativeXComponent_GetSurface 可以拿到原生窗口的句柄。
代码结构一般长这样:
static void OnSurfaceCreated(OH_NativeXComponent* component, void* window) { // 拿到 native window,准备创建 EGL 环境 } static void OnSurfaceChanged(OH_NativeXComponent* component, void* window) { // surface 尺寸变化,更新视口 } static void OnSurfaceDestroyed(OH_NativeXComponent* component, void* window) { // 释放 EGL 资源 }回调里拿到的 window 参数本质上是 NativeWindow 类型,对应着 XComponent 在底层创建的渲染表面。后续创建 EGLSurface 全靠它。
在初始化时,通过 OH_NativeXComponent_RegisterCallback 注册上面这些回调。然后还有一个很关键的步骤:通过 OH_NativeXComponent_GetSurfaceId 拿到 surfaceId,这个 id 在 JS 层初始化 XComponent 时需要用到,它是连接 ArkUI 组件和 C++ 侧对象的桥梁。
2.2 生命周期回调的触发时机与注意事项
关于生命周期,我在实际开发中总结了几个容易出问题的地方:
OnSurfaceCreated 只有在 XComponent 组件真正被挂载到页面并且底层 surface 创建完成后才会触发。不要在页面 aboutToAppear 里就急着初始化渲染环境,一定要等这个回调进来。我见过有人在这个回调之前就去调用 EGL 相关方法,结果程序直接崩掉,本质上是对回调时机没有概念。
OnSurfaceChanged 会在 surface 大小变化时触发,比如屏幕旋转、窗口尺寸调整。这里必须同步更新 EGL 环境里的视口大小,否则画面会拉伸或变形。一个容易忽略的细节是,回调参数里会带上 width 和 height,要能用这两个值去调用 glViewport。
以我在模拟项目 X 里的经验,回调触发的顺序并不保证与 JS 侧的生命周期完全对应。在页面销毁时,ArkUI 先通知 XComponent 销毁,然后才是 aboutToDisappear。所以资源释放逻辑尽量放在 OnSurfaceDestroyed 里,不要依赖 ArkTS 侧的生命周期去做 EGL 相关清理。
static void OnSurfaceDestroyed(OH_NativeXComponent* component, void* window) { // 先释放 EGLSurface 和 Context // 再销毁 EGLDisplay }注意:OnSurfaceDestroyed 调用之后,窗口句柄就已经失效了,不要在释放完 EGL 环境后再去操作 window,否则容易出现野指针崩溃。
2.3 DispatchTouchEvent 在识别交互中的价值
很多做视觉应用的人容易忽略 XComponent 的触摸事件,但其实它很重要。你可以用它在原生层直接处理用户交互,比如点击某个识别框时获取点击坐标,在原生层做命中测试,而不用绕道 ArkUI 的事件系统。
DispatchTouchEvent 回调的参数是一个 OH_NativeXComponent_TouchEvent 结构体,里面包含了事件类型(比如 DOWN、MOVE、UP)和触摸点的坐标信息。
static void OnDispatchTouchEvent(OH_NativeXComponent* component, void* window, const OH_NativeXComponent_TouchEvent* event) { if (event->type == OH_NativeXComponent_TouchEventType::OH_NATIVE_XCOMPONENT_DOWN) { // 记录按下坐标,后续做碰撞检测 } }这里有个细节:触摸事件的坐标是在 XComponent 的本地坐标系里,也就是相对于组件左上角。如果你要把点击的坐标和 GL 绘制坐标对应起来,需要做一定的换算。实际开发中我一般会把这套事件通过回调再抛给 ArkTS 层统一处理,避免在原生层堆业务逻辑。
3. 实操过程与核心环节实现
3.1 环境准备与工程结构
在开始写代码前,先确认你的工程类型。我用的是 HarmonyOS 6 的 Stage 模型加 Native C++ 模板。打开 DevEco Studio,创建项目时选择 Native C++,系统会自动生成一个 C++ 子工程目录,里面包含 CMakeLists.txt 和 napi_init.cpp 这类文件。
项目结构大致是:
entry/src/main/ ├── cpp/ │ ├── CMakeLists.txt │ ├── napi_init.cpp │ ├── render/ │ │ ├── xcomponent_render.cpp │ │ ├── xcomponent_render.h │ ├── common/ │ │ └── log_common.h └── ets/ ├── pages/ │ └── Index.ets └── entryability/ └── EntryAbility.etsCMakeLists.txt 里需要链接的库主要有 libace_napi.z.so、libnative_window.so、libegl.so、libGLESv3.so,以及 libohos.so。我在实际编译中遇到过找不到符号的问题,基本都是 CMake 里少了 target_link_libraries 的配置。
target_link_libraries(entry PUBLIC libace_napi.z.so libnative_window.so libegl.so libGLESv3.so libohos.so )3.2 前端 ArkTS 侧挂载 XComponent
前端这部分看起来简单,但有几个参数必须一一对应。
aboutToAppear() { this.mXComponentId = 'face_recognize_xcomponent'; this.surfaceId = new HashMap<string, string>(); } build() { Column() { XComponent({ id: this.mXComponentId, type: 'TEXTURE', libraryname: 'entry' }) .onLoad((xComponentContext) => { // 在这里通过上下文拿到 surfaceId }) .width('100%') .height('100%') } }关键点在于 type 必须是 TEXTURE,这表示 XComponent 作为一个纹理表面供原生层渲染。如果你写的是 SURFACE,那走的是另一套逻辑,不适用于 OH_NativeXComponent。libraryname 填的是你在 CMakeLists 里构建出来的 so 名字,要和工程配置对应。
onLoad 回调里可以拿到 XComponent 的上下文,里面会提供获取 surfaceId 的方法。这个 surfaceId 需要传给 C++ 侧,用于建立 XComponent 实例和原生渲染对象的绑定关系。
在 C++ 侧,有一个很重要的绑定函数 OH_NativeXComponent_GetXComponentById,它的入参之一就是这个 surfaceId。拿到了匹配的原生 XComponent 对象后,再做后续初始化。
3.3 原生侧初始化 XComponent 与注册回调
拿到 XComponent 对象的引用后,第一件事就是注册生命周期回调。以我常用的写法为例:
napi_value Init(napi_env env, napi_value exports) { napi_property_descriptor desc[] = { {"registerXComponentCallback", nullptr, RegisterCallback, nullptr, nullptr, nullptr, napi_default, nullptr} }; napi_define_properties(env, exports, 1, desc); return exports; }RegisterCallback 方法里做的事情是:从 JS 传过来的参数里提取 surfaceId,然后根据 surfaceId 找到对应的 OH_NativeXComponent,最后把回调结构体注册进去。
static napi_value RegisterCallback(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); char surfaceId[65] = {0}; size_t len = 0; napi_get_value_string_utf8(env, args[0], surfaceId, sizeof(surfaceId), &len); OH_NativeXComponent* component = OH_NativeXComponent_GetXComponentById(env, surfaceId); if (component == nullptr) { return nullptr; } OH_NativeXComponent_Callback callback; callback.OnSurfaceCreated = OnSurfaceCreated; callback.OnSurfaceChanged = OnSurfaceChanged; callback.OnSurfaceDestroyed = OnSurfaceDestroyed; callback.DispatchTouchEvent = OnDispatchTouchEvent; OH_NativeXComponent_RegisterCallback(component, &callback); // 可以把 component 指针和 surfaceId 缓存到全局,后续渲染会用到 return nullptr; }这里有个开发时要留意的点:OH_NativeXComponent_GetXComponentById 的调用必须在 XComponent 已经挂载到页面之后,否则可能拿不到对象。所以从 ArkTS 侧调用 registerXComponentCallback 的时机要在 onLoad 之后,不能在 aboutToAppear 里直接调。
3.4 EGL 环境搭建
EGL 环境是整个绘制链路的地基。它负责连接 OpenGL ES 和底层窗口,把渲染命令最终呈现到屏幕上。我在搭建过程中吃过不少亏,这里把流程完整列出来。
第一步,获取 EGLDisplay:
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY); if (display == EGL_NO_DISPLAY) { // 处理错误 } eglInitialize(display, nullptr, nullptr);第二步,选择合适的配置。人脸识别这种实时性要求高的场景,我通常选择 RGBA8888 颜色格式,并开启 EGL_SURFACE_TYPE 为窗口类型。如果应用需要做离屏渲染,还要加上 EGL_RENDERABLE_TYPE 为 EGL_OPENGL_ES3_BIT。
EGLConfig config; EGLint configAttribs[] = { EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE }; EGLint numConfigs; eglChooseConfig(display, configAttribs, &config, 1, &numConfigs);第三步,创建 EGLContext。要让 OpenGL ES 3.0 生效,需要在属性列表里指定 EGL_CONTEXT_MAJOR_VERSION 为 3。
EGLint contextAttribs[] = { EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 0, EGL_NONE }; EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs);第四步也是关键一步,创建 EGLSurface。这里要用到 XComponent 回调里拿到的 native window:
EGLSurface surface = eglCreateWindowSurface(display, config, nativeWindow, nullptr); if (surface == EGL_NO_SURFACE) { // 处理创建失败 } eglMakeCurrent(display, surface, surface, context);window 参数来自 OnSurfaceCreated 的回调。拿到它之后要先判断是不是 nullptr,因为偶尔会出现 surface 还没就绪但回调已经触发的情况。
EGL 环境创建完成后,可以设置一下交换间隔,让画面刷新和屏幕垂直同步对齐:
eglSwapInterval(display, 1);3.5 摄像头帧与识别结果的原生绘制
EGL 环境准备好之后,真正的绘制逻辑就开始了。人脸识别的绘制任务拆开来是两块:一是把 Camera 采集到的帧画面展示出来,二是在帧画面上叠加上人脸框和关键点。
我这边的做法是用 GLSL 写一个简单的纹理贴图着色器,把 Camera 的 YUV 帧转成 RGBA 后上传到纹理,再把纹理绘制到一个全屏四边形上。这样每一帧只用一张纹理就能完成画面显示,效率很高。
由于人脸识别通常是用 YUV 数据做的输入,这里需要先做一个 YUV 到 RGB 的转换。我一般在 C++ 里直接写 NEON 优化过的转换函数,或者用 OpenGL ES 的着色器来做 GPU 加速转换。用着色器有个好处,可以省掉 CPU 端的转换开销,直接把 YUV 三个平面分别绑定到三个纹理单元,在片元着色器里做矩阵运算。
这里给出一个简化的顶点着色器和片元着色器思路:
// 顶点着色器 attribute vec4 aPosition; attribute vec2 aTexCoord; varying vec2 vTexCoord; void main() { vTexCoord = aTexCoord; gl_Position = aPosition; } // 片元着色器(以 RGBA 纹理为例) precision mediump float; varying vec2 vTexCoord; uniform sampler2D uTexture; void main() { gl_FragColor = texture2D(uTexture, vTexCoord); }人脸框的绘制我选择用线框的方式画矩形。先在 CPU 端把人脸框的坐标从模型输出坐标系换算到屏幕坐标系,然后生成顶点数据,再用 glDrawArrays 以 GL_LINE_LOOP 的方式画出来。为了让人脸框明显一点,可以先把线宽调大些,或者画两层不同颜色的线框做描边效果。
void DrawFaceBox(float x, float y, float w, float h) { float vertices[] = { x, y, x + w, y, x + w, y + h, x, y + h }; glVertexAttribPointer(positionLoc, 2, GL_FLOAT, GL_FALSE, 0, vertices); glEnableVertexAttribArray(positionLoc); glDrawArrays(GL_LINE_LOOP, 0, 4); }注意上面画的是一个覆盖了整个 GL 视口大小的框,实际使用中你还得考虑纹理和视口的坐标映射关系。通常我会把纹理绘制和一个固定的正交投影矩阵结合,然后所有的叠加元素(人脸框、关键点、追踪轨迹)都在这个投影坐标系里计算。
每一帧的渲染结束后,要调用 eglSwapBuffers 把内容呈现到屏幕上:
eglSwapBuffers(display, surface);3.6 与摄像头数据的串接实践
在 HarmonyOS 里,Camera 的帧数据回调和 XComponent 的绘制是两条独立的链路,需要我们在代码里把它们串起来。我的做法是:Camera 帧回调里先把数据转成纹理,送到 OpenGL 管线中缓存起来,然后再触发渲染线程执行一帧绘制。
这里有个线程模型的问题值得注意。Camera 的帧回调线程和 GL 渲染线程不能是同一个,因为 GL 线程需要独立管理 EGLContext,Camera 线程拿到的数据要跨线程传给渲染线程。最简单的设计方案是:Camera 回调里只做数据拷贝和标记,渲染线程通过条件变量或帧同步机制感知到新帧到来后,从共享缓冲区取数据并执行绘制。
缓冲区我用的是环形缓冲,长度设置 3 就够。原因是 Camera 帧率一般在 30 帧左右,如果缓冲区太大反而会造成延迟增加,识别结果和画面不同步。实测 3 帧缓冲既能保证画面不撕裂,又可以让识别框的跟随相对及时。
4. 常见问题与排查技巧实录
4.1 常见错误速查表
我把自己在开发过程中遇到的高频问题整理成了一张表,方便你直接对照排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| XComponent 黑屏 | EGL 环境未创建成功 | 检查 OnSurfaceCreated 是否触发 |
| 画面拉伸变形 | glViewport 尺寸与 surface 不匹配 | 检查 OnSurfaceChanged 回调参数 |
| 程序启动即崩溃 | 在 OnSurfaceCreated 之前调用 EGL | 确认所有 EGL 操作都在回调内 |
| 画面纹理颠倒 | OpenGL 纹理坐标约定问题 | 调整纹理坐标 V 方向 |
| 人脸框漂移 | 坐标映射关系错误 | 检查模型坐标系与 GL 坐标系换算 |
| 帧率很低 | 每帧做了 CPU 端 YUV 转换 | 改用 GPU 着色器转换 |
| 页面退出时崩溃 | EGL 资源释放顺序错误 | 先释放 surface 再释放 context |
| 回调无法触发 | surfaceId 匹配出错 | 确认 JS 侧传入的 ID 与原生一致 |
这张表里的问题我在模拟项目 X 中几乎全踩过一遍,每一个都是实际开发中很容易掉进去的坑。
4.2 黑屏问题定位思路
如果 XComponent 区域一片黑,不着急改代码,先按下面这个顺序排查。
第一,确认 OnSurfaceCreated 有没有被调用。可以在回调里加上日志输出,如果这个回调都没进来,说明注册有问题,优先检查 surfaceId 和 OH_NativeXComponent_RegisterCallback 是否被正确执行。
第二,确认 EGL 初始化是否在 OnSurfaceCreated 之后。如果 EGL 环境初始化失败,eglGetError 会返回错误码,日志里加上错误码输出会很快定位问题。
第三,确认 glClear 操作是否生效。理论上只要 EGL 环境创建成功且绘制调用了 glClear,即使不画任何纹理,背景颜色也应该变。如果连清除色都看不到,基本可以断定 EGL 环境有问题。
4.3 线程模型与资源释放的坑
线程模型是 OH_NativeXComponent 最容易出问题的区域。EGLContext 是线程相关的,在 A 线程创建的 EGLContext 不能在 B 线程直接使用。实际开发中你最好不要碰这个底线,强烈建议让 EGLContext 的创建、绑定、绘制、销毁都发生在同一个渲染线程里。
资源释放顺序也有讲究。正确的顺序是:
- 删除 GL 资源(纹理、缓冲区、着色器程序)
- 释放 EGLSurface
- 释放 EGLContext
- 终止 EGLDisplay
如果把顺序搞反,轻则资源泄漏,重则页面退出时直接崩溃。我在早期版本里就是先释放了 EGLDisplay 再去销毁 EGLSurface,结果每次退出页面都崩,查了半天才发现是这个顺序问题。
4.4 识别框与画面延迟不一致的优化
人脸框和画面不同步,往往是识别链路和绘制链路没有在时间戳上对齐。解决思路是在 Camera 帧回调里记录帧序号和时间戳,识别结果也带上同样的时间戳,绘制时优先画最新完成识别的那一帧。这样可以避免画面都更新到第 30 帧了,人脸框还在画第 15 帧的识别结果。
另一个提升跟随效果的方法是把人脸框做一次简单的平滑。因为模型在单帧上的检测结果会有轻微抖动,直接画出来会看到人脸框在跳,用指数平滑算法可以让框的位置更稳定。
// 指数平滑,alpha 取值 0.3 - 0.7 之间 smoothX = (1.0f - alpha) * smoothX + alpha * currentX; smoothY = (1.0f - alpha) * smoothY + alpha * currentY;alpha 取太小反应慢,取太大又不够平滑。我在模拟项目 X 里经过多轮调整,最后定在 0.5 左右,追踪跟手且稳定性比较好。
5. 实践中的几个细节体会
5.1 关于投影矩阵的选择
人脸框绘制我最初用的是屏幕像素坐标直接绘制,后来发现不同设备分辨率下适配很麻烦。改成归一化坐标后,整个世界清静了。做法很简单,把模型输出的人脸框坐标除以图像宽高,让值域落到 0 到 1 之间,然后在 GL 里用一个简单的正交投影,把归一化坐标映射到视口。这样不管屏幕多大,画出来的框位置都不会错。
5.2 关于 GLES 版本的选择
HarmonyOS 6 同时支持 GLES 3.0 和 GLES 3.2。我在人脸识别项目里用的 GLES 3.0,原因是对大部分设备兼容性最好,功能也足够用。如果你的应用需要用到更高阶的着色器特性,再考虑升级到 3.2。不要一上来就用最高版本,兼容性问题在真机测试时会非常头大。
5.3 关于日志打点
在 OH_NativeXComponent 的开发里,你会遇到很多无法直接观察的状态。我强烈建议在关键回调里都加上日志打点,比如 OnSurfaceCreated、OnSurfaceChanged、OnSurfaceDestroyed、EGL 创建成功、首帧绘制完成。这些日志在你后续做性能分析和问题排查时会非常有用。
6. 结尾的一点心得
说心里话,OH_NativeXComponent 这套东西初次上手时确实有点绕,要同时理解 ArkUI 的组件生命周期、NAPI 的桥接机制、EGL 的上下文管理,还有 OpenGL ES 的绘制管线。但一旦把这条链路跑通,后面再做类似的渲染需求就会变得非常顺手,因为你的工具箱里多了一套稳定高效的绘制通道。
我个人在实际操作中的体会是:做这种底层开发,最重要的是要有耐心去理解每一层接口的设计意图。比如 OH_NativeXComponent 之所以把回调触发时机设计成和页面生命周期不完全同步,就是为了保证渲染环境的安全性,理解了这个设计初衷,很多看似奇怪的限制就都能说得通了。
最后再分享一个小经验:如果你在开发中碰到莫名其妙的崩溃,先别急着怀疑是逻辑代码的问题,优先查资源持有和释放。XComponent 相关的问题七成以上出在线程模型和生命周期上,把这个基础打牢靠,后面就能少走很多弯路。