《HarmonyOS技术精讲-UI开发 (基于NDK构建UI)》第9篇:实战——游戏HUD组件开发

这个 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 组件包含三个核心模块:

  1. 血量条:使用 Canvas 绘制矩形条,颜色从绿色渐变到红色。
  2. 技能冷却图标:使用 Canvas 绘制圆形遮罩,根据冷却进度显示扇形填充。
  3. 小地图:使用 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 的被动触发。

最佳实践

  1. 避免在 onDraw 中做过多计算onDraw 回调的主要职责是绘制,任何非必须的计算都应该提前完成。比如血量百分比、技能冷却进度这些数据,应该在游戏线程中算好,然后直接传给 C++。
  2. 使用 OH_Drawing_CanvasSave/Restore :如果连续绘制多个 HUD 元素,并且每个元素有自己的抗锯齿、透明度等属性,推荐在绘制前调用 Save,绘制后调用 Restore。这样可以避免绘制属性相互污染。
  3. 预分配 Brush 和 Path 对象 :每帧都 CreateDestroy 对象会带来额外的内存分配和释放开销。推荐在 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 中重新初始化。