这篇不是从“怎么把 libcurl 跑起来”开始的。真正让我重写这一段 Native 下载逻辑的,是一个很普通的现象:页面已经返回上一层了,HiLog 里下载进度还在刷;偶尔快速进出两次页面,第二次页面会收到上一条任务的进度。
下载本身没有崩,文件也能落盘,所以最初很容易把它当成一个 UI 更新问题。后来把 ArkTS 页面、Node-API 桥和 C++ Worker 的时间线叠在一起,才发现这是三个生命周期没有对齐:页面先结束,Native 任务还在跑;Native 任务准备回调时,ArkTS 回调对象已经不再应该被触发;用户第二次进入,又创建了新的任务上下文。
这次我单独做了NativeTransferLab。测试任务固定为curl_job_20261001_04,文件总大小 29.2 MB,在 63%、18.4 MB 时离开页面。页面触发取消后,状态从RUNNING → CANCELLING → CANCELLED,Worker 最终进入STOPPED,同时记录到 2 次“晚到回调”被主动丢弃,而不是继续向已经退出的页面发事件。
一、页面返回以后日志还在刷,问题就已经不在 ArkUI 里了
最初版本的 ArkTS 很直接:页面出现时启动下载,Native 回调进度后修改@State。页面退出时我只是把isVisible = false,认为不再渲染就够了。
实际上 C++ 根本不知道页面已经退出。libcurl 仍在自己的执行上下文里读网络、写文件;进度回调也会继续产生。只是在 ArkTS 这一侧“看不见”了而已。
更麻烦的是,Node-API 跨线程回调通常要把 Native 子线程的数据送回 ArkTS/JS 所在环境。官方文档也明确建议耗时任务放到异步工作中,跨线程通知使用线程安全的回调机制,而不是把耗时逻辑塞进主线程。
所以我先把状态拆成两组:
- 页面状态:
VISIBLE / HIDDEN / DESTROYED - Native 任务状态:
IDLE / RUNNING / CANCELLING / CANCELLED / COMPLETED / FAILED
两者不再互相冒充。页面隐藏只代表“不能再把进度发给这个页面”,并不等于 Worker 已经停止。
二、ArkTS 只发取消意图,不假装自己已经停掉 Native
这段代码解决的是页面销毁时直接把任务状态改成 CANCELLED,导致 UI 状态早于真实 Worker的问题。
ArkTS 的aboutToDisappear()只做两件事:关闭本页回调门,向 Native 发出 cancel。最终CANCELLED必须由 Native Worker 退出以后再确认。
importnativeTransferfrom'libnative_transfer.so'@Entry@Componentstruct NativeTransferPage{@Stateprivatestate:string='IDLE'@Stateprivateprogress:number=0privatetaskId:string='curl_job_20261001_04'privatecallbackEnabled:boolean=trueaboutToAppear():void{this.callbackEnabled=truethis.startTransfer()}aboutToDisappear():void{this.callbackEnabled=falsethis.state='CANCELLING'nativeTransfer.cancel(this.taskId,'PAGE_HIDE')}privateasyncstartTransfer():Promise<void>{this.state='RUNNING'awaitnativeTransfer.start({taskId:this.taskId,url:this.buildDownloadUrl(),targetPath:this.buildTargetPath(),onProgress:(p:number)=>{if(!this.callbackEnabled){return}this.progress=p},onFinished:(result)=>{if(!this.callbackEnabled){return}this.state=result.cancelled?'CANCELLED':'COMPLETED'}})}}这里的callbackEnabled不是取消 Native 的手段,只是第一道门。它解决的是页面还没等 Native 完全退出时,晚到的一两个回调不要再改 UI。
正式项目里我不会只依赖布尔值,因为页面重新创建以后,旧任务和新任务都可能存在。我会把taskId + pageGeneration一起作为回调上下文,只有两者都匹配才接收。
三、真正的取消点要放在 libcurl 可以中断传输的位置
Native 侧如果只是设置一个cancelled = true,但 Worker 从不检查它,取消同样只是心理安慰。libcurl 支持在进度回调里返回非零值中止传输,这就给了我们一个稳定的检查点。
这段代码解决的是ArkTS 已经发出 cancel,但 Worker 还继续下载直到文件完成的问题。示例省略了 easy handle 配置,只保留取消链路。
structTransferContext{std::atomic_bool cancelled{false};std::atomic_bool callbackClosed{false};std::atomic_int lateCallbackDropped{0};std::string cancelReason;napi_threadsafe_function tsfn{nullptr};};staticintProgressCallback(void*data,curl_off_t total,curl_off_t now,curl_off_t,curl_off_t){auto*ctx=static_cast<TransferContext*>(data);if(ctx->cancelled.load()){return1;// 让 libcurl 结束当前传输}PostProgress(ctx,now,total);return0;}voidCancelTransfer(TransferContext*ctx,conststd::string&reason){ctx->cancelReason=reason;ctx->callbackClosed.store(true);ctx->cancelled.store(true);}这里我故意先关callbackClosed,再设cancelled。这样从用户点击返回到 Worker 下一次进入ProgressCallback之间,如果又产生一条进度,它也不会被转发给 ArkTS。
当前 Demo 在 63% 离开页面,因此日志会先出现RUNNING -> CANCELLING,随后 Worker 因回调返回非零结束本次传输。最终的错误码还需要区分“用户主动取消”和真正网络失败,不能都映射成FAILED。
四、跨线程回调要有“门”,不能只相信任务马上就会停
子线程结束不是瞬间发生的。取消标记写入和 libcurl 下一次进度回调之间存在时间差;如果此时 Native 还持有线程安全函数,仍然可能排队一条消息。
这段代码解决的是页面已经关掉回调,但 Native 队列里还有晚到消息的问题。
voidPostProgress(TransferContext*ctx,int64_tnow,int64_ttotal){if(ctx->callbackClosed.load()){ctx->lateCallbackDropped.fetch_add(1);return;}auto*payload=newProgressPayload{now,total};napi_status status=napi_call_threadsafe_function(ctx->tsfn,payload,napi_tsfn_nonblocking);if(status!=napi_ok){deletepayload;}}我把“丢弃晚到回调”做成可计数指标,而不是静默 return。原因很实际:如果线上发现lateCallbackDropped经常是几十、几百,说明取消响应太慢,或者 Worker 回调频率太高,后面还要继续优化。
这次测试里是2,在页面退出和 Worker 停止的几十毫秒窗口内出现,属于预期范围。
图里的状态停在CANCELLING,这正是我想保留的中间态。以前页面一返回就直接显示取消成功,日志里其实 Worker 还在继续。现在页面、桥接层、Worker 都有自己的真实状态,排查会容易很多。
五、异步工作对象和 libcurl 资源,要由同一个上下文收口
Native 代码最容易留下的坑,不是某个 API 不会调,而是资源分散在不同函数里:easy handle 在一个函数创建,FILE 指针在另一个函数打开,线程安全函数在初始化阶段创建,异步 work 又在 Node-API 包装层创建。任何一个异常分支没走到完整清理,就会出现泄漏或悬空引用。
我最后把这些资源都归到TransferContext。Worker 完成以后,无论成功、取消还是失败,都走统一FinalizeTransfer()。
voidFinalizeTransfer(napi_env env,TransferContext*ctx){ctx->callbackClosed.store(true);if(ctx->easy!=nullptr){curl_easy_cleanup(ctx->easy);ctx->easy=nullptr;}if(ctx->file!=nullptr){fclose(ctx->file);ctx->file=nullptr;}if(ctx->tsfn!=nullptr){napi_release_threadsafe_function(ctx->tsfn,napi_tsfn_release);ctx->tsfn=nullptr;}if(ctx->work!=nullptr){napi_delete_async_work(env,ctx->work);ctx->work=nullptr;}}这里最重要的是“统一出口”。不要在取消分支清 easy handle,在失败分支关文件,在成功分支 release callback。分支一多,迟早漏一个。
还有一个边界:napi_release_threadsafe_function()以后就不能继续投递消息,所以 release 之前必须确保 Worker 不再走PostProgress()。这也是为什么 context 里需要callbackClosed和明确的 Worker 退出顺序。
六、页面再次进入时,不复用旧 TaskContext
快速返回再进入是这次复现 bug 最稳定的方式。旧版本里,我用一个全局 native 单例保存下载上下文。第二次页面启动,新的 callback 覆盖旧 callback,但旧 Worker 还没完全结束,于是旧任务进度被送到了新页面。
修复后,taskId是上下文唯一键。curl_job_20261001_04的 context 在 finalizer 完成以前不会被新任务覆盖。新页面如果再次发起下载,要么生成新 taskId,要么等待旧任务完成回收。
从产品体验看,可能觉得“马上重开下载”更重要;从工程稳定性看,我宁可多等几十毫秒,也不愿让两个 Worker 共用一个 callback 句柄。
七、最终验收不只看文件,还看任务有没有真的死干净
之前我的验收只有一个:目标文件是否存在。现在多了四项:
- Worker 是否进入
STOPPED; - Thread-safe callback 是否释放;
easy handle和文件句柄是否清理;- 页面退出后还有没有新的 UI 回调。
这次最终页面保留了一份调试快照:任务curl_job_20261001_04在 63% 取消,18.4 / 29.2 MB,原因PAGE_HIDE,WorkerSTOPPED,晚到回调丢弃 2 条。状态完整走过RUNNING → CANCELLING → CANCELLED。
这里还有一个容易混淆的点:主动取消并不等于“下载失败”。正式业务最好把CANCELLED做成单独状态,否则埋点里会把用户正常返回也算成网络失败,后面分析错误率会完全跑偏。
八、Node-API 这一层,最大的价值是把边界写清楚
HarmonyOS 上做 Native 能力时,很容易一开始只关注“ArkTS 能不能调 C++”。真正进入产品以后,跨语言边界带来的问题反而更多:谁拥有资源、谁发起取消、谁确认完成、回调在哪个线程、页面销毁后谁还能继续发消息。
官方关于 Node-API 的文档强调,C/C++ 能力通过桥接暴露给 ArkTS/JS;异步任务和跨线程通知需要使用对应的异步工作与线程安全机制。Hvigor 对预构建 so 的链接也已经比较顺手,libcurl 这类依赖可以通过 CMake 纳入工程。
这次改造没有让下载速度更快,却把快速进出、主动取消和重复启动这几条路径稳定了下来。对 Native 桥接代码,我现在会先画生命周期图,再写接口。下一步即使加入超时、断点续传或后台策略,也都建立在“可取消、可确认、可释放”这条基线上。
参考资料
- HarmonyOS Node-API 跨语言调用:https://developer.huawei.com/consumer/cn/doc/doccenter-games/games-universal-using-napi-interaction-0000002411166425
- Node-API 异步工作与线程安全函数说明(libuv 对照):https://developer.huawei.com/consumer/en/doc/harmonyos-references-V13/libuv-V13
- Hvigor 预构建库快速链接:https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-hvigor-so