欢迎加入【开源鸿蒙PC社区】,一起共建鸿蒙化C/C++三方库生态。
欢迎在【PC社区】平台贡献你的项目。
仓库: g-truc/glm v0.9.9.9 — OpenGL Mathematics,header-only C++ 数学库
集成平台: 鸿蒙PC| 测试SDK: HarmonyOS 6.1.0(23)
源代码:https://atomgit.com/unisources/OHOSGlmSample

前置说明

项目说明
集成库GLM v0.9.9.9(OpenGL Mathematics)
库类型Header-only(纯头文件,无 .a/.so)
目标平台鸿蒙PC
SDK 版本HarmonyOS 6.1.0(23),兼容 6.0.0(20)
开发工具DevEco Studio
原生编译器BiSheng(build-profile.json5 中 nativeCompiler: BiSheng
ABI 架构arm64-v8a(abiFilters
NAPI 接口数37 个(12 测试套件 + 25 交互接口)
UI 框架ArkTS Stage 模式,三栏桌面布局

在这里插入图片描述

GLM 与一般三方库最大的区别是:它只有头文件。这意味着省去了最痛苦的「交叉编译静态库」环节,但反过来说,C++ 模板的编译时间、BiSheng 编译器对模板的兼容性、header-only 在 NAPI 共享库中的符号导出,反而成了新的关注点。


传统方式的效率瓶颈

先看传统手动集成一条龙要走多少步:

失败

失败

工程搭建

GLM头文件部署

CMake 配置

NAPI 桥接 37接口

ArkTS 类型声明

UI 页面联动

编译测试

阶段主要痛点传统耗时
工程搭建手动建目录、改 module.json5、配 main_pages10分钟
头文件部署GLM 有 200+ 头文件,目录结构嵌套深,放错位置 CMake 找不到5分钟
CMake 配置include_directories 路径拼写、BiSheng 编译器标志15分钟
NAPI 桥接37 个接口,每个都要 napi_get_cb_info + 类型转换 + 返回,模板代码重复60分钟
类型声明Index.d.ts 签名必须与 C++ 精确匹配,number/string 返回类型易错15分钟
UI 联动ArkTS 调用 so、状态管理、实时重算30分钟
编译排错模板报错信息冗长、跨语言调试、链接符号缺失30-120分钟

总计 2.5-4.5 小时,而且大部分时间花在重复的 NAPI 模板代码和编译错误来回切换上。这就是痛点所在——集成一个 header-only 库本不该这么久


AtomCode + Skills 全流程

本次集成全程使用 AtomCode(GLM-5.2 模型)驱动,从工程结构到 UI 还原一气呵成。下面按 7 个环节拆解。

环节 1:工程结构识别

AtomCode 首先扫描工程目录树,识别出这是一个标准的 OpenHarmony Stage 模型工程:

OHOSGlmSample/
├── entry/src/main/
│   ├── cpp/                  ← NAPI 原生层
│   │   ├── CMakeLists.txt
│   │   ├── napi_init.cpp     ← 桥接实现(587行)
│   │   └── thirdparty/glm/include/glm/  ← GLM 头文件
│   ├── ets/pages/Index.ets   ← ArkTS UI
│   └── module.json5
└── build-profile.json5       ← BiSheng + arm64-v8a 配置

关键发现:GLM 已经部署在 thirdparty/glm/include/ 下,build-profile.json5 已指定 BiSheng 编译器。这意味着库部署环节省了,重点在 NAPI 桥接和 UI。

环节 2:CMake 配置(header-only 特化)

GLM 是 header-only,CMake 极简,但有一个细节容易踩坑——不要链接任何 GLM 静态库,只做头文件包含:

cmake_minimum_required(VERSION 3.5.0)
project(OHOSGlmSample)

set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

if(DEFINED PACKAGE_FIND_FILE)
    include(${PACKAGE_FIND_FILE})
endif()

# GLM 是 header-only —— 只包含头文件,不链接 .a/.so
include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include
                    ${NATIVERENDER_ROOT_PATH}/thirdparty/glm/include)

add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC libace_napi.z.so)

三个关键点

  1. thirdparty/glm/include 必须显式包含,否则 #include <glm/glm.hpp> 找不到
  2. 只链接 libace_napi.z.so(NAPI 运行时),GLM 本身零链接
  3. PACKAGE_FIND_FILE 是 OHOS 构建系统注入的,保留以兼容 hvigor

环节 3:NAPI 桥接(37 接口)

这是最繁重的环节。37 个接口分两类:12 个测试套件(返回字符串日志)和 25 个交互接口(返回数值或格式化字符串)。

