这篇不是从“怎么把 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 → CANCELLEDWorker 最终进入STOPPED同时记录到 2 次“晚到回调”被主动丢弃而不是继续向已经退出的页面发事件。一、页面返回以后日志还在刷问题就已经不在 ArkUI 里了最初版本的 ArkTS 很直接页面出现时启动下载Native 回调进度后修改State。页面退出时我只是把isVisible false认为不再渲染就够了。实际上 C 根本不知道页面已经退出。libcurl 仍在自己的执行上下文里读网络、写文件进度回调也会继续产生。只是在 ArkTS 这一侧“看不见”了而已。更麻烦的是Node-API 跨线程回调通常要把 Native 子线程的数据送回 ArkTS/JS 所在环境。官方文档也明确建议耗时任务放到异步工作中跨线程通知使用线程安全的回调机制而不是把耗时逻辑塞进主线程。所以我先把状态拆成两组页面状态VISIBLE / HIDDEN / DESTROYEDNative 任务状态IDLE / RUNNING / CANCELLING / CANCELLED / COMPLETED / FAILED两者不再互相冒充。页面隐藏只代表“不能再把进度发给这个页面”并不等于 Worker 已经停止。二、ArkTS 只发取消意图不假装自己已经停掉 Native这段代码解决的是页面销毁时直接把任务状态改成 CANCELLED导致 UI 状态早于真实 Worker的问题。ArkTS 的aboutToDisappear()只做两件事关闭本页回调门向 Native 发出 cancel。最终CANCELLED必须由 Native Worker 退出以后再确认。importnativeTransferfromlibnative_transfer.soEntryComponentstruct NativeTransferPage{Stateprivatestate:stringIDLEStateprivateprogress:number0privatetaskId:stringcurl_job_20261001_04privatecallbackEnabled:booleantrueaboutToAppear():void{this.callbackEnabledtruethis.startTransfer()}aboutToDisappear():void{this.callbackEnabledfalsethis.stateCANCELLINGnativeTransfer.cancel(this.taskId,PAGE_HIDE)}privateasyncstartTransfer():Promisevoid{this.stateRUNNINGawaitnativeTransfer.start({taskId:this.taskId,url:this.buildDownloadUrl(),targetPath:this.buildTargetPath(),onProgress:(p:number){if(!this.callbackEnabled){return}this.progressp},onFinished:(result){if(!this.callbackEnabled){return}this.stateresult.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*ctxstatic_castTransferContext*(data);if(ctx-cancelled.load()){return1;// 让 libcurl 结束当前传输}PostProgress(ctx,now,total);return0;}voidCancelTransfer(TransferContext*ctx,conststd::stringreason){ctx-cancelReasonreason;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*payloadnewProgressPayload{now,total};napi_status statusnapi_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-easynullptr;}if(ctx-file!nullptr){fclose(ctx-file);ctx-filenullptr;}if(ctx-tsfn!nullptr){napi_release_threadsafe_function(ctx-tsfn,napi_tsfn_release);ctx-tsfnnullptr;}if(ctx-work!nullptr){napi_delete_async_work(env,ctx-work);ctx-worknullptr;}}这里最重要的是“统一出口”。不要在取消分支清 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 是否进入STOPPEDThread-safe callback 是否释放easy handle和文件句柄是否清理页面退出后还有没有新的 UI 回调。这次最终页面保留了一份调试快照任务curl_job_20261001_04在 63% 取消18.4 / 29.2 MB原因PAGE_HIDEWorkerSTOPPED晚到回调丢弃 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-0000002411166425Node-API 异步工作与线程安全函数说明libuv 对照https://developer.huawei.com/consumer/en/doc/harmonyos-references-V13/libuv-V13Hvigor 预构建库快速链接https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-hvigor-so