
一、为什么需要 Native 开发
ArkTS 是鸿蒙应用的主语言,绝大多数业务逻辑用它写既快又安全。但当我们计算、处理、渲染的密度超过某个阈值时,纯 ArkTS(基于方舟运行时 AOT/JIT)就会暴露出瓶颈。
先看几个典型场景。
音视频处理是最早拥抱 Native 的领域。解码、滤镜、混音、降噪这类运算,每帧都是几十万次浮点计算,用 ArkTS 逐元素循环几乎不可行;而把 FFmpeg、WebRTC 等成熟 C/C++ 库直接通过 NDK 编译进应用,既省去重写成本,又享受 SIMD 指令加速。
图形渲染里,OpenGL ES / Vulkan 的驱动接口本身就是 C ABI,必须用 Native 层做 EGL 上下文管理和绘制提交,ArkTS 侧只负责声明式 UI 与交互。
高性能算法方面,密码学(AES、SHA、ECC、国密 SM2/3/4)、矩阵运算、图像卷积、物理仿真等,C/C++ 的内存布局可控、零成本抽象、可手动优化缓存命中,性能往往比 ArkTS 高出一个数量级。
加密与安全 还要求把密钥、白盒算法放进 Native 的 .so 里,至少增加逆向分析的难度。
最后,复用存量 C/C++ 库本身就是强需求:很多跨平台引擎(游戏、AI 推理、科学计算)只有 C/C++ 实现,NAPI 是它们在鸿蒙上"开口说话"的桥。
一句话小结:Native 不是炫技,而是当性能、生态或安全约束出现时,鸿蒙给 ArkTS 准备的"后门"。
这条桥,在鸿蒙上叫 NAPI(Native API)。它是 ArkTS 与 C/C++ 之间的标准绑定层,基于 Node.js 的 N-API 思想演进而来,但运行在方舟运行时里,不依赖 V8。理解它,是写好原生模块的前提。
二、NDK 工程配置:让编译器认识你的 C++
一个能跑 Native 代码的鸿蒙工程,和普通 ArkTS 工程最大的区别在三个配置文件的协同:CMakeLists.txt(编译脚本)、build-profile.json5(构建画像)、oh-package.json5(模块元信息)。下面逐个拆解。
2.1 CMakeLists.txt ------ 决定 .so 怎么生成
CMake 是 NDK 默认的构建系统。最简可用的最小配置如下,注意它必须链接 libace_napi.z.so(NAPI 运行时)和 libhilog_ndk.z.so(日志)。
cmake
# CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
project(native_demo)
# 指定 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 收集源文件
file(GLOB NATIVE_SRC *.cpp)
# 生成共享库,模块名 native_demo 会映射到 libnative_demo.so
add_library(native_demo SHARED ${NATIVE_SRC})
# 头文件路径(NDK 自带 napi 头文件)
target_include_directories(native_demo PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${OHOS_NDK}/sysroot/usr/include
)
# 关键:链接 NAPI 与 hilog 运行时
target_link_libraries(native_demo PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
libc++.so
libm.so
)
这里有个容易踩的坑:libace_napi.z.so 末尾的 .z 表示它是运行时提供的系统库,不需要你提供实现,只需声明链接。写错成 libace_napi.so 会在打包阶段报"找不到库"。
2.2 build-profile.json5 ------ 把 CMake 接到构建链
在模块的 build-profile.json5 里,需要声明 externalNativeOptions,告诉 DevEco Studio 用哪个 CMakeLists、针对哪些 ABI 编译。
json5
// build-profile.json5(模块级)
{
"apiType": "stageMode",
"buildOption": {
"externalNativeOptions": {
"path": "./CMakeLists.txt",
"arguments": "",
"cppFlags": "-std=c++17 -Wall",
"abiFilters": [
"arm64-v8a",
"x86_64"
]
}
},
"buildOptionSet": [
{
"name": "release",
"nativeLib": {
"debugSymbol": {
"strip": true,
"exclude": []
}
}
}
],
"targets": [
{
"name": "default"
}
]
}
abiFilters 只保留 arm64-v8a 和 x86_64,覆盖了真机与模拟器;如果为了缩小包体只留 arm64-v8a 也行,但模拟器调试会失败,建议开发期保留两者。
2.3 oh-package.json5 ------ 声明原生依赖
模块自己的 oh-package.json5 要正确声明类型与入口,让 ArkTS 能 import 到编译产物。
json5
// oh-package.json5(native 模块级)
{
"name": "libnative_demo.so",
"version": "1.0.0",
"description": "鸿蒙 NAPI 演示模块",
"main": "index.d.ts",
"types": "index.d.ts",
"dependencies": {},
"devDependencies": {}
}
小结:三个文件各管一件事------CMake 管编译、build-profile 管接入、oh-package 管暴露。三者对齐模块名(
native_demo↔libnative_demo.so),工程才能打通。
三、NAPI 模块开发:C/C++ 侧的核心动作
NAPI 的本质,是让你用一套 C 风格的 napi_* 函数,把 C++ 能力注册成一个 ArkTS 能 import 的对象。它不要求你理解方舟运行时的内部细节,只需要掌握三件事:导出函数、注册模块、数据类型转换。
3.1 napi_value 是什么
ArkTS 里的所有值(数字、字符串、数组、对象、函数)在 Native 侧都被抽象成不透明的 napi_value 句柄。你不能直接读它的值,必须通过 napi_* 函数"拆箱"与"装箱"。
cpp
// napi 头文件是统一的入口
#include "napi/native_api.h"
#include "hilog/log.h"
// 定义一个日志标签
static const OHOS::HiviewDFX::HiLogLabel LABEL = {
LOG_CORE, 0xD001, "NativeDemo"
};
napi_value 类似"智能指针的壳":它引用运行时里的一个值,但句柄本身的生命周期由运行时管理,你不需要手动释放。理解这一点,就不会写出 C++ 式的 delete 去删 napi_value 的低级错误。
3.2 参数解析:从 napi_value 到 C++ 类型
ArkTS 调用原生函数时,传进来的参数是一个 napi_value[] 数组。解析它们最稳妥的方式是用 napi_get_value_* 系列,并配合 napi_typeof 做类型校验,否则用户输入一个字符串就会让 napi_get_value_double 直接抛异常。
cpp
// 解析两个 double 参数,带类型检查
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);
// 类型校验
napi_valuetype type0, type1;
napi_typeof(env, args[0], &type0);
napi_typeof(env, args[1], &type1);
if (type0 != napi_number || type1 != napi_number) {
napi_throw_error(env, nullptr, "参数必须是 number 类型");
return nullptr;
}
// 拆箱成 C++ double
double a = 0.0, b = 0.0;
napi_get_value_double(env, args[0], &a);
napi_get_value_double(env, args[1], &b);
// 后续计算...
double sum = a + b;
// 返回值稍后构造
napi_value result;
napi_create_double(env, sum, &result);
return result;
}
注意 napi_get_cb_info 的第三个参数 &argc 既是输入(期望参数个数)也是输出(实际收到个数)。如果你预留 argc = 2 但用户只传 1 个,args[1] 会是 nullptr,直接解引用会崩溃,所以务必先比对实际个数。
3.3 返回值构造:从 C++ 类型到 napi_value
和解析对称,构造返回值用 napi_create_* 系列。常用的有:napi_create_double、napi_create_int32、napi_create_string_utf8、napi_create_object、napi_create_array、napi_create_bool。
cpp
// 构造一个字符串返回值
static napi_value Greet(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);
// 先确定 ArkTS 字符串长度
size_t len = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len);
// 多留一个字节给 '\0'
std::string name(len + 1, '\0');
napi_get_value_string_utf8(env, args[0], &name[0], len + 1, &len);
std::string greeting = "Hello, " + name + "!";
napi_value result;
napi_create_string_utf8(env, greeting.c_str(), greeting.size(), &result);
return result;
}
字符串处理有个经典陷阱:napi_get_value_string_utf8 需要你提前分配好缓冲区。正确做法是先传空指针拿长度,再分配,再填充------上面两段式调用就是标准范式。如果缓冲区太小,字符串会被截断且不会报错,是隐蔽 bug 的来源。
3.4 注册模块:把函数挂到导出表上
写完导出函数,最后一步是把它们组织成一个 napi_property_descriptor 数组,并在 napi_module 里登记。系统加载 .so 时会调用你指定的入口函数 napi_module_register。
cpp
// 模块入口:注册所有导出方法
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr},
{"greet", nullptr, Greet, nullptr, nullptr, nullptr, napi_default, nullptr}
};
napi_define_properties(env, exports,
sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
// 声明模块元信息
static napi_module demoModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "native_demo", // 与 .so 名对应(去掉 lib 前缀和 .so)
.nm_priv = nullptr,
.reserved = {0}
};
// 必须导出的 C 函数,运行时据此注册
extern "C" __attribute__((constructor)) void RegisterDemoModule() {
napi_module_register(&demoModule);
}
nm_modname 是 ArkTS 侧 import 时使用的名字,必须和 libnative_demo.so 去掉 lib 前缀与 .so 后缀后的 native_demo 完全一致,否则会报"模块不存在"。extern "C" 配合 __attribute__((constructor)) 保证 .so 被加载时自动执行注册,无需手动调用。
小结:导出函数负责"干活",
napi_get_cb_info负责"收活",napi_create_*负责"交活",napi_module_register负责"挂牌"。四步闭环,一个原生函数就活了。
四、ArkTS 侧调用:import 与错误处理
Native 模块在 ArkTS 侧看起来和普通 TS 模块没有区别,只是它的实现在 .so 里。关键在导入路径与异常处理。
4.1 import 原生模块
因为模块名 native_demo 已通过 nm_modname 注册,ArkTS 直接按名字导入即可,无需相对路径。
ts
// Index.ets / 任意 ArkTS 文件
import nativeDemo from 'libnative_demo.so';
// 直接像调用普通函数一样调用
const sum: number = nativeDemo.add(3.5, 4.2);
const msg: string = nativeDemo.greet('HarmonyOS');
console.info(`add 结果: ${sum}`); // 7.7
console.info(`greet 结果: ${msg}`); // Hello, HarmonyOS!
注意 import 的路径写的是 libnative_demo.so 全名(带 lib 和 .so),这点和 C 侧 nm_modname 省略前缀的写法不同,是 DevEco 模块解析层的约定,别混淆。
4.2 错误处理:捕获 Native 抛出的异常
当 Native 侧调用 napi_throw_error 抛异常时,ArkTS 侧会收到一个标准 Error。务必用 try/catch 包住可能失败的调用。
ts
// 错误处理范式
function safeAdd(a: number, b: number): number | string {
try {
return nativeDemo.add(a, b);
} catch (e) {
const err = e as Error;
console.error(`原生调用失败: ${err.message}`);
return `错误: ${err.message}`;
}
}
// 故意传入非法类型(会被 Native 的 napi_typeof 拦截)
const r1 = safeAdd(1, 2);
console.info(`正常调用: ${r1}`);
对于更复杂的调用,建议在 Native 侧用 napi_create_error 构造带 code 的错误对象,ArkTS 侧通过 err.code 区分错误类型,实现更精细的降级逻辑。
小结:ArkTS 侧零感知------它不知道也不关心函数跑在 C++ 里。你只需要记住"异常会原样穿过边界",在调用点兜底即可。
五、综合实战:一个完整可运行的 NAPI 模块
理论讲完,我们把前面所有片段拼成一个真正能编译运行的工程 。实战目标:实现一个 StringUtils 原生模块,提供两个高性能函数------
countChars(text: string): number:统计字符串中的字符数(UTF-8 安全,正确处理多字节中文)。reverse(text: string): string:反转字符串(同样 UTF-8 安全,按码点反转而非按字节)。
为什么选它?字符串按"字节反转"是经典的 Native 陷阱,纯靠 ArkTS 处理多字节也要小心;用 C++ 按 UTF-8 码点操作,既能展示 napi_value 双向转换,又贴近真实需求。
5.1 完整 .cpp 实现(src/main/cpp/native_demo.cpp)
cpp
#include "napi/native_api.h"
#include "hilog/log.h"
#include <string>
#include <vector>
static const OHOS::HiviewDFX::HiLogLabel LABEL = {
LOG_CORE, 0xD001, "NativeDemo"
};
// UTF-8 安全的字符数统计:按码点计数
static napi_value CountChars(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);
napi_valuetype type;
napi_typeof(env, args[0], &type);
if (type != napi_string) {
napi_throw_error(env, "ERR_TYPE", "参数必须是 string");
return nullptr;
}
// 先取长度
size_t len = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len);
std::string s(len + 1, '\0');
napi_get_value_string_utf8(env, args[0], &s[0], len + 1, &len);
// 按 UTF-8 首字节规则累计码点数
int32_t count = 0;
for (unsigned char c : s) {
if ((c & 0xC0) != 0x80) { // 非续字节即为一个码点起点
count++;
}
}
napi_value result;
napi_create_int32(env, count, &result);
return result;
}
// UTF-8 安全的字符串反转:按码点反转
static napi_value Reverse(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);
napi_valuetype type;
napi_typeof(env, args[0], &type);
if (type != napi_string) {
napi_throw_error(env, "ERR_TYPE", "参数必须是 string");
return nullptr;
}
size_t len = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len);
std::string s(len + 1, '\0');
napi_get_value_string_utf8(env, args[0], &s[0], len + 1, &len);
// 切出每个 UTF-8 码点
std::vector<std::string> cps;
for (size_t i = 0; i < len; ) {
unsigned char c = s[i];
size_t clen = (c < 0x80) ? 1 : (c < 0xE0) ? 2 :
(c < 0xF0) ? 3 : 4;
cps.emplace_back(s.substr(i, clen));
i += clen;
}
// 逆序拼接
std::string out;
for (auto it = cps.rbegin(); it != cps.rend(); ++it) {
out += *it;
}
napi_value result;
napi_create_string_utf8(env, out.c_str(), out.size(), &result);
return result;
}
// 注册导出方法
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"countChars", nullptr, CountChars, nullptr, nullptr, nullptr, napi_default, nullptr},
{"reverse", nullptr, Reverse, nullptr, nullptr, nullptr, napi_default, nullptr}
};
napi_define_properties(env, exports,
sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
static napi_module demoModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "native_demo",
.nm_priv = nullptr,
.reserved = {0}
};
extern "C" __attribute__((constructor)) void RegisterDemoModule() {
napi_module_register(&demoModule);
}
这段代码里,CountChars 通过对每个 UTF-8 首字节((c & 0xC0) != 0x80 判断非续字节)计数,避免了把中文当成多个字符;Reverse 先把字符串按码点切成一个个小段(正确处理 1~4 字节字符),再逆序拼接,保证"你好"反转为"好你"而不是乱码字节序列。这正是 Native 层能比"逐字节处理"更可靠地解决问题的小例子。
5.2 类型声明 .d.ts(src/main/cpp/index.d.ts)
类型声明让 ArkTS 获得类型提示与编译期检查,是工程化的关键一步。
ts
// index.d.ts ------ 描述原生模块对外接口
declare namespace nativeDemo {
/**
* 统计字符串的码点(字符)数量,UTF-8 安全
* @param text 输入字符串
* @returns 字符数
*/
function countChars(text: string): number;
/**
* 按码点反转字符串,UTF-8 安全
* @param text 输入字符串
* @returns 反转后的字符串
*/
function reverse(text: string): string;
}
export default nativeDemo;
5.3 ArkTS 调用示例(src/main/ets/pages/Index.ets)
ts
import nativeDemo from 'libnative_demo.so';
@Entry
@Component
struct Index {
@State message: string = '点击测试原生字符串处理';
@State result: string = '';
build() {
Column({ space: 16 }) {
Text(this.message)
.fontSize(18)
.fontWeight(FontWeight.Medium)
Button('统计字符数(你好,世界!)')
.onClick(() => {
try {
const n = nativeDemo.countChars('你好,世界!');
this.result = `字符数 = ${n}`; // 输出 6
} catch (e) {
this.result = `错误: ${(e as Error).message}`;
}
})
Button('反转字符串(HarmonyOS)')
.onClick(() => {
try {
const r = nativeDemo.reverse('HarmonyOS');
this.result = `反转 = ${r}`; // 输出 SOymonoHraH
} catch (e) {
this.result = `错误: ${(e as Error).message}`;
}
})
Text(this.result)
.fontSize(16)
.fontColor('#666666')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding(24)
}
}

运行后点击按钮,countChars('你好,世界!') 会正确返回 6(标点、汉字、感叹号各算一个码点),而不是按字节数出的更大数字;reverse('HarmonyOS') 也得到合法的逆序 ASCII 串。如果你想验证中文反转,把输入换成 '你好' 就能看到 好你,且无乱码------这背后就是 C++ 侧按码点切分的功劳。
5.4 工程目录结构总览
entry/
├── build-profile.json5 # 声明 externalNativeOptions
├── oh-package.json5 # 模块元信息
└── src/main/
├── cpp/
│ ├── CMakeLists.txt # 编译 .so
│ ├── native_demo.cpp # 核心实现 + 注册
│ └── index.d.ts # 类型声明
└── ets/pages/Index.ets # ArkTS 调用侧
实战小结:一个可运行的 NAPI 模块,本质是"配置三件套 + C++ 实现 + 类型声明 + ArkTS 调用"四层的对齐。任何一层名字对不上,都会在前两种错误里反复横跳------所以请反复核对
native_demo这个名在各处的写法差异。
六、避坑清单与进阶建议
走到这里,你已经拥有了一个能编译、能运行、带类型声明、做了 UTF-8 安全和异常兜底的 NAPI 模块。在真实项目里,还有几条经验值得记下。
第一,参数个数校验不要忘。 napi_get_cb_info 之后,先用 argc 比对期望个数,缺参时主动 napi_throw_error,而不是让后面的 args[i] 解引用崩溃。崩溃发生在 Native 侧,往往只显示一个笼统的 SIGSEGV,定位成本极高。
第二,字符串与缓冲区永远两段式。 先传 nullptr 拿长度,再分配,再填充。这个范式适用于所有 napi_get_value_string_* 调用。
第三,跨线程要小心。 NAPI 的 napi_env 与 napi_value 默认绑定到创建它们的 ArkTS 线程。如果你在 Native 侧另起线程做计算,要把结果传回 ArkTS,必须用 napi_create_threadsafe_function 或 napi_create_async_work 把回调抛回主线程,否则直接操作 env 会未定义行为。
第四,性能敏感处考虑异步。 上面的 CountChars/Reverse 执行极快,同步调用没问题;但如果是解码一个大视频帧,应该走 napi_create_async_work 把它塞进 worker 线程,避免阻塞 ArkTS 的 UI 渲染线程。
第五,善用 hilog 而不是 printf。 libhilog_ndk.z.so 提供的日志能直接显示在 DevEco 的 Log 窗口,按 label 过滤,比 stdout 方便得多。
最后给一个进阶方向:当你的 Native 逻辑很重时,可以研究 NAPI 对象与类的封装 (用 napi_define_class 导出 C++ 类实例),把"有状态"的能力(如一个解码器会话)封装成 ArkTS 的对象,而不是一堆无状态函数。它和本文讲的函数式注册是同一套机制,只是 descriptor 换成了方法表,理解本文后很容易上手。
收尾一句:Native 开发的门槛不在 C++ 语法,而在"让 ArkTS 与 C++ 在数据边界上严丝合缝地握手"。把
napi_value的拆箱/装箱、模块注册名、三份配置文件这三件事做对,你就打通了鸿蒙高性能开发的任督二脉。