助手函数:解决字符串返回难题

NAPI 返回字符串给 ArkTS 有个经典陷阱——两段式调用。先调一次拿长度,再调一次拿内容:

static std::string GetStringFromNAPI(napi_env env, napi_value value) {
    size_t bufSize = 0;
    napi_get_value_string_utf8(env, value, nullptr, 0, &bufSize);  // ① 先拿长度
    std::string result(bufSize, '\0');
    napi_get_value_string_utf8(env, value, &result[0], bufSize + 1, &bufSize);  // ② 再拿内容
    return result;
}

static napi_value Str(napi_env env, const std::string& s) {
    napi_value r;
    napi_create_string_utf8(env, s.c_str(), s.length(), &r);
    return r;
}
static napi_value Num(napi_env env, double v) {
    napi_value r;
    napi_create_double(env, v, &r);
    return r;
}

StrNum 两个助手让所有桥接函数的返回值处理统一成一行。这是 NAPI 桥接的第一个效率杠杆——写好助手函数,37 个接口都受益。

典型桥接:向量点积(返回 number)
static napi_value Vec3Dot(napi_env env, napi_callback_info info) {
    size_t argc = 6;
    napi_value a[6] = {};
    napi_get_cb_info(env, info, &argc, a, nullptr, nullptr);
    if (argc < 6) return Num(env, 0);                       // 边界检查
    double v[6];
    for (int i = 0; i < 6; i++) napi_get_value_double(env, a[i], &v[i]);
    return Num(env, glm::dot(
        glm::vec3(v[0], v[1], v[2]),
        glm::vec3(v[3], v[4], v[5])));                      // 调 GLM + 返回
}
典型桥接:向量叉积(返回 string)

叉积返回的是三维向量,NAPI 没有原生 vec3 类型,所以用 snprintf 格式化成 "x,y,z" 字符串:

static napi_value Vec3Cross(napi_env env, napi_callback_info info) {
    size_t argc = 6;
    napi_value a[6] = {};
    napi_get_cb_info(env, info, &argc, a, nullptr, nullptr);
    if (argc < 6) return Str(env, "0,0,0");
    double v[6];
    for (int i = 0; i < 6; i++) napi_get_value_double(env, a[i], &v[i]);
    auto r = glm::cross(glm::vec3(v[0], v[1], v[2]), glm::vec3(v[3], v[4], v[5]));
    char b[64];
    snprintf(b, sizeof(b), "%.2f,%.2f,%.2f", r.x, r.y, r.z);
    return Str(env, b);
}

这里有个精度细节:点积用 %.2f(两位小数够用),归一化用 %.4f(四位小数保证方向精度),折射用 %.3f。不同运算精度不同,统一用一种格式会在可视化时露出马脚。

复杂桥接:向量夹角(含归一化+反余弦)
// Vec3Angle(ax,ay,az,bx,by,bz) -> 角度(度)
static napi_value Vec3Angle(napi_env env, napi_callback_info info) {
    size_t argc = 6;
    napi_value a[6] = {};
    napi_get_cb_info(env, info, &argc, a, nullptr, nullptr);
    if (argc < 6) return Num(env, 0);
    double v[6];
    for (int i = 0; i < 6; i++) napi_get_value_double(env, a[i], &v[i]);
    double ang = glm::degrees(acos(glm::dot(
        glm::normalize(glm::vec3(v[0], v[1], v[2])),
        glm::normalize(glm::vec3(v[3], v[4], v[5])))));
    return Num(env, ang);
}

夹角公式 acos(dot(normalize(A), normalize(B))) 然后转角度。注意 必须先归一化再点积,否则得到的是 |A||B|cosθ 而非 cosθ,新手最容易在这里算错。

模块注册:37 接口一次性导出
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor d[] = {
        // 12 个测试套件
        { "testTrigonometric", nullptr, TestTrigonometric, nullptr, nullptr, nullptr, napi_default, nullptr },
        // ... 省略 11 个 ...
        { "glmFullTest", nullptr, GlmFullTest, nullptr, nullptr, nullptr, napi_default, nullptr },
        // 25 个交互接口
        { "vec3Add", nullptr, Vec3Add, nullptr, nullptr, nullptr, napi_default, nullptr },
        // ... 省略 24 个 ...
        { "lerp", nullptr, Lerp, nullptr, nullptr, nullptr, napi_default, nullptr },
    };
    napi_define_properties(env, exports, sizeof(d)/sizeof(d[0]), d);
    return exports;
}
EXTERN_C_END

