HarmonyOS Node-API 跨语言性能优化:ArrayBuffer、异步任务与生命周期
ArkTS 调用 C++ 并不自动变快。如果把十万个数拆成十万次跨语言函数调用,边界转换的成本可能比计算本身更高;如果在主线程里直接执行重计算,原生代码同样会卡住界面;如果异步任务仍然引用已经失效的缓冲指针,问题会进一步变成随机崩溃。
本文实现一个批量归一化示例:ArkTS 用ArrayBuffer一次传入整块Int32Array数据,Node-API 在主线程完成参数校验和数据快照,把纯 C++ 计算排入异步工作队列,最后回到原线程创建返回值并兑现 Promise。示例重点不在算法复杂度,而在跨语言责任边界是否正确。
一、先判断任务是否值得下沉到原生层
适合 Node-API 的工作通常具备以下特征:
- 计算量足够大,边界成本相对较小;
- 数据可以批量传输,而不是逐元素回调;
- 算法已有可靠 C/C++ 实现或需要原生库;
- 工作过程不依赖 ArkUI 组件和 ArkTS 对象;
- 可以明确输入、输出、错误和释放时机。
简单字符串拼接、少量字段转换或单个加法没有必要进入原生层。跨语言层应是粗粒度业务能力,而不是把每一行 ArkTS 都包装成 C++ 函数。
二、设计一个窄而稳定的接口
本文只暴露一个函数:输入ArrayBuffer,返回Promise<ArrayBuffer>。ArkTS 类型声明放在entry/src/main/cpp/types/libentry/index.d.ts:
exportconstnormalizeInt32:(input:ArrayBuffer)=>Promise<ArrayBuffer>这种接口有三个优点:
- 一次传入整块连续数据,减少跨语言次数;
- Promise 清楚表达任务不是同步完成;
- 输出仍是缓冲区,调用方可以选择
Int32Array、Uint8Array等视图解释。
接口契约还应写明元素类型、字节序、空输入行为、数值范围和失败类型。仅写ArrayBuffer并不能说明里面装的是什么。
三、ArkTS 侧只负责组织输入和消费结果
import{normalizeInt32}from'libentry.so'exportasyncfunctionnormalizeScores(values:number[]):Promise<Int32Array>{constinput=Int32Array.from(values)constoutputBuffer=awaitnormalizeInt32(input.buffer)returnnewInt32Array(outputBuffer)}调用方不要逐个元素调用原生方法:
// 不建议:边界往返次数与元素数量相同。for(constvalueofvalues){result.push(nativeNormalizeOne(value))}一次调用处理一批数据,通常比“更小的原生函数”更有意义。批量大小也不是越大越好,超大输入会增加复制、等待和峰值内存,需要结合业务分片。
四、CMake只链接必要的Node-API库
entry/src/main/cpp/CMakeLists.txt保持依赖最小:
cmake_minimum_required(VERSION 3.5.0) project(NodeApiBatchDemo) set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR}) add_library(entry SHARED napi_init.cpp) target_link_libraries(entry PUBLIC libace_napi.z.so)如果计算逻辑拆到独立源文件,再显式加入add_library。不要为了一个简单任务链接无关图形、媒体或网络库,否则会增加构建、包体和维护成本。
五、异步上下文要拥有自己的数据
通过napi_get_arraybuffer_info()获得的指针由 JavaScript 引擎管理,不能delete或free。更重要的是,异步工作排队后,原始 ArkTS 缓冲区的生命周期和并发修改都需要谨慎处理。
本文选择在创建任务的线程中复制一份输入,使工作线程只依赖 C++ 自有内存:
#include<algorithm>#include<cstdint>#include<cstring>#include<limits>#include<string>#include<vector>#include"napi/native_api.h"structNormalizeContext{napi_async_work work=nullptr;napi_deferred deferred=nullptr;std::vector<int32_t>input;std::vector<int32_t>output;std::string error;};这次复制有成本,但换来了明确的所有权和线程安全。如果业务要进一步减少复制,需要用引用保持输入对象存活,并严格评估工作期间调用方是否会修改底层数据,复杂度明显更高。
六、参数校验必须在排队前完成
主线程回调中先取得参数,确认是ArrayBuffer,再检查字节长度是否能被int32_t整除:
staticboolReadInt32Buffer(napi_env env,napi_callback_info info,std::vector<int32_t>&output){size_t argc=1;napi_value argv[1]={nullptr};if(napi_get_cb_info(env,info,&argc,argv,nullptr,nullptr)!=napi_ok||argc!=1){napi_throw_type_error(env,nullptr,"Expected one ArrayBuffer");returnfalse;}boolisArrayBuffer=false;if(napi_is_arraybuffer(env,argv[0],&isArrayBuffer)!=napi_ok||!isArrayBuffer){napi_throw_type_error(env,nullptr,"Input must be ArrayBuffer");returnfalse;}void*raw=nullptr;size_t byteLength=0;if(napi_get_arraybuffer_info(env,argv[0],&raw,&byteLength)!=napi_ok){napi_throw_error(env,nullptr,"Cannot read ArrayBuffer");returnfalse;}if(byteLength%sizeof(int32_t)!=0){napi_throw_range_error(env,nullptr,"Invalid Int32 byte length");returnfalse;}if(byteLength==0){output.clear();returntrue;}if(raw==nullptr){napi_throw_error(env,nullptr,"ArrayBuffer data is null");returnfalse;}constauto*begin=static_cast<constint32_t*>(raw);output.assign(begin,begin+byteLength/sizeof(int32_t));returntrue;}当byteLength为 0 时,不应对空指针执行无意义运算。正式实现可以把空输入直接返回空缓冲,或者按接口契约拒绝;无论选择哪一种,都要固定行为。
七、工作线程只运行纯C++计算
napi_create_async_work()的 execute 回调运行在工作线程。这里不能使用原来的env创建napi_value,也不要触碰 ArkUI 状态。
staticvoidExecuteNormalize(napi_env env,void*data){auto*context=static_cast<NormalizeContext*>(data);if(context->input.empty()){context->output.clear();return;}constauto[minIt,maxIt]=std::minmax_element(context->input.begin(),context->input.end());constint64_tminValue=*minIt;constint64_tmaxValue=*maxIt;constint64_trange=maxValue-minValue;context->output.resize(context->input.size());if(range==0){std::fill(context->output.begin(),context->output.end(),0);return;}for(size_t i=0;i<context->input.size();++i){constint64_tshifted=static_cast<int64_t>(context->input[i])-minValue;context->output[i]=static_cast<int32_t>((shifted*1000)/range);}}中间计算使用int64_t,避免int32_t相减和乘法溢出。算法选择和边界类型同样属于接口正确性,不应因为代码进入 C++ 就忽略。
八、完成回调负责创建返回值和释放任务
complete 回调回到原 ArkTS 线程,可以创建ArrayBuffer、兑现 Promise,并删除异步工作。输出缓冲由引擎创建,C++ 只把结果复制进去:
staticnapi_valueCreateError(napi_env env,conststd::string&message){napi_value text=nullptr;napi_value error=nullptr;napi_create_string_utf8(env,message.c_str(),message.size(),&text);napi_create_error(env,nullptr,text,&error);returnerror;}staticvoidCompleteNormalize(napi_env env,napi_status status,void*data){auto*context=static_cast<NormalizeContext*>(data);if(status!=napi_ok||!context->error.empty()){conststd::string message=context->error.empty()?"Async work failed":context->error;napi_reject_deferred(env,context->deferred,CreateError(env,message));}else{void*outputData=nullptr;napi_value arrayBuffer=nullptr;constsize_t bytes=context->output.size()*sizeof(int32_t);if(napi_create_arraybuffer(env,bytes,&outputData,&arrayBuffer)==napi_ok){if(bytes>0){std::memcpy(outputData,context->output.data(),bytes);}napi_resolve_deferred(env,context->deferred,arrayBuffer);}else{napi_reject_deferred(env,context->deferred,CreateError(env,"Cannot create output ArrayBuffer"));}}napi_delete_async_work(env,context->work);deletecontext;}napi_delete_async_work()只调用一次,context也只释放一次。输入指针来自引擎时从未手工释放,复制后的std::vector则随上下文析构。
九、创建Promise、任务并处理排队失败
入口函数把前面几段连接起来:
staticnapi_valueNormalizeInt32(napi_env env,napi_callback_info info){auto*context=newNormalizeContext();if(!ReadInt32Buffer(env,info,context->input)){deletecontext;returnnullptr;}napi_value promise=nullptr;if(napi_create_promise(env,&context->deferred,&promise)!=napi_ok){deletecontext;napi_throw_error(env,nullptr,"Cannot create Promise");returnnullptr;}napi_value resourceName=nullptr;napi_create_string_utf8(env,"NormalizeInt32",NAPI_AUTO_LENGTH,&resourceName);napi_status status=napi_create_async_work(env,nullptr,resourceName,ExecuteNormalize,CompleteNormalize,context,&context->work);if(status!=napi_ok){napi_reject_deferred(env,context->deferred,CreateError(env,"Cannot create async work"));deletecontext;returnpromise;}status=napi_queue_async_work(env,context->work);if(status!=napi_ok){napi_delete_async_work(env,context->work);napi_reject_deferred(env,context->deferred,CreateError(env,"Cannot queue async work"));deletecontext;}returnpromise;}创建失败与排队失败是两条不同清理路径:前者还没有有效 work,后者已经创建但没有执行,需要先删除 work。任何提前返回都要核对上下文、Promise 和 work 各自由谁负责。
十、注册导出函数时保持名称一致
staticnapi_valueInit(napi_env env,napi_value exports){napi_property_descriptor properties[]={{"normalizeInt32",nullptr,NormalizeInt32,nullptr,nullptr,nullptr,napi_default,nullptr}};napi_define_properties(env,exports,sizeof(properties)/sizeof(properties[0]),properties);returnexports;}EXTERN_C_STARTstaticnapi_module entryModule={.nm_version=1,.nm_flags=0,.nm_filename=nullptr,.nm_register_func=Init,.nm_modname="entry",.nm_priv=nullptr,.reserved={0}};EXTERN_C_ENDextern"C"__attribute__((constructor))voidRegisterEntryModule(void){napi_module_register(&entryModule);}nm_modname、生成的共享库名、类型声明目录和 ArkTS import 必须对应。出现“模块能加载但找不到函数”时,先逐项核对这四个名称,不要先怀疑计算逻辑。
十一、为什么这里没有在工作线程直接使用输入指针
napi_get_arraybuffer_info()返回数据地址和长度,但地址所有权仍属于引擎。异步任务至少要回答两个问题:
- 工作执行期间,ArrayBuffer 如何保持可达并且不被回收?
- ArkTS 是否可能同时修改同一块缓冲,形成数据竞争?
复制输入把这两个问题转换成明确的 C++ 所有权,适合多数中等规模计算。若数据巨大且复制成为主要成本,可考虑更高级的零拷贝方案,但必须同时设计引用生命周期、只读约束、并发访问和异常清理,不能只删除memcpy就称为零拷贝。
十二、批量大小需要在延迟和吞吐之间取舍
单次 100 个元素时,异步调度可能比计算本身更贵;单次几千万个元素时,复制和等待又可能造成峰值内存过高。可以对多个批量做基准:
asyncfunctionbenchmarkBatch(size:number):Promise<number>{constinput=newInt32Array(size)for(leti=0;i<size;i++){input[i]=(i*17)%10000}conststart=Date.now()awaitnormalizeInt32(input.buffer)returnDate.now()-start}至少测试 1K、10K、100K 和业务真实上限,并分别记录总耗时、主线程响应、峰值内存和并发任务数。不要用 Debug 单次结果决定 Release 策略。
十三、为并发任务设置入口限流
异步不代表资源无限。如果用户快速重复点按,多个大任务会同时复制输入并占用工作队列。ArkTS 服务层可以限制同一业务只运行一个任务:
classNativeNormalizeService{privaterunning:boolean=falseasyncexecute(input:Int32Array):Promise<Int32Array>{if(this.running){thrownewError('A normalize task is already running')}this.running=truetry{constresult=awaitnormalizeInt32(input.buffer)returnnewInt32Array(result)}finally{this.running=false}}}需要并行时,应根据 CPU、任务长度和内存预算设置上限,并定义排队、取消和页面离开后的结果处理规则。
十四、正确性用边界数据验证
至少覆盖:
1. 空数组 2. 单元素数组 3. 所有元素相同 4. 正数与负数混合 5. INT32_MIN 与 INT32_MAX 6. 字节长度不是4的倍数 7. 传入非ArrayBuffer 8. 连续并发调用 9. 页面离开后任务完成 10. 原生任务创建或排队失败归一化结果还要和一份 ArkTS 参考实现逐项比较。性能优化不能改变算法语义,尤其要关注整数溢出、除零、舍入方式和输出字节解释。
十五、上线前生命周期清单
- 接口按批传输,不在循环中频繁跨语言调用;
ArrayBuffer的元素类型、字节长度和空输入行为已写入契约;- 引擎拥有的输入指针从未被
delete或free; - 工作线程只访问 C++ 自有数据,不创建
napi_value; - Promise 的 resolve/reject 只发生一次;
- create、queue、execute、complete 每条失败路径都能释放资源;
napi_delete_async_work()与上下文析构各执行一次;- 并发入口有限流,超大输入有分片或上限;
- Release 真机比较边界次数、总耗时、主线程响应和峰值内存;
- 边界数据结果与 ArkTS 参考算法一致。
十六、性能来自清晰的跨语言边界
Node-API 性能优化首先是边界设计,其次才是 C++ 算法。用ArrayBuffer把大量元素合并成一次调用,用异步工作把纯计算移出 ArkTS 主线程,再让完成回调负责创建结果和释放任务,三层责任才真正闭合。
示例主动复制输入,是在性能与可证明生命周期之间做出的保守选择。只有基准数据证明复制已经成为瓶颈,并且团队能完整处理引用、并发和异常时,才值得进入更复杂的共享内存方案。
Node-API资料索引
- 华为开发者文档:Node-API开发简介
- 华为开发者文档:Node-API开发规范
- 华为开发者文档:使用Node-API异步任务