📱 鸿蒙 HarmonyOS 全栈专栏 · 第 32 篇 / 共 34 篇(第 4 季)
上一篇:HarmonyOS AI 能力接入 | 下一篇:分布式深度:跨端迁移与数据对象

目录

一、NAPI 到底是什么

二、从零跑通:最小的 Native 工程

三、写一个真正有用的函数

四、线程规则:为什么不能在子线程回调 ArkTS

五、移植第三方库:以 ffmpeg 为例

六、日志:C++ 里的输出去哪了

七、崩溃排查:从 tombstone 到具体代码行

八、六个坑与排错表

这一篇你该记住什么


 

这是第 30 篇投票里的第 1 个方向:Native 开发,C++ / ffmpeg / 游戏。

先说清楚它不是什么东西:NAPI 不是「让 C++ 独立跑在鸿蒙上」,而是「让 ArkTS 能调用 C++ 的函数」。你的 C++ 依然是库,界面依然是 ArkTS,中间靠一层薄薄的桥接。

这层桥只有几百行,但它是整个鸿蒙开发里最容易让人卡住的地方——因为出错时你面对的是「undefined symbol」「Cannot find module」这种完全不给方向的报错。这篇就把这条链路整个摊开讲。

📖 本篇导读

全文约 14 分钟,代码基于 DevEco Studio 的 Native C++ 模板结构。

  1. NAPI 到底是什么:三层结构
  2. 从零跑通:最小的 Native 工程
  3. 写一个真正有用的函数(以及类型转换表)
  4. 线程规则:为什么不能在子线程回调 ArkTS
  5. 移植第三方库:以 ffmpeg 为例
  6. 日志:C++ 里的输出去哪了
  7. 崩溃排查:从 tombstone 到具体代码行
  8. 六个坑与一张排错表

一、NAPI 到底是什么

图 1:ArkTS → NAPI 桥接 → C++ 的三层结构

这个结构的核心事实是:两边的内存模型、类型系统、线程模型全都不一样。NAPI 桥接层做的工作,就是在这三种差异上做翻译。

差异ArkTS 侧C++ 侧桥接层做什么
类型number / string / object / arraydouble / char* / struct / pointer逐个转换,不能强转
内存GC 自动回收手动 new / deleteC++ 分配的内存要自己释放
线程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++ 侧转换函数注意
numberdouble / int64_tnapi_get_value_double / napi_create_int64整数要用 int64 系列,否则大数丢精度
stringstd::stringnapi_get_value_string_utf8(两步)必须先取长度再分配
booleanboolnapi_get_value_bool—
ArrayBufferuint8_t*napi_get_arraybuffer_info图像、音频数据的标准通道
objectnapi_valuenapi_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_64build-profile 里同时支持 arm64-v8a 和 x86_64
随机崩溃,日志无规律在子线程碰了 ArkTS 对象改用线程安全函数或 Promise
内存持续上涨C++ 侧 new 了没 delete用 RAII / 智能指针,别裸 new
长文本处理偶发崩溃字符串转换缓冲区越界先取长度再分配,别用固定数组

这一篇你该记住什么

① NAPI 是通道,不是运行时。你的 C++ 还是库,界面还是 ArkTS,别指望用它绕开鸿蒙的 UI 体系。

② 三个名字必须一致,线程规则必须遵守。90% 的新手问题都出在这两条上。

③ 值得上 Native 的判断标准:你有现成的 C/C++ 资产,而不是「听说 Native 更快」。纯业务逻辑用 ArkTS,性能问题先 profile 再决定。

下一篇是鸿蒙最有特色、也最少人讲透的一块:分布式深度——跨端迁移、分布式数据对象、多设备协同,以及那些「看起来能用但一上真机就散」的细节。


如果这篇帮你把豆包用出了点效率,点赞 + 收藏 + 关注 支持一下。
你还有哪个场景没跑通?评论区说清楚你的目标 + 现在怎么问的,我帮你改成能用的提示词。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