static napi_module demoModule = {
    .nm_version = 1, .nm_flags = 0, .nm_filename = nullptr,
    .nm_register_func = Init, .nm_modname = "entry",
    .nm_priv = ((void*)0), .reserved = {0},
};
extern "C" __attribute__((constructor)) void RegisterEntryModule(void) {
    napi_module_register(&demoModule);
}

关键点nm_modname = "entry" 必须与 oh-package.json5 里的依赖名 libentry.so 对应,否则 ArkTS import glm from 'libentry.so' 会报模块未找到。

环节 4:ArkTS 类型声明

Index.d.ts 是 C++ 和 ArkTS 的契约文件。签名必须逐字精确匹配,否则运行时报类型错误。

// 数值返回
export const vec3Length: (x: number, y: number, z: number) => number;
export const vec3Dot: (ax: number, ay: number, az: number, bx: number, by: number, bz: number) => number;
// 字符串返回
export const vec3Add: (x1: number, y1: number, z1: number, x2: number, y2: number, z2: number) => string;
export const vec3Cross: (ax: number, ay: number, az: number, bx: number, by: number, bz: number) => string;
// 7 参数(折射含 eta)
export const vec3Refract: (x: number, y: number, z: number, nx: number, ny: number, nz: number, eta: number) => string;
// 16 参数(4x4 矩阵)
export const mat4Inverse: (m00: number, m01: number, m02: number, m03: number,
    m10: number, m11: number, m12: number, m13: number,
    m20: number, m21: number, m22: number, m23: number,
    m30: number, m31: number, m32: number, m33: number) => string;

返回类型规则:C++ 用 Num(env, x) 返回的,d.ts 写 number;用 Str(env, s) 返回的,写 string。混淆了 ArkTS 侧会把数字当字符串拼接,可视化全乱。

oh-package.json5 里声明依赖路径,让 ArkTS 能 import:

{
  "dependencies": {
    "libentry.so": "file:./src/main/cpp/types/libentry"
  }
}

环节 5:UI 页面联动

UI 要还原一张 GLM vec3 文档设计图——桌面端三栏布局:左侧导航树 + 中间运算主内容 + 右侧 API 信息侧栏。

导入与状态定义
import glm from 'libentry.so';

