这阵子我在折腾HarmonyOS 6下的自定义人脸识别模型,上一篇把模型从训练格式转换到端侧推理格式,跑通了单帧推理,输出了一组人脸框坐标和关键点坐标。可真到了想把它做成一个能在屏幕上看到效果的Demo时,问题就来了:这些坐标散落在C++侧,怎么实时画到界面上?最开始我试着用ArkTS组件去刷,卡顿到怀疑人生;后来切到XComponent,用OH_NativeXComponent方式在Native侧直接绘制,总算把帧率拉回到可用水平。这篇文章就把这条路上的核心设计、绘制链路和踩坑点拆开讲,适合已经把推理模型跑通、正准备做可视化渲染的开发同学。
1. 为什么人脸识别渲染要绕开ArkUI,直接选XComponent
1.1 ArkUI组件在实时叠层上的力不从心
人脸识别可视化需要把推理结果和摄像头帧同步呈现,通常是30fps甚至更高。摄像头每一帧都会产生新的边界框、人脸框、点位,如果把这几个元素做成ArkUI的Image加Canvas组件,每次更新都需要跨语言调用、组件树diff、布局计算,即便框架做了优化,当人脸框数量比较多、还要标注关键点时,帧率波动很明显。更要命的是,ArkUI组件本身不支持直接绑定到Native侧Surface,模型推理结果在C++侧已经生成,再往ArkTS侧搬运,多一次序列化开销。
在HarmonyOS 6上,官方推荐的方案是使用XComponent来解决这类高频渲染场景。XComponent并不是一个普普通通的容器组件,它更像是一个“窗口化身”,当你把它的type设为surface时,它就直接挂接在Surface体系上,由Native侧掌握像素填充权。人脸识别这种渲染需求恰好是推理在C++侧、结果也在C++侧,如果在绘制阶段也能留在C++侧,整个链路就会非常短,这也是我最终选择XComponent的核心原因。
1.2 XComponent在渲染路径上比ArkUI组件少走多少路
两种方案最大的差异是数据流长短。ArkUI组件叠层方案里,C++推理线程把坐标塞进JSON或者相兼容的结构体,通过NAPI抛给ArkTS,再生成几十个组件节点,驱动组件树做布局和重绘。XComponent加OH_NativeXComponent方案则完全绕开了这一层:
| 方案 | 数据流 | 帧率表现 | 适合场景 |
|---|---|---|---|
| ArkUI组件叠层 | C++推理 → NAPI → ArkTS → 组件树渲染 | 受组件树规模影响大 | 低频结果展示、文本提示 |
| XComponent + OH_NativeXComponent | C++推理 → C++绘制 → Surface | 高且稳定 | 实时视频叠加、关键点标注 |
用一张表可以直接说明问题:组件树方案每帧的数据流路径长,而且位置越靠上,跨语言调用带来的损耗越明显。人脸识别模型一帧可能输出几十个点位,关键点数量再一多,ArkTS侧要处理的UI更新数量是很大的。如果你只是画一个人脸框,也许体感不明显,要做到68个关键点、人脸姿态角、是否戴口罩这些同时在屏幕上叠出来,ArkUI方案就很容易掉到十几帧。
当然,ArkUI在HarmonyOS 6的很多场景里仍然是高效方案,只不过它擅长的是低频交互事件和少量组件更新。人脸识别标记属于高频图形绘制,适合把渲染权下放。我把这次项目的初始版本分别实现过一遍,最终保留XComponent方案,并不是因为ArkUI不行,而是渲染路径短带来的帧率优势实在太大。
1.3 OH_NativeXComponent在整套体系里的定位
理解OH_NativeXComponent之前,要先把两个概念区分开:NAPI负责数据交换,OH_NativeXComponent负责渲染窗口。很多人一上来就混淆,以为注册了NAPI回调就等于拿到了绘制能力,其实不是。
OH_NativeXComponent提供的是C/C++侧操作XComponent的接口,它主要帮你拿到XComponent底层的Native Window、注册Surface事件回调、和EGL/OpenGL ES环境对接。简单说,它负责的是“画在哪”和“怎么知道Surface变大了”。真正画什么、怎么画,还得靠你自己的绘制管线。它可以和NAPI配合:用NAPI暴露推理函数和参数给ArkTS,用OH_NativeXComponent拿到窗口去绘制,两者各司其职。
我在工程里的做法是,把它们封装成两个模块:InferenceCore负责模型推理,RenderEngine负责窗口和绘制。两者之间只通过一个坐标队列通信。这样的好处是,后续如果要在PC上调试纯推理模块,可以把RenderEngine整个替换掉,不影响核心逻辑。
2. OH_NativeXComponent的初始化、回调注册与窗口获取顺序
2.1 ArkTS侧声明Surface类型XComponent
在HarmonyOS 6的ArkTS页面里,声明XComponent时要指定type为surface,并绑定Native侧.so库。我一般这样写:
XComponent({ id: 'face_render_comp', type: 'surface', libraryname: 'face_render' })注意这个libraryname对应的是CMake生成的.so名,开发调试时如果改了库名,一定要同步改这里,否则Native侧回调进不来。width和height可以先给一个初始值,后续在OnSurfaceChanged回调里由真实的Native窗口尺寸覆盖。ArkTS侧并不需要去手动控制Surface绘制,只需要把这个组件放在页面合适的位置,剩下的交给Native层。
2.2 C++侧获取XComponent实例的两种时机
OH_NativeXComponent接口获取XComponent实例通常发生在NAPI模块初始化阶段。可以通过OH_NativeXComponent_GetXComponentById从全局拿到NativeXComponent指针,也可以在NAPI导出函数的参数里直接拿。我建议在模块初始化时把指针保存到一个全局Map里,以XComponent的id为键,方便后面回调触发时快速找到对应的渲染上下文。
这里容易踩的坑是,如果你的页面有多个XComponent,回调注册不小心重复,会覆盖之前的渲染上下文。所以我在初始化函数里都会加一个日志,确认每个id只被注册一次。
static std::unordered_map<std::string, NativeXComponent*> g_xcomponent_map; napi_value RegisterXComponent(napi_env env, napi_callback_info info) { // 从ArkTS传入的id字段拿到XComponent id // 调用 OH_NativeXComponent_GetXComponentById 获取实例 // 保存到 g_xcomponent_map return nullptr; }这里还有一个容易忽略的问题:OH_NativeXComponent的生命周期和NAPI模块生命周期并不完全一致。NAPI模块通常只在.so加载时初始化一次,但页面切走再回来,XComponent实例可能被销毁重建。所以不能只在模块初始化时注册回调,还要在页面创建时重新绑定XComponent,否则再进来时会发现回调不触发。
2.3 生命周期回调不是“取到即用”
拿到NativeXComponent指针以后,要注册四类核心回调:OnSurfaceCreated、OnSurfaceChanged、OnSurfaceDestroyed、OnSurfaceRedraw。真正的NativeWindow句柄在第一个回调参数里,别在初始化阶段提前去取,否则拿到的可能是空指针或无效窗口。
我一开始习惯在模块初始化时把窗口句柄一并缓存下来,结果切后台再回来的场景经常白屏。正确做法是:只在OnSurfaceCreated里创建EGL窗口并初始化绘制环境,OnSurfaceChanged里更新视口尺寸,OnSurfaceDestroyed里释放EGL和Surface相关资源。注册代码大致如下:
void OnSurfaceCreated(OH_NativeXComponent* component, void* window) { // window 才是真正可用的 NativeWindow renderEngine->OnSurfaceCreated(window); } void OnSurfaceChanged(OH_NativeXComponent* component, uint32_t width, uint32_t height) { renderEngine->OnSurfaceChanged(width, height); } void OnSurfaceDestroyed(OH_NativeXComponent* component) { renderEngine->OnSurfaceDestroyed(); }这类回调和系统里的其他Surface回调非常像,但接口名和理解方式完全不同。我第一次从别的图形栈转过来时,习惯性地在OnSurfaceCreated里直接拿component去交换EGLSurface,结果窗口类型不对,EGL初始化一直失败。后来才想明白:要使用的是回调参数里的window,而不是XComponent指针本身。
2.4 NAPI导出函数与回调配对
为了让ArkTS能真正调用到Native的推理/绘制函数,需要用napi创建导出函数。这里顺便把自定义人脸识别模型的推理入口也暴露出来,比如inferImage(source, width, height),然后让NAPI内部直接走已有的C++推理管线。需要强调的是,NAPI和OH_NativeXComponent虽然都基于napi,但一个是接口层,一个是渲染层,两者不能互相替代。NAPI负责传数据,XComponent负责提供窗口。
static napi_value Init(napi_env env, napi_value exports) { napi_property_descriptor desc[] = { {"inferImage", nullptr, InferImage, nullptr, nullptr, nullptr, napi_default, nullptr}, {"registerRenderComponent", nullptr, RegisterXComponent, nullptr, nullptr, nullptr, napi_default, nullptr}, }; napi_define_properties(env, exports, 2, desc); return exports; }这套结构跑通以后,你在ArkTS里可以这样调用:
import native from 'libface_render.so'; // 注册XComponent native.registerRenderComponent('face_render_comp'); // 推理某帧图像 const result = native.inferImage(img, width, height);不过要注意,inferImage如果走同步返回,耗时会直接卡住ArkTS侧调用线程。实际项目中建议改成异步回调,或者只把推理请求发到Native线程队列,推理完成后再通过NAPI回调通知ArkTS。前期调试为了简单用同步也可以,但一定要了解这个限制。
3. 用EGL/OpenGL ES还是软件绘制?渲染管线选择的思路
3.1 先明确人脸标记渲染的负载
人脸框和关键点本质上只是一堆线段和圆点,GPU绘制可能比CPU绘制更快,但会引入EGL环境初始化的成本。反之,如果只是画几十个点和几个框,CPU软件渲染也足够。
我建议:如果后续要做平滑滤波、人脸对齐后的纹理贴图、实时美颜,直接上OpenGL ES管线;如果仅是给推理结果做可视化,先用软件渲染会更快上线。HarmonyOS 6的XComponent支持多种绘制生态,我自己在测试工程里因为后续要叠加相机画面,所以选了EGL加OpenGL ES。
人脸识别标记的渲染负载并不高,真正的性能瓶颈在推理端,但渲染管线选型会影响后续扩展。我见过一个纯做验证的Demo,用软件渲染方式,每帧画完一个矩形就直接提交Surface,已经能跑得很流畅。只是当你想给框加圆角、画关键点连线、做抗锯齿时,软件渲染的代码量会逐步失控,最终还是要回到GPU这条路上。
3.2 EGL初始化顺序和关键参数
EGL初始化的顺序很固定:eglGetDisplay→eglInitialize→eglChooseConfig→eglCreateWindowSurface→eglCreateContext→eglMakeCurrent。需要注意的有几点:
- 窗口Surface必须从
OnSurfaceCreated回调里拿到的native window创建,而不是直接new一个EGLSurface。 EGLConfig的EGL_RENDERABLE_TYPE要配成EGL_OPENGL_ES2_BIT,否则后面不能绑定GLES2/3。- 如果希望在多个线程共享EGLContext,需要设置
EGL_CONTEXT_CLIENT_VERSION和share context参数,这一步很多人忽略,导致跨线程调用GL时报非法操作。
我整理了一份常用配置参考:
| 参数 | 推荐值 | 用途 |
|---|---|---|
| EGL_RED_SIZE | 8 | 颜色精度 |
| EGL_ALPHA_SIZE | 8 | 透明叠加 |
| EGL_SURFACE_TYPE | EGL_WINDOW_BIT | 绑定窗口 |
| EGL_RENDERABLE_TYPE | EGL_OPENGL_ES2_BIT | 支持GLES2/3 |
| EGL_DEPTH_SIZE | 0 | 人脸绘制不需要深度,可省内存 |
初始化伪代码大致如下:
eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); eglInitialize(eglDisplay, &major, &minor); eglChooseConfig(eglDisplay, configAttribs, &eglConfig, 1, &numConfigs); eglSurface = eglCreateWindowSurface(eglDisplay, eglConfig, nativeWindow, nullptr); eglContext = eglCreateContext(eglDisplay, eglConfig, EGL_NO_CONTEXT, contextAttribs); eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext);代码顺序看起来简单,但每步的返回值都要检查。如果某一步失败,后续绘制不会立刻崩,而是会在第一次glClear时通过日志暴露出来。最好把这几个返回值都打上日志,否则排查白屏时很难定位。
3.3 渲染线程与模型推理线程的调度
我这边实际采用的是“双线程加双缓冲”的思路:推理线程负责计算人脸框和关键点,结果写入一个环形缓冲区,并带时序戳;绘制线程负责EGL交换和绘制,每帧去取最新的推理结果,如果推理速度跟不上,就复用上一帧坐标并画一个稍大的半透明框,这比丢帧更美观。
常见误区是让推理和绘制在同一个线程串行,这样帧率直接被推理耗时封顶。实测推理耗时30ms时会直接压到33fps,而拆开后,渲染仍可稳定跑满屏幕刷新率。HarmonyOS设备上的刷新率差异很大,有的屏幕是60Hz,有的是90Hz,如果只按30fps去设计,会白白浪费硬件能力。
双线程带来的新问题是数据竞争。我推荐使用无锁环形缓冲区,写入端和读取端各自维护自己的读写索引,并用atomic保护。针对人脸坐标这种固定结构体数据,用这种方式比加锁队列更稳。唯一要注意的是,坐标数据一定要按值拷贝进去,不要只拷贝指针,否则推理线程复用内存时,绘制线程可能读到被覆盖的数据。
4. 人脸框与关键点的坐标变换、顶点缓存与绘制命令组织
4.1 从模型输出到屏幕坐标的换算
人脸识别模型输出的坐标通常是归一化的,范围在0到1,而OpenGL ES的NDC坐标范围是-1到1。如果想把归一化坐标画到屏幕,直接乘width/height再转换到NDC会有问题:竖屏和横屏时坐标会变形。
我在实际实现中,先根据图像大小得到图像在Surface上的缩放关系,把归一化坐标换算成Surface像素坐标,再用glViewport的宽高转成NDC。这一步稍不注意,画出来的人脸框就会永远和实际人脸位置偏差半个头。具体公式可以写成:
float screenX = faceBox.centerX * surfaceWidth; float screenY = faceBox.centerY * surfaceHeight; float ndcX = screenX / surfaceWidth * 2.0f - 1.0f; float ndcY = 1.0f - screenY / surfaceHeight * 2.0f;这个公式看起来简单,但Y轴方向很多人会忘记翻转,因为模型坐标通常以图像左上角为原点,OpenGL以左下角为原点。不做翻转,关键点会整体上下颠倒。
还有一个容易被忽略的细节:Surface的宽高并不一定与相机流宽高一致。如果XComponent是居中裁剪的,那么模型坐标要先经过一次裁剪映射,才能得到Surface坐标。我在首版里直接拿Surface宽高算,结果摄像头预览画面是16:9,而Surface是1:1,所有关键点都往右下方偏。后来改成先计算图像与Surface的“包含”关系,再映射,问题才解决。
4.2 画框和关键点用哪种图元
人脸框一般画成四条线段或一个GL_LINE_LOOP;关键点则是一堆小圆点。对于圆点,如果逐点生成三角扇会比较浪费,我惯用的做法是:把点精灵开起来,使用GL_POINTS加gl_PointSize,在片元着色器里用距离判断画成圆形。
比如关键点可以用这样的顶点着色器和片元着色器组合:
// vertex shader layout(location = 0) in vec2 aPos; uniform float uPointSize; void main() { gl_Position = vec4(aPos, 0.0, 1.0); gl_PointSize = uPointSize; }// fragment shader precision mediump float; uniform vec4 uColor; void main() { vec2 coord = gl_PointCoord * 2.0 - 1.0; if (dot(coord, coord) > 1.0) discard; gl_FragColor = uColor; }这样画出来的就是圆点。如果用普通GL_POINTS不做片元处理,会变成方块,观感很糙。如果要画轮廓连接线,可以把关键点按顺序传入GL_LINE_STRIP,在着色器里处理粗细和颜色渐变。人脸框画线时,注意OpenGL ES的线宽在部分设备上被限制为1,需要扩线宽的话,要么用三角形拼接,要么用几根矩形条模拟。
4.3 顶点数据的CPU分配陷阱
最容易导致掉帧的不是GPU绘制,而是每帧在C++里动态分配顶点数组。我写过一版每帧new float[]写顶点,帧率在设备上掉到40fps左右;改成预分配两个VBO之后,同样是测试机,帧率立刻回到60fps。
具体做法是人脸框一个VBO,关键点一个VBO,坐标数据每帧用glBufferSubData更新,而不是重新glBufferData。glBufferSubData只更新已分配显存上的数据,性能稳定,但要保证上次分配的大小足够容纳最新数据,否则会崩。所以分配的时候要按最大人脸数、最大关键点数预留,这是“空间换时间”的典型用法。
// 初始化时按最大容量分配 glBufferData(GL_ARRAY_BUFFER, maxBufferSize, nullptr, GL_DYNAMIC_DRAW); // 每帧只更新有效数据段 glBufferSubData(GL_ARRAY_BUFFER, 0, validSize, dataPtr);如果人脸数不固定,建议把缓冲区分成固定块,每张人脸占一块,这样在GPU侧也能用实例化绘制。这个方案的扩展性很好,后续要加姿态盒、呼吸灯之类的人脸特效时,不需要重构整套渲染逻辑。
4.4 绘制命令的组织与排序
人脸识别标记一般分两层:先画半透明的人脸框遮罩,再画关键点。如果多人脸,每一层的绘制顺序要尽量一致,避免透明混合导致颜色叠加顺序不稳定。实践中我会先把所有框的顶点一次性写入VBO,用一次glDrawArrays画完所有框,再写关键点,再画一次。这样可以减少DrawCall数量。
以5个人脸为例,如果不合并顶点数据,每个人脸框一次glDrawArrays,再加关键点一次绘制,一轮就要6次以上DrawCall。合并之后只需要2次。虽然现代GPU对DrawCall的容忍度很高,但在低端设备上的差别仍然显著。我给自己定的规矩是:静态属性一律uniform,动态属性一律attribute,让顶点数据尽量紧凑。
5. 实测帧率、白屏崩溃与流畅度问题的排查清单
5.1 “明明绘制了,屏幕上一片黑”的经典原因
黑屏或者白屏是XComponent开发里最常出现的问题。按我的排查顺序来:
- 确认
OnSurfaceCreated有没有被调用,没有说明XComponent的libraryname或type配置错误。 - 确认EGL有没有
eglMakeCurrent成功,失败时日志会输出EGL_BAD_ACCESS之类错误。 - 确认是否在绘制前调用了
glClear,并且glClearColor不是全黑。 - 确认
Surface尺寸是否为0,如果页面还没有布局结束,OnSurfaceChanged里的宽高可能是0,需要等回调再次触发。
其中“库名对不上”是最容易犯的。CMake里生成的库名是libface_render.so,ArkTS里的libraryname应该写face_render,不加前缀和后缀。我因为在libraryname里写成了libface_render.so,回调一直不触发,排查了快一天。如果遇到回调不进来,先检查这个,比查其他所有问题都快。
5.2 人脸框会拖影、闪烁,和双缓冲的关系
拖影通常是因为绘制线程在交换前后用了不同的缓冲:一边用framebuffer,一边用EGL surface,没有遵守“渲染到当前drawable,再交换”的节奏。建议在每帧开头加一个eglWaitClient,在swap前加eglWaitNative,保证GL命令完成后才展示。
如果仍然闪烁,检查是否忘了设置EGL_SWAP_BEHAVIOR为EGL_BUFFER_PRESERVED,或者是否在多个线程同时操作同一个EGLContext。我在实测中用“单绘制线程独占EGLContext加主线程只负责推数据”解决掉大部分闪烁。
一个实用的调试技巧是:先画一个填充整个屏幕的纯色块,如果纯色块不闪,说明双缓冲基本正常,问题的重点在人脸框透明混合;如果纯色块也闪,说明EGL配置或线程调度有问题,先别急着调绘制逻辑。
5.3 页面从后台切回来,渲染线程崩溃
一个容易爆的坑是:XComponent销毁回调已经触发,而绘图线程还停在旧的EGLContext上继续画。HarmonyOS 6对Surface的释放是异步的,如果销毁流程没有把running标志置位,线程会在下一帧交换时访问已释放的窗口,直接崩溃。
我的做法是用std::atomic<bool>控制渲染循环,在OnSurfaceDestroyed里先置false并join线程,再释放EGL资源;并且所有EGL函数只在渲染线程调用,避免跨线程释放。
std::atomic<bool> renderRunning{true}; void RenderLoop() { while (renderRunning.load()) { // 绘制、交换 } eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); eglDestroySurface(display, eglSurface); eglDestroyContext(display, eglContext); } void OnSurfaceDestroyed() { renderRunning.store(false); if (renderThread.joinable()) { renderThread.join(); } }这套思路处理下来,至少不会再出现切后台崩溃。值得注意的是,不能直接在OnSurfaceDestroyed里调用eglDestroyContext,因为它可能和渲染线程的执行时序重叠,造成use-after-free。
5.4 帧率统计与性能基线
在测试工程里我用一个高精度时钟统计每帧耗时。简单画框和点时的耗时会很低,真正消耗大多在纹理上传和坐标数据的CPU搬运上。如果后续要叠加摄像头画面,记得先定位到是glTexSubImage2D还是glDrawArrays占大头。
我先给一个性能基线供参考:单张1080P推理耗时约23ms,绘制耗时约1.8ms,双线程下整体端到端延迟为27ms左右,体验已经比较跟手。如果低于这个水平,优先查推理线程是否有锁阻塞,比如推理线程用同一个队列和渲染线程抢锁,会导致坐标数据迟迟不能更新。
我最初一版把推理结果设计成std::shared_ptr传递,结果每帧都在原子增加引用计数,反而成了瓶颈。后来换成固定内存连续覆盖,配合帧序号校验,性能立刻上来了。性能优化这件事,永远要从实际火焰图出发,而不是盲目套用某个技巧。
5.5 一个快速验证叠加效果的取巧方案
最后分享一个适合效果验证的小技巧。如果你不想一上来就把相机纹理上传到OpenGL里做复杂叠加,可以先用XComponent把背景清成透明或半透明,只绘制人脸框和关键点,然后让ArkTS层用别的组件在底下铺一层相机流。这样XComponent变成了一个悬浮在相机画面上的标注层,你在没有完成相机纹理映射之前,也能先看到人脸框跟着人动。
等到这种叠加效果稳定了,再把相机流真正传到GPU侧,把坐标映射从Surface坐标换成纹理坐标。两步走的好处是,每一步都能单独验证,不用把系统和绘制链路一次性全接上。至少在我这个项目里,这个办法让我省掉了很多联调时间。
画人脸识别标记这件事,真正难的并不在某个函数怎么调用,而是把推理、坐标变换、GPU渲染和生命周期管理串成一条稳定链路的全局思维。希望这些从实际调试里踩出来的经验,能帮你少走几步弯路。