
上一篇咱们把 Node-API 的基本流程走通了。但工程化用起来还会撞上一堆为什么这样不行的问题so 名字改了加载不到、Native 函数里开个线程回调 ArkTS 就 crash、模拟器跑得好好的真机崩了。这篇把 SO 命名、注册、多线程、调试、性能这几块的约束和背后原因讲清楚省得踩坑靠玄学。SO 命名规则ArkTS 侧import xxx from libentry.so这一行背后对应三个地方的名字必须对齐位置写法取值CMakeLists.txtadd_library(entry SHARED ...)entrynapi_init.cppdemoModule.nm_modname entryentryArkTS 侧import xxx from libentry.solibentry.so规则是模块名为entryso 文件名就是libentry.sonm_modname字段填entry不带 lib 前缀和 .so 后缀ArkTS import 时写完整的libentry.so。大小写也要一致Entry和entry是两个不同的模块。这个对应关系不是凑巧是加载链路硬性要求的ArkTS 引擎拿到import libentry.so去文件系统找libentry.sodlopen 加载so 加载时跑RegisterDemoModule把nm_modname entry注册到模块表ArkTS 引擎再根据 import 路径反查模块表找到对应 exports 对象。任何一环名字不对要么 so 找不到要么模块注册了但查不到结果都是undefined。多模块工程里每个 Native 模块都要有自己的 so 名不能图省事都用entry。比如有libcrypto.so和libimage.so两个模块nm_modname分别是crypto和image注册入口函数名也要不一样RegisterCryptoModule、RegisterImageModule否则符号冲突。注册建议Init 函数加 staticstaticnapi_valueInit(napi_env env,napi_value exports){...}这个static不是可有可无的。C 里函数默认有外部链接没static的话符号会导出到 so 的符号表两个 so 都有Init函数时链接器可能把调用解析到错误的那个上。加了static把符号限制在文件内就不会串。注册入口函数名唯一externC__attribute__((constructor))voidRegisterDemoModule(){...}extern C去掉 C name mangling__attribute__((constructor))让 so 加载时自动调用。这个函数名必须全工程唯一。两个 so 都导出RegisterDemoModule符号dlopen 第二个 so 时可能覆盖第一个的符号导致第一个模块没被注册。实际工程里建议按模块名命名RegisterCryptoModule、RegisterImageModule一眼看出归属也不容易重名。重复注册不会生效同一个 so 被多次 importnapi_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 可能已经把它回收了。Native子线程ArkTS线程异步派发ArkTS 调 Native 函数CallNative 执行env 在此线程有效返回 napi_valuestd::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_functionTSFN这个专门为跨线程回调设计的接口。它创建一个线程安全的函数句柄任意线程都能往里塞调用请求引擎在 ArkTS 线程上串行执行。TSFN 的 API 比较繁琐但跨线程回调 ArkTS 这是唯一靠谱的路。一个错误示例// 错误在 Native 子线程里用 env 调 ArkTS callbackstaticnapi_valueBadAsyncCall(napi_env env,napi_callback_info info){size_t argc1;napi_value args[1]{nullptr};napi_get_cb_info(env,info,argc,args,nullptr,nullptr);std::thread([env,args](){// ❌ env 和 args[0] 都属于 ArkTS 线程这里用会 crashnapi_value argv;napi_create_int32(env,42,argv);napi_value result;napi_call_function(env,nullptr,args[0],1,argv,result);}).detach();returnnullptr;}这段代码在子线程里用 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_logHiLog#includehilog/log.h#defineLOG_TAGMyNative#defineLOG_DOMAIN0x3200staticnapi_valueCallNative(napi_env env,napi_callback_info info){// ... 业务逻辑OH_LOG_Print(LOG_APP,LOG_INFO,LOG_DOMAIN,LOG_TAG,CallNative result: %{public}f,sum);returnresult;}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)或 RelWithDebInforelease 模式 strip 过符号的 so 断点下不准。性能注意事项跨边界开销每次 ArkTS 调 Native 都有固定开销参数从napi_value转成 C 类型napi_get_value_*调用进入 Native 上下文返回值从 C 类型包回napi_valuenapi_create_*引擎状态切换单次调用开销在微秒级看着不大但在循环里调几十万次就明显了。// 慢循环里反复跨边界letsum0;for(leti0;i1000000;i){sumnativeModule.add(sum,i);}// 快一次跨边界C 里循环letsumnativeModule.rangeSum(0,1000000);对应的 Cstaticnapi_valueRangeSum(napi_env env,napi_callback_info info){size_t argc2;napi_value args[2]{nullptr};napi_get_cb_info(env,info,argc,args,nullptr,nullptr);int64_tstart,end;napi_get_value_int64(env,args[0],start);napi_get_value_int64(env,args[1],end);int64_tsum0;for(int64_tistart;iend;i){sumi;// 纯 C 循环不跨边界}napi_value ret;napi_create_int64(env,sum,ret);returnret;}实测百万次加法循环跨边界版本要几十毫秒一次跨边界版本不到 1 毫秒。差距全在边界开销上。数据转换成本不同类型转换成本差很多类型转换方式成本number (int/double)napi_get_value_double低booleannapi_get_value_bool低string两次调用拿长度拷贝中Object逐字段 napi_get_named_property高ArrayBuffernapi_get_arraybuffer_info 拿指针极低传大块数值数据用ArrayBufferNative 侧直接拿到内存指针操作没有拷贝staticnapi_valueProcessArray(napi_env env,napi_callback_info info){size_t argc1;napi_value args[1]{nullptr};napi_get_cb_info(env,info,argc,args,nullptr,nullptr);void*datanullptr;size_t length0;napi_get_arraybuffer_info(env,args[0],data,length);// 直接操作内存零拷贝double*arrstatic_castdouble*(data);size_t nlength/sizeof(double);for(size_t i0;in;i){arr[i]arr[i]*2.0;// 原地翻倍}returnnullptr;}ArkTS 侧constbufnewArrayBuffer(8*1000);// 1000 个 doubleconstviewnewFloat64Array(buf);for(leti0;i1000;i)view[i]i;nativeModule.processArray(buf);// 原地修改图像处理、矩阵运算这种场景ArrayBuffer 是标配。举个栗子Native 侧计时器回调 ArkTS需求ArkTS 调 Native 启动一个定时器N 秒后 Native 回调 ArkTS 通知。子线程里不能直接用 env得用 TSFN。#includethread#includechronostaticnapi_valueStartTimer(napi_env env,napi_callback_info info){size_t argc2;napi_value args[2]{nullptr};napi_get_cb_info(env,info,argc,args,nullptr,nullptr);doubledelayMs;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 cbstatic_castnapi_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_castint(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();returnnullptr;}ArkTS 侧importnativeModulefromlibentry.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 报 undefinedso 没编出来或名字不对。先看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 异常不会自动传到 ArkTStry/catch 在 ArkTS 侧抓不到。Native 侧要么自己 catch 完返回错误码要么用napi_throw_error抛 ArkTS 异常。线上 crash 排查靠 hilog 抓堆栈但 release 包符号被 strip 了堆栈是地址。要保留一份带符号的 so 做离线符号化DevEco build 出来的entry/build/default/obj/default/entry/下有未 strip 的 so。