@Entry
@Component
struct Index {
  @State ax: string = '1'; @State ay: string = '2'; @State az: string = '3';
  @State bx: string = '4'; @State by: string = '5'; @State bz: string = '6';
  // 11 个运算结果 + 6 个卡片结果
  @State resDot: string = ''; @State resCross: string = '';
  // ... 省略 ...
  @State activeTab: string = '运算';
实时重算逻辑

输入框 onChange 时触发重算,一次性算完所有 17 个结果:

private recompute(): void {
  try {
    if (!glm) return;
    const a = this.va; const b = this.vb;
    const ax = a[0], ay = a[1], az = a[2], bx = b[0], by = b[1], bz = b[2];
    this.resDot = String(glm.vec3Dot(ax, ay, az, bx, by, bz));
    this.resCross = glm.vec3Cross(ax, ay, az, bx, by, bz);
    this.resLenA = String(glm.vec3Length(ax, ay, az));
    this.resAngle = String(glm.vec3Angle(ax, ay, az, bx, by, bz));
    this.resReflect = glm.vec3Reflect(ax, ay, az, bx, by, bz);
    this.resRefract = glm.vec3Refract(ax, ay, az, bx, by, bz, 0.5);
    this.rAdd = glm.vec3Add(ax, ay, az, bx, by, bz);
    // ... 其余接口 ...
  } catch (_) {}
}

为什么用 try-catch 包住:NAPI 调用在 so 未加载或参数异常时会抛 JS 异常,不捕获会导致整个页面白屏。if (!glm) return 防止 so 未链接时崩溃。

三栏布局骨架
build() {
  Row() {
    // ① 左侧导航树 220px
    Column() { /* 核心类型/数学函数/扩展/图形/SIMD + GLM 1.0.1 */ }.width(220)
    // ② 中间 + 右侧
    Row() {
      Scroll() { /* 运算页:输入向量/3D可视化/结果表/6卡片/代码 */ }.layoutWeight(1)
      Scroll() { /* API 信息侧栏 240px */ }.width(240)
    }.layoutWeight(1)
  }.width('100%').height('100%')
}

踩坑专区

坑 1:GLM 模板在 BiSheng 编译器下的编译时间爆炸

现象
首次编译 napi_init.cpp(587 行,include 了 glm.hpp + gtc + gtx 多个扩展)耗时异常长,CPU 满载近 2 分钟。

根因
GLM 是重度模板库,每个 glm::vec3glm::mat4 都展开成模板实例化。build-profile.json5 里指定了 nativeCompiler: BiSheng,BiSheng 对深层模板实例化的优化不如标准 clang 激进,且未启用 PCH(预编译头)。

修复
两个手段。一是精简 include,只引真正用到的头,不要一股脑 #include <glm/glm.hpp> 后又引 gtc/gtx 全家桶:

// 精简前(编译慢)
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <glm/gtc/quaternion.hpp>
#include <glm/gtc/random.hpp>
#include <glm/gtc/color_space.hpp>
#include <glm/gtc/constants.hpp>
#include <glm/gtc/round.hpp>
#include <glm/gtc/noise.hpp>
#include <glm/gtx/euler_angles.hpp>
#include <glm/gtx/matrix_decompose.hpp>
#include <glm/gtx/transform.hpp>

// 精简后(按需引入,但本工程确实用到了这些,故保留,改用 PCH)

二是若头确实都要用,在 CMake 启用预编译头:

target_precompile_headers(entry PRIVATE
    ${NATIVERENDER_ROOT_PATH}/thirdparty/glm/include/glm/glm.hpp)

本工程因 12 个测试套件确实覆盖了上述全部模块,保留 include 但接受首次编译耗时,后续增量编译命中缓存即可。

坑 2:NAPI 字符串返回的缓冲区大小陷阱

现象
部分接口返回的字符串在 ArkTS 侧被截断,或末尾出现乱码字符。

根因
napi_create_string_utf8 第三个参数是长度。若传 NAPI_AUTO_LENGTH(即 -1)让它自己算 strlen,对含中文或特殊字符的 UTF-8 字符串没问题;但用 snprintf 生成的字符串如果缓冲区未 \0 结尾,会越界读取。

修复
统一用 stringlength() 而非缓冲区大小,并保证 snprintf 缓冲足够:

static napi_value Str(napi_env env, const std::string& s) {
    napi_value r;
    // 用 s.length() 而非 s.size(),语义更明确
    napi_create_string_utf8(env, s.c_str(), s.length(), &r);
    return r;
}

// snprintf 缓冲要足够,64 字节够装 "x.xxxxxx,x.xxxxxx,x.xxxxxx"
char b[64];
snprintf(b, sizeof(b), "%.4f,%.4f,%.4f", r.x, r.y, r.z);
return Str(env, b);  // b 自动转 std::string,安全

反向读取(ArkTS → C++) 必须两段式,坑 1 已展示。漏掉第一次 napi_get_value_string_utf8(env, value, nullptr, 0, &bufSize) 直接读会段错误。

坑 3:header-only 库的「链接顺序」认知误区

现象
很多人按静态库经验,在 CMake 里写 target_link_libraries(entry PUBLIC libglm.a),结果报找不到库。

根因
GLM 是 header-only,根本没有任何 .a 或 .so 文件。强行链接是徒劳。header-only 库的「链接」实质是编译期头文件展开,运行期零符号。

修复
CMake 只做 include,不做 link:

# 错误 —— GLM 没有静态库
set(LIB_PATH ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/glm/lib/libglm.a)
target_link_libraries(entry PUBLIC ${LIB_PATH})

# 正确 —— 只包含头文件
include_directories(${NATIVERENDER_ROOT_PATH}/thirdparty/glm/include)
add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC libace_napi.z.so)  # 只链 NAPI 运行时

判断一个库是否 header-only 的方法:看其源码目录下有没有 CMakeLists.txtadd_library(... STATIC/SHARED)。GLM 的官方 CMake 只装头文件不编库,即为 header-only。

坑 4:ForEach 缺少 keyGenerator 导致 ArkTS 编译警告/渲染异常

现象
导航树用 ForEach 渲染分组项目,未传第三个参数 keyGenerator 时,DevEco Studio 报 ArkTS 警告,且切换激活项偶尔出现列表项错乱。

根因
ArkTS 的 ForEach 第三个参数是 keyGenerator,用于 Diff 算法标识项。缺省时按数组索引当 key,当数组顺序变化或条件渲染时,复用错位导致 UI 异常。

修复
显式传入唯一 key 生成函数:

// 错误 —— 缺 keyGenerator
ForEach(items, (item: string) => {
  Text(item)
})

// 正确 —— 传 keyGenerator
ForEach(items, (item: string) => {
  Text(item)
}, (item: string) => item)

这是 ArkTS 与 React/Flutter 的差异点——ForEach 的 key 不是可选项,生产代码必须传。


通用集成模板(拿来即用)

Header-only 库 CMakeLists.txt 模板

