上一篇咱们把 Node-API 的基本流程走通了。但工程化用起来还会撞上一堆"为什么这样不行"的问题:so 名字改了加载不到、Native 函数里开个线程回调 ArkTS 就 crash、模拟器跑得好好的真机崩了。这篇把 SO 命名、注册、多线程、调试、性能这几块的约束和背后原因讲清楚,省得踩坑靠玄学。
SO 命名规则
ArkTS 侧 import xxx from 'libentry.so' 这一行,背后对应三个地方的名字必须对齐:
| 位置 | 写法 | 取值 |
|---|---|---|
| CMakeLists.txt | add_library(entry SHARED ...) |
entry |
| napi_init.cpp | demoModule.nm_modname = "entry" |
"entry" |
| ArkTS 侧 | import xxx from 'libentry.so' |
libentry.so |
规则是:模块名为 entry,so 文件名就是 libentry.so,nm_modname 字段填 entry(不带 lib 前缀和 .so 后缀),ArkTS import 时写完整的 libentry.so。大小写也要一致,Entry 和 entry 是两个不同的模块。
这个对应关系不是凑巧,是加载链路硬性要求的:ArkTS 引擎拿到 import 'libentry.so',去文件系统找 libentry.so,dlopen 加载;so 加载时跑 RegisterDemoModule,把 nm_modname = "entry" 注册到模块表;ArkTS 引擎再根据 import 路径反查模块表,找到对应 exports 对象。任何一环名字不对,要么 so 找不到,要么模块注册了但查不到,结果都是 undefined。
多模块工程里,每个 Native 模块都要有自己的 so 名,不能图省事都用 entry。比如有 libcrypto.so 和 libimage.so 两个模块,nm_modname 分别是 "crypto" 和 "image",注册入口函数名也要不一样(RegisterCryptoModule、RegisterImageModule),否则符号冲突。
注册建议
Init 函数加 static
cpp
static napi_value Init(napi_env env, napi_value exports) { ... }
这个 static 不是可有可无的。C++ 里函数默认有外部链接,没 static 的话符号会导出到 so 的符号表,两个 so 都有 Init 函数时,链接器可能把调用解析到错误的那个上。加了 static 把符号限制在文件内,就不会串。
注册入口函数名唯一
cpp
extern "C" __attribute__((constructor)) void RegisterDemoModule() { ... }
extern "C" 去掉 C++ name mangling,__attribute__((constructor)) 让 so 加载时自动调用。这个函数名必须全工程唯一。两个 so 都导出 RegisterDemoModule 符号,dlopen 第二个 so 时可能覆盖第一个的符号,导致第一个模块没被注册。
实际工程里建议按模块名命名:RegisterCryptoModule、RegisterImageModule,一眼看出归属,也不容易重名。
重复注册不会生效
同一个 so 被多次 import,napi_module_register 会被调用多次,但模块表里只保留第一次注册的结果。所以不用担心 ArkTS 侧多个文件 import 同一个 so 导致重复注册。
多线程限制
这是 Node-API 工程化里最容易出问题的地方。规则只有一条,但后果很严重:
每个引擎实例对应一个 ArkTS 线程,实例上的对象不能跨线程操作,否则 crash。
拆开看:
- Node-API 接口只能在 ArkTS 线程用 :所有
napi_*函数都假设调用方在创建env的那个线程。在 Native 自己开的 std::thread 里调napi_create_double(env, ...),env 是从 ArkTS 线程拿的,跨线程用,行为未定义,大概率 crash。 - env 和线程绑定 :
napi_env不是个无状态句柄,它背后是当前线程的引擎上下文。同一个 env 不能在两个线程用。 - napi_value 不能跨线程持有 :一个线程创建的
napi_value在另一个线程里是无效的,GC 可能已经把它回收了。
#mermaid-svg-iGpWIiVLqlSJGZ1T{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iGpWIiVLqlSJGZ1T .error-icon{fill:#552222;}#mermaid-svg-iGpWIiVLqlSJGZ1T .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iGpWIiVLqlSJGZ1T .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .marker.cross{stroke:#333333;}#mermaid-svg-iGpWIiVLqlSJGZ1T svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iGpWIiVLqlSJGZ1T p{margin:0;}#mermaid-svg-iGpWIiVLqlSJGZ1T .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .cluster-label text{fill:#333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .cluster-label span{color:#333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .cluster-label span p{background-color:transparent;}#mermaid-svg-iGpWIiVLqlSJGZ1T .label text,#mermaid-svg-iGpWIiVLqlSJGZ1T span{fill:#333;color:#333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .node rect,#mermaid-svg-iGpWIiVLqlSJGZ1T .node circle,#mermaid-svg-iGpWIiVLqlSJGZ1T .node ellipse,#mermaid-svg-iGpWIiVLqlSJGZ1T .node polygon,#mermaid-svg-iGpWIiVLqlSJGZ1T .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iGpWIiVLqlSJGZ1T .rough-node .label text,#mermaid-svg-iGpWIiVLqlSJGZ1T .node .label text,#mermaid-svg-iGpWIiVLqlSJGZ1T .image-shape .label,#mermaid-svg-iGpWIiVLqlSJGZ1T .icon-shape .label{text-anchor:middle;}#mermaid-svg-iGpWIiVLqlSJGZ1T .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iGpWIiVLqlSJGZ1T .rough-node .label,#mermaid-svg-iGpWIiVLqlSJGZ1T .node .label,#mermaid-svg-iGpWIiVLqlSJGZ1T .image-shape .label,#mermaid-svg-iGpWIiVLqlSJGZ1T .icon-shape .label{text-align:center;}#mermaid-svg-iGpWIiVLqlSJGZ1T .node.clickable{cursor:pointer;}#mermaid-svg-iGpWIiVLqlSJGZ1T .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .arrowheadPath{fill:#333333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iGpWIiVLqlSJGZ1T .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iGpWIiVLqlSJGZ1T .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iGpWIiVLqlSJGZ1T .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iGpWIiVLqlSJGZ1T .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iGpWIiVLqlSJGZ1T .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iGpWIiVLqlSJGZ1T .cluster text{fill:#333;}#mermaid-svg-iGpWIiVLqlSJGZ1T .cluster span{color:#333;}#mermaid-svg-iGpWIiVLqlSJGZ1T div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iGpWIiVLqlSJGZ1T .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iGpWIiVLqlSJGZ1T rect.text{fill:none;stroke-width:0;}#mermaid-svg-iGpWIiVLqlSJGZ1T .icon-shape,#mermaid-svg-iGpWIiVLqlSJGZ1T .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iGpWIiVLqlSJGZ1T .icon-shape p,#mermaid-svg-iGpWIiVLqlSJGZ1T .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iGpWIiVLqlSJGZ1T .icon-shape .label rect,#mermaid-svg-iGpWIiVLqlSJGZ1T .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iGpWIiVLqlSJGZ1T .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iGpWIiVLqlSJGZ1T .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iGpWIiVLqlSJGZ1T :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Native子线程
ArkTS线程
异步派发
ArkTS 调 Native 函数
CallNative 执行
env 在此线程有效
返回 napi_value
std::thread 启动
在此线程调 napi_*
❌ env 跨线程
想跨线程怎么办
Native 侧有耗时计算想开线程做,做完了想通知 ArkTS,不能直接拿 env 调 napi_call_function。正确做法是:
- Native 子线程里把数据算完,结果存成 C++ 原生类型(
int、std::string这种,不是napi_value)。 - 通过线程安全的队列把结果传回 ArkTS 线程。
- ArkTS 线程从队列取结果,在 ArkTS 线程里调
napi_create_*包成napi_value返回。
或者用 napi_threadsafe_function(TSFN)这个专门为跨线程回调设计的接口。它创建一个线程安全的函数句柄,任意线程都能往里塞调用请求,引擎在 ArkTS 线程上串行执行。TSFN 的 API 比较繁琐,但跨线程回调 ArkTS 这是唯一靠谱的路。
一个错误示例
cpp
// 错误:在 Native 子线程里用 env 调 ArkTS callback
static napi_value BadAsyncCall(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);
std::thread([env, args]() {
// ❌ env 和 args[0] 都属于 ArkTS 线程,这里用会 crash
napi_value argv;
napi_create_int32(env, 42, &argv);
napi_value result;
napi_call_function(env, nullptr, args[0], 1, &argv, &result);
}).detach();
return nullptr;
}
这段代码在子线程里用 env 创建 napi_value 并调用 callback,运行时大概率 crash,且堆栈看不出明显原因,因为 crash 发生在 GC 或引擎内部状态被破坏之后。这种 bug 极难定位,必须从源头避免。
代码调试
设备选择
| 设备 | 适合场景 | 注意 |
|---|---|---|
| 真机 | 性能调优、Native crash 复现 | 优先选 |
| 模拟器 | 无真机或无权限时 | 部分 Native 行为和真机有差异 |
| 预览器 | ❌ 不能调 Native | 只渲染组件,不加载 so |
预览器调 Native 会报 TypeError: undefined is not callable,因为预览器根本没 dlopen so。这个报错看起来像 ArkTS 写错了,其实是预览器不支持,换模拟器或真机就好。
真机和模拟器差异主要在 CPU 架构(真机 arm64,模拟器 x86_64)和系统库版本。涉及 ABI 细节(如结构体对齐、调用约定)的 Native 代码,模拟器过了真机不一定过,最终验证得在真机。
日志调试
Native 侧打日志用 __hiwrite_log(HiLog):
cpp
#include "hilog/log.h"
#define LOG_TAG "MyNative"
#define LOG_DOMAIN 0x3200
static napi_value CallNative(napi_env env, napi_callback_info info) {
// ... 业务逻辑
OH_LOG_Print(LOG_APP, LOG_INFO, LOG_DOMAIN, LOG_TAG, "CallNative result: %{public}f", sum);
return result;
}
ArkTS 侧的 console.log 和 Native 侧的 HiLog 都能在 hilog 流里看到。Native crash 的堆栈在 crash 发生后用 hdc shell hilog | grep -A 30 "SIGSEGV" 抓。
断点调试
DevEco Studio 支持双端断点:ArkTS 和 C++ 都能下断点,调试时在同一个 session 里来回切。配置好 Native C++ 工程后,Debug 模式运行,ArkTS 调进 Native 时会自动切到 C++ 调试视图,变量、调用栈都能看。
混合调试的前提是 so 带 debug 符号(CMake 里 set(CMAKE_BUILD_TYPE Debug) 或 RelWithDebInfo),release 模式 strip 过符号的 so 断点下不准。
性能注意事项
跨边界开销
每次 ArkTS 调 Native 都有固定开销:
- 参数从
napi_value转成 C++ 类型(napi_get_value_*) - 调用进入 Native 上下文
- 返回值从 C++ 类型包回
napi_value(napi_create_*) - 引擎状态切换
单次调用开销在微秒级,看着不大,但在循环里调几十万次就明显了。
typescript
// 慢:循环里反复跨边界
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum = nativeModule.add(sum, i);
}
// 快:一次跨边界,C++ 里循环
let sum = nativeModule.rangeSum(0, 1000000);
对应的 C++:
cpp
static napi_value RangeSum(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2] = {nullptr};
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
int64_t start, end;
napi_get_value_int64(env, args[0], &start);
napi_get_value_int64(env, args[1], &end);
int64_t sum = 0;
for (int64_t i = start; i < end; i++) {
sum += i; // 纯 C++ 循环,不跨边界
}
napi_value ret;
napi_create_int64(env, sum, &ret);
return ret;
}
实测百万次加法,循环跨边界版本要几十毫秒,一次跨边界版本不到 1 毫秒。差距全在边界开销上。
数据转换成本
不同类型转换成本差很多:
| 类型 | 转换方式 | 成本 |
|---|---|---|
| number (int/double) | napi_get_value_double | 低 |
| boolean | napi_get_value_bool | 低 |
| string | 两次调用拿长度+拷贝 | 中 |
| Object | 逐字段 napi_get_named_property | 高 |
| ArrayBuffer | napi_get_arraybuffer_info 拿指针 | 极低 |
传大块数值数据用 ArrayBuffer,Native 侧直接拿到内存指针操作,没有拷贝:
cpp
static napi_value ProcessArray(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);
void* data = nullptr;
size_t length = 0;
napi_get_arraybuffer_info(env, args[0], &data, &length);
// 直接操作内存,零拷贝
double* arr = static_cast<double*>(data);
size_t n = length / sizeof(double);
for (size_t i = 0; i < n; i++) {
arr[i] = arr[i] * 2.0; // 原地翻倍
}
return nullptr;
}
ArkTS 侧:
typescript
const buf = new ArrayBuffer(8 * 1000); // 1000 个 double
const view = new Float64Array(buf);
for (let i = 0; i < 1000; i++) view[i] = i;
nativeModule.processArray(buf); // 原地修改
图像处理、矩阵运算这种场景,ArrayBuffer 是标配。
举个栗子:Native 侧计时器回调 ArkTS
需求:ArkTS 调 Native 启动一个定时器,N 秒后 Native 回调 ArkTS 通知。子线程里不能直接用 env,得用 TSFN。
cpp
#include <thread>
#include <chrono>
static napi_value StartTimer(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2] = {nullptr};
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
double delayMs;
napi_get_value_double(env, args[0], &delayMs);
// 创建 TSFN:把 ArkTS callback 包装成线程安全句柄
napi_threadsafe_function tsfn;
napi_value workName;
napi_create_string_utf8(env, "TimerCallback", NAPI_AUTO_LENGTH, &workName);
napi_create_threadsafe_function(
env, args[1], nullptr, workName, 0, 1, nullptr, nullptr, nullptr,
[](napi_env env, napi_value /*cb*/, void* /*context*/, void* data) {
// 在 ArkTS 线程执行
napi_value cb = static_cast<napi_value>(data);
napi_value undefined;
napi_get_undefined(env, &undefined);
napi_value result;
napi_call_function(env, undefined, cb, 0, nullptr, &result);
},
&tsfn);
// 子线程里等待,然后通过 TSFN 触发回调
std::thread([tsfn, delayMs]() {
std::this_thread::sleep_for(std::chrono::milliseconds(static_cast<int>(delayMs)));
// Acquire 一次,把 callback 推进队列
napi_acquire_threadsafe_function(tsfn);
napi_call_threadsafe_function(tsfn, nullptr, napi_tsfn_nonblocking);
napi_release_threadsafe_function(tsfn, napi_tsfn_release);
}).detach();
return nullptr;
}
ArkTS 侧:
typescript
import nativeModule from 'libentry.so';
nativeModule.startTimer(2000, () => {
console.log('2 秒到了');
});
TSFN 的 API 看着繁琐,但套路固定:创建时传一个"在 ArkTS 线程执行的回调",子线程里 napi_call_threadsafe_function 把数据塞进去,引擎在 ArkTS 线程上串行调用那个回调。所有跨线程回调 ArkTS 的场景都是这个模式。
注意一下下
- so 改了不生效 :CMake 缓存问题。DevEco 里 Build > Clean Project,再重新 build。或者直接删
entry/build/default目录。 - crash 在 OS_GC_Thread 线程 :堆栈里看到
OS_GC_Thread、CompressGCMarker这类字样,多半是 Native 侧把 napi_value 跨线程持有,GC 移动对象时指针失效。回查 Native 代码里有没有把 napi_value 存到 C++ 全局变量或子线程闭包里。 - 模拟器跑通真机崩 :检查 ABI 相关代码。结构体对齐、
long大小(arm64 是 8 字节,x86_64 也是 8,但 x86 是 4)、未定义行为(UB)在不同架构表现不同。Sanitize 工具能帮着抓 UB。 - import 报 undefined :so 没编出来,或名字不对。先看
entry/build下有没有 libentry.so,再看 CMake 里add_library名字和nm_modname对不对。 - Native 侧内存泄漏 :用
napi_create_*创建的 napi_value 由 GC 管,不用手动释放。但 Native 侧自己malloc/new的内存要自己释放,引擎不会代劳。napi_create_external创建的 external 对象可以挂 finalize 回调,对象被 GC 时回调里释放 Native 内存。
总结一下下
- 跨边界调用次数是性能关键指标,不是单次调用快慢。把 N 次小调用合并成 1 次大调用,性能能差几个数量级。
- TSFN 是跨线程回调的唯一正规路径。别想着用全局变量存 env 然后子线程里调,迟早 crash。
- Native 侧的 C++ 异常不会自动传到 ArkTS,try/catch 在 ArkTS 侧抓不到。Native 侧要么自己 catch 完返回错误码,要么用
napi_throw_error抛 ArkTS 异常。 - 线上 crash 排查靠 hilog 抓堆栈,但 release 包符号被 strip 了,堆栈是地址。要保留一份带符号的 so 做离线符号化,DevEco build 出来的
entry/build/default/obj/default/entry/下有未 strip 的 so。