HarmonyOS Native 开发:让 C++ 真正跑进你的 App
📱 鸿蒙 HarmonyOS 全栈专栏 · 第 32 篇 / 共 34 篇(第 4 季)
上一篇:HarmonyOS AI 能力接入 | 下一篇:分布式深度:跨端迁移与数据对象
目录
这是第 30 篇投票里的第 1 个方向:Native 开发,C++ / ffmpeg / 游戏。
先说清楚它不是什么东西:NAPI 不是「让 C++ 独立跑在鸿蒙上」,而是「让 ArkTS 能调用 C++ 的函数」。你的 C++ 依然是库,界面依然是 ArkTS,中间靠一层薄薄的桥接。
这层桥只有几百行,但它是整个鸿蒙开发里最容易让人卡住的地方——因为出错时你面对的是「undefined symbol」「Cannot find module」这种完全不给方向的报错。这篇就把这条链路整个摊开讲。
📖 本篇导读
全文约 14 分钟,代码基于 DevEco Studio 的 Native C++ 模板结构。
- NAPI 到底是什么:三层结构
- 从零跑通:最小的 Native 工程
- 写一个真正有用的函数(以及类型转换表)
- 线程规则:为什么不能在子线程回调 ArkTS
- 移植第三方库:以 ffmpeg 为例
- 日志:C++ 里的输出去哪了
- 崩溃排查:从 tombstone 到具体代码行
- 六个坑与一张排错表
一、NAPI 到底是什么

图 1:ArkTS → NAPI 桥接 → C++ 的三层结构
这个结构的核心事实是:两边的内存模型、类型系统、线程模型全都不一样。NAPI 桥接层做的工作,就是在这三种差异上做翻译。
| 差异 | ArkTS 侧 | C++ 侧 | 桥接层做什么 |
|---|---|---|---|
| 类型 | number / string / object / array | double / char* / struct / pointer | 逐个转换,不能强转 |
| 内存 | GC 自动回收 | 手动 new / delete | C++ 分配的内存要自己释放 |
| 线程 | UI 主线程 | 可开任意线程 | 子线程不能直接回调,要回主线程 |
理解了这三行,后面所有的报错都能对上号:类型没转对 → 结果错但不崩;内存没释放 → 内存涨;线程搞错 → 直接崩。
二、从零跑通:最小的 Native 工程