适用于 GLM 这类纯头文件库(如 fmt、spdlog 的 header-only 模式、catch2):

cmake_minimum_required(VERSION 3.5.0)
project({Project} C CXX)

set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

if(DEFINED PACKAGE_FIND_FILE)
    include(${PACKAGE_FIND_FILE})
endif()

# Header-only —— 只包含,不链接
include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include
                    ${NATIVERENDER_ROOT_PATH}/thirdparty/{lib}/include)

add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC libace_napi.z.so)

# 可选:预编译头加速模板库编译
# target_precompile_headers(entry PRIVATE
#     ${NATIVERENDER_ROOT_PATH}/thirdparty/{lib}/include/{lib}/{lib}.hpp)

静态库 CMakeLists.txt 模板

适用于有 .a 的库(如 libhv、openssl、zlib):

cmake_minimum_required(VERSION 3.5.0)
project({Project} C CXX)

set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
set(LIB_PATH ${NATIVERENDER_ROOT_PATH}/../../../libs/arm64-v8a/lib{lib}.a)

if(NOT EXISTS ${LIB_PATH})
    message(FATAL_ERROR "{lib} not found: ${LIB_PATH}")
endif()

include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include)

add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC libace_napi.z.so)
# 静态库链接顺序:基础库在前,业务库在后
target_link_libraries(entry PUBLIC m pthread ${LIB_PATH})

NAPI 桥接函数 5 步模板

static napi_value MyFunction(napi_env env, napi_callback_info info) {
    // ① 解析参数
    size_t argc = 2;
    napi_value argv[2] = {};
    napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
    // ② 边界检查
    if (argc < 2) return Num(env, 0);  // 或 Str(env, "error")
    // ③ 类型校验(可选,防御性编程)
    napi_valuetype vt;
    napi_typeof(env, argv[0], &vt);
    if (vt != napi_number) return Num(env, 0);
    // ④ 调用 C/C++ API
    double x, y;
    napi_get_value_double(env, argv[0], &x);
    napi_get_value_double(env, argv[1], &y);
    double result = myCApi(x, y);
    // ⑤ 返回 NAPI 值(用 Str/Num 助手统一处理)
    return Num(env, result);
}

异步 NAPI 函数模板(耗时操作)

适用场景:模板重度编译期已过,但运行期仍有耗时操作(大矩阵运算、批量向量)。避免阻塞 UI 线程:

struct AsyncData {
    double input[6];      // 输入参数
    std::string result;   // 输出结果
    std::string error;    // 错误信息
};

static void AsyncExecute(napi_env env, void *data) {
    auto *async = static_cast<AsyncData*>(data);
    // worker 线程执行耗时运算
    glm::vec3 a(async->input[0], async->input[1], async->input[2]);
    glm::vec3 b(async->input[3], async->input[4], async->input[5]);
    auto r = glm::cross(a, b);
    char buf[64];
    snprintf(buf, sizeof(buf), "%.4f,%.4f,%.4f", r.x, r.y, r.z);
    async->result = buf;
}

static void AsyncComplete(napi_env env, napi_status status, void *data) {
    auto *async = static_cast<AsyncData*>(data);
    napi_value ret;
    napi_create_string_utf8(env, async->result.c_str(), async->result.length(), &ret);
    // 实际项目用 napi_resolve_deferred 返回 Promise
    delete async;
}

static napi_value MyAsyncFunction(napi_env env, napi_callback_info info) {
    auto *async = new AsyncData();
    // 解析参数到 async->input
    napi_value work;
    napi_create_async_work(env, nullptr,
        napi_create_string_utf8(env, "GlmWork", NAPI_AUTO_LENGTH, &work),
        AsyncExecute, AsyncComplete, async, &work);
    napi_queue_async_work(env, work);
    return nullptr;  // 实际项目返回 Promise
}

本工程的 vec3 运算都在微秒级,未用异步;但若集成的是 openssl 大数运算或 openssl TLS 握手,必须用异步模板,否则 UI 卡顿明显。


总结

GLM 作为 header-only 库,集成鸿蒙的真正难点不在编译(无静态库),而在 37 个 NAPI 接口的桥接质量ArkTS 签名精确匹配。BiSheng 编译器对模板的编译时间、NAPI 字符串两段式读取、ForEach 的 keyGenerator,是三个最容易被忽视的坑。


你在 NAPI 集成中遇到过什么奇怪的错误?是字符串截断、链接顺序还是 BiSheng 编译器兼容问题?欢迎在评论区分享你的经验。

如果本文对你有帮助,请 点赞、收藏、转发 支持一下~

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