
这个 HUD 组件要解决什么问题
HarmonyOS 上的游戏开发,UI 部分一直比较棘手。ArkUI 框架本身是声明式 UI,渲染走的是 GPU 加速,但对于 HUD(Head-Up Display)这种需要高频更新、且对帧率敏感的组件,直接使用纯 ArkTS 的 Stack + 状态绑定的写法,很容易出现帧率抖动。
很多人在开发游戏时遇到一个问题:血量条用 @State 绑定,每秒更新 60 次,结果 UI 线程负担太重,游戏主线程掉帧。实际测试下来,当 HUD 组件包含 3 个以上独立更新区域(血条、技能 CD、小地图)时,纯 ArkTS 实现的渲染耗时在 5-8ms,留给游戏逻辑的时间就很少了。
所以这篇文章要做的,是用 NDK + Canvas 的方式,把 HUD 的渲染工作从 ArkTS 侧剥离,交给 C++ 的 Canvas 绘制。这样 ArkTS 侧只负责接收数据、触发绘制,真正的渲染计算由 C++ 处理。最终目标:在 60fps 下稳定运行,且 HUD 渲染耗时控制在 2ms 以内。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
整体架构设计
这个 HUD 组件包含三个核心模块:
- 血量条:使用 Canvas 绘制矩形条,颜色从绿色渐变到红色。
- 技能冷却图标:使用 Canvas 绘制圆形遮罩,根据冷却进度显示扇形填充。
- 小地图:使用 Canvas 绘制简化地形,玩家位置用红点标记。
数据更新策略是这样:
- ArkTS 侧:负责接收游戏逻辑发来的数据(血量百分比、技能冷却剩余时间、玩家位置),并传递给 C++。
- C++ 侧:负责计算绘制参数(颜色渐变、扇形角度、坐标映射),然后调用 Canvas 的绘图 API 完成渲染。
所有绘制操作都在 GPU 上执行,C++ 代码只做计算和 API 调用,不阻塞 UI 线程。
核心实现
1. ArkTS 侧:数据中转与 Canvas 容器
先写 ArkTS 侧的入口文件。这个 HudComponent 组件负责实例化 C++ 的绘制类,并通过 Canvas 把绘制结果呈现出来。
typescript
// HudComponent.ets
import { HudRender } from '../native/cpp/NativeHudRender';
@Entry
@Component
struct HudComponent {
@State private healthPercent: number = 1.0;
@State private skillCooldown: number = 0.0;
@State private playerX: number = 0.5;
@State private playerY: number = 0.5;
private nativeRender: HudRender = new HudRender();
aboutToAppear() {
// 初始化 C++ 渲染器
this.nativeRender.init();
// 模拟游戏数据更新(实际项目里由游戏线程推送)
setInterval(() => {
this.healthPercent = Math.max(0, this.healthPercent - 0.01);
this.skillCooldown = (this.skillCooldown + 0.02) % 1.0;
this.playerX = 0.5 + Math.sin(Date.now() / 1000) * 0.3;
this.playerY = 0.5 + Math.cos(Date.now() / 1000) * 0.3;
}, 16); // 约 60fps
}
build() {
Column() {
Canvas(this.context) {
// 这里用 Canvas 包裹 C++ 的绘制结果
}
.width('100%')
.height('100%')
.onReady(() => {
// Canvas 准备好后,把 Native Canvas 指针传给 C++
let nativeCanvas = this.context.getNativeCanvas();
this.nativeRender.setNativeCanvas(nativeCanvas);
})
.onDraw((context) => {
// 每次绘制时,把数据传给 C++,由 C++ 完成渲染
this.nativeRender.drawHud(
this.healthPercent,
this.skillCooldown,
this.playerX,
this.playerY
);
})
}
}
}
代码说明:
HudRender是 C++ 导出到 ArkTS 的类,负责管理 Native Canvas 和绘制逻辑。setInterval模拟游戏数据更新。真机开发中,这些数据应该来自游戏引擎的帧循环。onDraw回调是 ArkUI 的 Canvas 事件,每次帧渲染时触发。这里把数据传递给 C++ 一次性绘制,避免多次跨语言调用。
注意事项:
getNativeCanvas()返回的是底层的OH_NativeCanvas指针,不能频繁获取。建议在onReady里只获取一次,然后缓存到 C++ 侧。onDraw的回调频率与 ArkUI 的刷新率一致,默认是 60fps。如果游戏引擎需要独立帧率,可以考虑用requestAnimationFrame替代。
2. C++ 侧:绘制类实现
这一部分是核心。C++ 代码使用 libNativeCanvas.so 提供的 API 完成绘制。
cpp
// NativeHudRender.h
#ifndef NATIVE_HUD_RENDER_H
#define NATIVE_HUD_RENDER_H
#include <napi/native_api.h>
#include <native_drawing/drawing_canvas.h>
class HudRender {
private:
OH_Drawing_Canvas* canvas_ = nullptr;
int32_t width_ = 0;
int32_t height_ = 0;
public:
void Init(OH_Drawing_Canvas* canvas, int32_t width, int32_t height);
void DrawHealthBar(float percent);
void DrawSkillCooldown(float progress);
void DrawMinimap(float playerX, float playerY);
};
#endif
cpp
// NativeHudRender.cpp
#include "NativeHudRender.h"
#include <native_drawing/drawing_brush.h>
#include <native_drawing/drawing_path.h>
void HudRender::Init(OH_Drawing_Canvas* canvas, int32_t width, int32_t height) {
canvas_ = canvas;
width_ = width;
height_ = height;
}
void HudRender::DrawHealthBar(float percent) {
if (percent < 0.0f) percent = 0.0f;
if (percent > 1.0f) percent = 1.0f;
// 血量条背景(灰色)
OH_Drawing_Brush* bgBrush = OH_Drawing_BrushCreate();
OH_Drawing_BrushSetColor(bgBrush, 0xFF333333);
OH_Drawing_CanvasDrawRect(canvas_, 50, height_ - 60, 350, height_ - 40);
// 血量条前景(从红到绿渐变)
uint32_t red = static_cast<uint32_t>((1.0f - percent) * 255);
uint32_t green = static_cast<uint32_t>(percent * 255);
uint32_t color = (0xFF << 24) | (red << 16) | (green << 8) | 0xFF;
OH_Drawing_BrushSetColor(bgBrush, color);
float fillWidth = 300.0f * percent;
OH_Drawing_CanvasDrawRect(canvas_, 50, height_ - 60, 50 + fillWidth, height_ - 40);
OH_Drawing_BrushDestroy(bgBrush);
}
void HudRender::DrawSkillCooldown(float progress) {
// 技能图标背景(灰色圆)
int32_t cx = width_ - 80;
int32_t cy = height_ - 80;
int32_t radius = 30;
// 绘制圆形遮罩:根据 progress 绘制扇形
// 这里用 Path + 弧线实现
OH_Drawing_Path* path = OH_Drawing_PathCreate();
OH_Drawing_PathMoveTo(path, cx, cy);
float startAngle = -90.0f; // 从顶部开始
float sweepAngle = 360.0f * progress;
OH_Drawing_PathArcTo(path, cx - radius, cy - radius, cx + radius, cy + radius, startAngle, sweepAngle);
OH_Drawing_PathClose(path);
OH_Drawing_Brush* skillBrush = OH_Drawing_BrushCreate();
OH_Drawing_BrushSetColor(skillBrush, 0xFF00AABB);
OH_Drawing_CanvasDrawPath(canvas_, path);
OH_Drawing_PathDestroy(path);
OH_Drawing_BrushDestroy(skillBrush);
}
void HudRender::DrawMinimap(float playerX, float playerY) {
// 小地图背景(半透明矩形)
OH_Drawing_Brush* mapBg = OH_Drawing_BrushCreate();
OH_Drawing_BrushSetColor(mapBg, 0x88000000);
OH_Drawing_CanvasDrawRect(canvas_, 10, 10, 210, 210);
// 简单地形:画几条线
OH_Drawing_Path* terrainPath = OH_Drawing_PathCreate();
OH_Drawing_PathMoveTo(terrainPath, 20, 20);
OH_Drawing_PathLineTo(terrainPath, 200, 50);
OH_Drawing_PathLineTo(terrainPath, 150, 200);
// 这里只演示,实际地形数据可以动态传入
OH_Drawing_CanvasDrawPath(canvas_, terrainPath);
// 玩家位置(红点)
int32_t mapCenterX = 110;
int32_t mapCenterY = 110;
int32_t dotX = mapCenterX + static_cast<int32_t>((playerX - 0.5) * 200);
int32_t dotY = mapCenterY + static_cast<int32_t>((playerY - 0.5) * 200);
// 绘制一个半径为5的小圆
OH_Drawing_BrushSetColor(mapBg, 0xFFFF0000);
OH_Drawing_CanvasDrawCircle(canvas_, dotX, dotY, 5);
OH_Drawing_BrushDestroy(mapBg);
OH_Drawing_PathDestroy(terrainPath);
}
代码说明:
- 每个绘制函数都接受浮点数参数,由 ArkTS 侧传入。
- 血量条和技能冷却的绘制逻辑直接使用了 OH_Drawing API,避免了在 ArkTS 侧做复杂计算。
- 小地图的地形绘制使用了 Path,可以扩展为加载地形数据。
性能优化:
- 这里每帧都创建和销毁 Brush、Path 对象。更优的做法是在 Init 里预创建好 Brush 和 Path 对象,每帧只修改颜色和坐标。但这里为了代码清晰,采用了每帧重新创建的方式。实际项目里,建议把 Brush 和 Path 缓存起来,减少内存操作。
3. NAPI 导出
为了让 ArkTS 能够调用 C++ 类,需要写一个 NAPI 绑定。
cpp
// napi_init.cpp
#include <napi/native_api.h>
#include "NativeHudRender.h"
static HudRender* g_render = nullptr;
static napi_value Init(napi_env env, napi_callback_info info) {
size_t argc = 3;
napi_value args[3];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 假设传入的参数是: canvas指针, width, height
// 实际项目中需要通过 getNativeCanvas 获取
return nullptr;
}
static napi_value DrawHud(napi_env env, napi_callback_info info) {
size_t argc = 4;
napi_value args[4];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
double healthPercent, skillCooldown, playerX, playerY;
napi_get_value_double(env, args[0], &healthPercent);
napi_get_value_double(env, args[1], &skillCooldown);
napi_get_value_double(env, args[2], &playerX);
napi_get_value_double(env, args[3], &playerY);
if (g_render) {
g_render->DrawHealthBar(static_cast<float>(healthPercent));
g_render->DrawSkillCooldown(static_cast<float>(skillCooldown));
g_render->DrawMinimap(static_cast<float>(playerX), static_cast<float>(playerY));
}
return nullptr;
}
// NAPI 模块注册
static napi_value InitModule(napi_env env, napi_value exports) {
g_render = new HudRender();
// 注册 "init" 和 "drawHud" 两个方法
napi_value fn_init, fn_draw;
napi_create_function(env, "init", NAPI_AUTO_LENGTH, Init, nullptr, &fn_init);
napi_create_function(env, "drawHud", NAPI_AUTO_LENGTH, DrawHud, nullptr, &fn_draw);
napi_set_named_property(env, exports, "init", fn_init);
napi_set_named_property(env, exports, "drawHud", fn_draw);
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, InitModule)
说明:
- 实际项目中,Init 函数需要处理 Native Canvas 指针的传递。官方推荐的做法是:在 ArkTS 侧通过
Canvas.getNativeCanvas()获取OH_NativeCanvas对象,然后通过 NAPI 传递到 C++ 侧。 - 这里简化了过程,只展示了数据传递和绘制调用。完整实现需要参考官方文档中关于
OH_NativeCanvas的使用方式。
常见问题与踩坑
问题 1:Canvas 绘制圆角不够圆
现象:绘制技能冷却图标的扇形时,边缘出现锯齿,视觉效果不柔滑。
原因 :OH_Drawing_PathArcTo 的曲线绘制在默认情况下没有开启抗锯齿。ArkUI 的 Canvas 其实支持抗锯齿,但在 NDK 侧需要通过 OH_Drawing_CanvasSetAntiAlias 来设置。
解决方案 :在 DrawSkillCooldown 函数开始时,调用 OH_Drawing_CanvasSetAntiAlias(canvas_, true);。注意,这个设置会影响整个 Canvas 的绘制,如果不想影响其他绘制,可以在绘制前保存/恢复 Canvas 状态。
问题 2:ArkUI 的 onDraw 回调在游戏高负载下可能不触发
现象 :当游戏逻辑非常繁忙时,onDraw 回调的间隔变得不稳定,甚至出现短暂卡顿。
原因 :onDraw 是在 ArkUI 的 UI 线程中触发的。如果游戏逻辑阻塞了 UI 线程,ArkUI 就无法及时触发 onDraw。这在纯 ArkTS 方案中同样存在。
解决方案 :将游戏逻辑完全放到独立线程(例如 Worker 或 NAPI 线程)中执行,只把最终数据通过跨线程通信传给 HUD 组件。HUD 组件的更新不依赖游戏逻辑的计算耗时。同时,为了确保 60fps,可以在 HUD 组件中使用 requestAnimationFrame 来主动请求绘制,而不是依赖 onDraw 的被动触发。
最佳实践
- 避免在
onDraw中做过多计算 :onDraw回调的主要职责是绘制,任何非必须的计算都应该提前完成。比如血量百分比、技能冷却进度这些数据,应该在游戏线程中算好,然后直接传给 C++。 - 使用
OH_Drawing_CanvasSave/Restore:如果连续绘制多个 HUD 元素,并且每个元素有自己的抗锯齿、透明度等属性,推荐在绘制前调用Save,绘制后调用Restore。这样可以避免绘制属性相互污染。 - 预分配 Brush 和 Path 对象 :每帧都
Create和Destroy对象会带来额外的内存分配和释放开销。推荐在 Init 阶段创建好对象,然后每帧只修改属性(例如颜色、路径)。这样可以减少 GC 压力,提升稳定性。
完整入口
typescript
// Index.ets
import { HudComponent } from './HudComponent';
@Entry
@Component
struct Index {
build() {
Stack() {
// 游戏场景(示意)
Column() {
Text('游戏画面')
.fontSize(50)
.fontColor(Color.White)
}
.width('100%')
.height('100%')
.backgroundColor(Color.Black)
// HUD 组件覆盖在最上层
HudComponent()
}
.width('100%')
.height('100%')
}
}
FAQ
Q:为什么真机上 HUD 渲染流畅,而在模拟器上偶尔卡顿?
A:模拟器通常无法完全模拟真机的 GPU 驱动和硬件加速效果。模拟器中的 Canvas 操作可能通过软件渲染完成,性能远低于真机。建议以真机为准,模拟器仅用于调试逻辑。
Q:ArkTS 侧的数据更新频率必须和 C++ 绘制频率一致吗?
A:不需要。onDraw 回调的频率由 ArkUI 控制,通常与设备刷新率一致(60Hz 或 90Hz)。数据更新频率可以更高(例如 120Hz),但 C++ 侧只取最新的数据进行绘制。多余的更新会被跳过,不会造成性能浪费。
Q:这个 HUD 组件可以复用给其他页面吗?
A:可以。HudComponent 是一个标准的 ArkTS 组件,可以放在任何页面中。但需要注意,C++ 侧的 Native Canvas 是与具体页面实例绑定的。如果页面切换时需要重新初始化,建议在 aboutToDisappear 中释放 C++ 对象,在 aboutToAppear 中重新初始化。