图 2:.cpp 是怎么变成 HAP 里的 .so,搞不清这条链路就只能瞎试
DevEco Studio 新建工程时选 Native C++ 模板,会自动生成这套结构:
目录结构
entry/src/main/
├── cpp/
│ ├── CMakeLists.txt # 告诉构建系统生成哪个 so
│ ├── napi_init.cpp # 桥接代码(导出函数的地方)
│ ├── include/ # 你的头文件
│ └── types/libentry/
│ └── Index.d.ts # 给 ArkTS 看的类型声明
└── ets/
└── pages/Index.ets # 调用方
三份关键文件,缺一不可:
cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.5.0)
project(entry)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
include_directories(${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include)
# 这个名字 entry 决定产物是 libentry.so
add_library(entry SHARED
napi_init.cpp
my_logic.cpp # 你自己的实现文件
)
target_link_libraries(entry PUBLIC libace_napi.z.so)
target_link_libraries(entry PUBLIC libhilog_ndk.z.so) # 打日志用
cpp/napi_init.cpp
#include "napi/native_api.h"
#include <string>
// ---------- 一个最简函数:两数相加 ----------
static napi_value Add(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2] = {nullptr, nullptr};
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
double a = 0, b = 0;
napi_get_value_double(env, args[0], &a); // 失败要判返回值,这里省略
napi_get_value_double(env, args[1], &b);
napi_value sum = nullptr;
napi_create_double(env, a + b, &sum);
return sum;
}
// ---------- 注册 ----------
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, exports,
sizeof(desc) / sizeof(desc[0]), desc);
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", // 必须和 CMakeLists 里的库名一致
.nm_priv = nullptr,
.reserved = {0},
};
extern "C" __attribute__((constructor)) void RegisterEntryModule() {
napi_module_register(&demoModule);
}
cpp/types/libentry/Index.d.ts
export const add: (a: number, b: number) => number;
Index.ets
import { hilog } from '@kit.PerformanceAnalysisKit';
import native from 'libentry.so'; // 注意:不是 entry,是 libentry.so
@Entry
@Component
struct Index {
@State result: number = 0;
build() {
Column({ space: 20 }) {
Text(`结果:${this.result}`).fontSize(24)
Button('算一下 3 + 5')
.onClick(() => {
this.result = native.add(3, 5);
hilog.info(0x0000, 'Native', 'result=%{public}d', this.result);
})
}
.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
跑通了这三份文件,你就有了一个能用的通道。后面所有的事都是在里面加函数。
⚠️ 三个名字必须对得上,否则就是那个经典的Cannot find module libentry.so: CMakeLists 里的add_library(entry ...)→ 产物 libentry.so;napi_init.cpp 里的.nm_modname = "entry";ArkTS 里的import ... from 'libentry.so'。三处任意一处不一致都报同一个错。
三、写一个真正有用的函数
加法只是验证通道。真实场景通常是:传一个字符串进来,处理后返回一个对象。这一步涉及类型转换,是新手最容易出错的地方。
返回对象
static napi_value ProcessText(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1] = {nullptr};
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 1. string → char*(走两步:先取长度,再拷贝)
size_t len = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len);
std::string input(len, '\0');
napi_get_value_string_utf8(env, args[0], &input[0], len + 1, &len);
// 2. 你的真实处理
size_t wordCount = 0;
for (char c : input) if (c == ' ') wordCount++;
// 3. 组装返回对象 { words, length }
napi_value obj = nullptr;
napi_create_object(env, &obj);
napi_value words = nullptr, length = nullptr;
napi_create_int64(env, wordCount + 1, &words);
napi_create_int64(env, input.size(), &length);
napi_set_named_property(env, obj, "words", words);
napi_set_named_property(env, obj, "length", length);
return obj; // 对象由 napi 创建,会被 GC 管,不用手动释放
}
类型转换的对照表,建议截图存下来:
| ArkTS 类型 | C++ 侧 | 转换函数 | 注意 |
|---|---|---|---|
| number | double / int64_t | napi_get_value_double / napi_create_int64 | 整数要用 int64 系列,否则大数丢精度 |
| string | std::string | napi_get_value_string_utf8(两步) | 必须先取长度再分配 |
| boolean | bool | napi_get_value_bool | — |
| ArrayBuffer | uint8_t* | napi_get_arraybuffer_info | 图像、音频数据的标准通道 |
| object | napi_value | napi_get_named_property | 要逐个字段取,没有反射 |
| null / undefined | 判类型 | napi_typeof | 不判类型直接取会崩 |
💡 字符串转换那两步是最容易写错的。很多人直接用一个固定大小的 char 数组去接,短文本没事,用户传一段长文本就越界了 —— 这种 bug 只在特定输入下崩,极难复现。
四、线程规则:为什么不能在子线程回调 ArkTS
这是 Native 开发里唯一会「必崩」的规则,必须记死:ArkTS 的对象只能在其所属线程访问,通常是 UI 主线程。你在 C++ 子线程里拿着 napi_value 去 set 属性,直接崩溃。
所以只要有异步(下载、解码、AI 推理),就必须用 线程安全函数(ThreadSafeFunction)把结果「送回」主线程再交给 ArkTS。
线程安全函数
// 思路示意:三件事
// 1. 创建线程安全函数(主线程做)
napi_create_threadsafe_function(env, callback, nullptr, name, 0, 1,
nullptr, nullptr, nullptr, CallJs, &tsfn);
// 2. 子线程里「投递」数据(注意:只投递数据,不碰 ArkTS 对象)
napi_call_threadsafe_function(tsfn, data, napi_tsfn_nonblocking);
// 3. 主线程被回调时,才做 napi_create_* 和返回
static void CallJs(napi_env env, napi_value js_cb, void* context, void* data) {
// 这里的 env 是主线程的,可以安全创建对象
}
如果只是偶尔一次异步(比如加载模型),还有个更省事的办法:让 ArkTS 侧用 async/await 包一层 Promise,C++ 侧用 napi_create_promise 拿到 deferred,完成后在主线程 resolve。比手工管 TSFN 简单。
⚠️ 不要在 C++ 里长期持有
napi_value。它的生命周期由 ArkTS 的 GC 管,你存下来之后可能已经被回收了,再访问就是野指针。
要跨调用保存数据,就拷成 C++ 自己的类型。
五、移植第三方库:以 ffmpeg 为例
这是投票里提到「ffmpeg」的原因——它代表了绝大多数人上 Native 的真实动机:复用现成的 C/C++ 生态。
整体流程四步,每一步都有坑:
| 步骤 | 做什么 | 常见的坑 |
|---|---|---|
| ① 交叉编译 | 用鸿蒙的 NDK 工具链把库编译成 arm64 的 so | --target-os 和 sysroot 要指向 ohos,不能按 linux 编 |
| ② 收敛依赖 | 把需要的子库一起编,或关掉不需要的功能 | ffmpeg 默认要一大堆系统库,configure 要显式裁剪 |
| ③ 写成 NAPI 接口 | 封装成几个 ArkTS 能调的方法 | 别把整个 API 都暴露,只暴露你需要的 3-5 个 |
| ④ 打包进 HAP | 把 so 放到正确的 ABI 目录 | 真机 arm64,模拟器 x86_64,两个都要 |
交叉编译
# 交叉编译的大致思路(具体路径随 SDK 版本变化)
export OHOS_SDK=/path/to/ohos-sdk
export TOOLCHAIN=$OHOS_SDK/native/llvm
./configure \
--target-os=ohos \
--arch=aarch64 \
--cc=$TOOLCHAIN/bin/clang \
--cxx=$TOOLCHAIN/bin/clang++ \
--enable-cross-compile \
--disable-everything \ # 先全关,再按需打开
--enable-decoder=h264 \
--enable-demuxer=mov \
--enable-protocol=file \
--disable-programs \ # 不要命令行工具,只要库
--disable-doc
💡 ffmpeg 的
--disable-everything 是关键一步。默认编出来的 so 有几十 MB,裁到只留你用的几个解码器,能压到 5MB 以内。
先裁后测,不要最后才想起来减体积。
六、日志:C++ 里的输出去哪了
printf 在真机上是看不到的。C++ 侧要用 hilog,而且它有一套自己的规矩,第一次用会觉得「日志怎么没打出来」。
cpp/log.h 用法
#include <hilog/log.h>
#undef LOG_DOMAIN
#undef LOG_TAG
#define LOG_DOMAIN 0x3200 // 自定义域,方便过滤
#define LOG_TAG "MyNative"
void demo() {
OH_LOG_INFO(LOG_APP, "loaded %{public}d items", 42);
OH_LOG_ERROR(LOG_APP, "decode failed, code=%{public}d", -1);
}
⚠️ 格式串里的%{public}前缀不能省。不写的话参数会被当成隐私数据脱敏成<private>,你看到的日志就是「loaded <private> items」——很多人以为日志丢了,其实是这个原因。
看日志的两种方式:DevEco 的 Log 面板(能按 domain 和 tag 过滤),或者命令行 hdc shell hilog | grep MyNative。真机调试建议用命令行,因为断点会打断时序,日志不会。
| 级别 | 什么时候用 | 上线是否保留 |
|---|---|---|
| DEBUG | 开发期的变量追踪 | 关掉,量太大 |
| INFO | 关键流程节点(加载完成、开始解码) | 保留,用于还原用户操作路径 |
| WARN | 能继续但要关注的异常(降级、重试) | 保留 |
| ERROR | 失败,但程序还能活 | 必须保留,配合崩溃日志定位 |
| FATAL | 即将崩溃 | 必须保留 |
Native 崩溃时,hilog 往往是你唯一的线索——因为崩溃栈只给你地址,而崩溃前最后几条日志能告诉你当时在处理什么数据。所以关键分支前后各打一条,成本很低,收益极高。
七、崩溃排查:从 tombstone 到具体代码行
Native 崩溃和 ArkTS 崩溃最大的区别:ArkTS 报错会告诉你在哪个 .ets 的哪一行,Native 崩溃只给你一串地址。
流程是这样的:
① 崩溃后系统会在设备上生成 tombstone 日志,通过 DevEco 的 FaultLogger 能看到。② 日志里有关键几行:崩溃信号(SIGSEGV 一般是空指针/越界)、以及一串 #00 pc 0000000000012345 /data/storage/.../libentry.so。③ 用 NDK 里的 llvm-addr2line 把地址翻译成 文件:行号。
addr2line
# 用 NDK 自带的工具解析
$OHOS_SDK/native/llvm/bin/llvm-addr2line -e libentry.so -f -C 0x12345
# 输出类似:
# ProcessText(napi_env__*, napi_callback_info__*)
# /path/to/napi_init.cpp:42
⚠️ 要解析成功,so 必须是带调试信息的版本(未 strip)。Release 包默认会 strip,所以线上崩溃你解析不出来。正确做法是:构建时保留一份 unstripped 的 so 作为符号表存档,按版本号管理。
另外,日志一定要打。hilog 是 C++ 侧能用的日志接口,在关键分支前后各打一条,崩溃时你就知道走到哪一步了。不要在 release 里全删掉,用 domain 和 level 控制输出即可。
八、六个坑与排错表
| 报错 / 现象 | 原因 | 怎么解 |
|---|---|---|
| Cannot find module libentry.so | 三个名字不一致 | CMakeLists 库名 / nm_modname / import 名对齐 |
| undefined symbol: xxx | 依赖库没链进去,或 ABI 不匹配 | 检查 target_link_libraries 和 abiFilters |
| 模拟器正常,真机崩 | 模拟器是 x86_64 | build-profile 里同时支持 arm64-v8a 和 x86_64 |
| 随机崩溃,日志无规律 | 在子线程碰了 ArkTS 对象 | 改用线程安全函数或 Promise |
| 内存持续上涨 | C++ 侧 new 了没 delete | 用 RAII / 智能指针,别裸 new |
| 长文本处理偶发崩溃 | 字符串转换缓冲区越界 | 先取长度再分配,别用固定数组 |
这一篇你该记住什么
① NAPI 是通道,不是运行时。你的 C++ 还是库,界面还是 ArkTS,别指望用它绕开鸿蒙的 UI 体系。
② 三个名字必须一致,线程规则必须遵守。90% 的新手问题都出在这两条上。
③ 值得上 Native 的判断标准:你有现成的 C/C++ 资产,而不是「听说 Native 更快」。纯业务逻辑用 ArkTS,性能问题先 profile 再决定。
下一篇是鸿蒙最有特色、也最少人讲透的一块:分布式深度——跨端迁移、分布式数据对象、多设备协同,以及那些「看起来能用但一上真机就散」的细节。
如果这篇帮你把豆包用出了点效率,点赞 + 收藏 + 关注 支持一下。
你还有哪个场景没跑通?评论区说清楚你的目标 + 现在怎么问的,我帮你改成能用的提示词。
更多推荐

所有评论(0)