把 aria2 搬进鸿蒙 PC:一个多协议下载器的原生移植实践
把 aria2 搬进鸿蒙 PC:一个多协议下载器的原生移植实践
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_FDM
在鸿蒙 PC 上,下载文件常常是个麻烦事:浏览器自带的下载不支持多线程加速,新上线就断头;BT 种子和磁力链接更是无处安放。本文记录「织流Aria下载器」的完整移植过程——把久经考验的开源下载引擎 aria2 编译进 HarmonyOS 应用,用 ArkTS 原生界面重写管理端,一次「核心复用 + 界面重写」的混合移植(T1)实战。

一、先聊聊痛点:PC 上下载这件「小事」
很多人以为「下载」是操作系统早就解决好的小事,直到真正在 PC 上深度使用才发现并非如此:
- 浏览器下载器太弱:单线程 HTTP 下载,遇到限速服务器就只能跑满「单连接速度」;网络一抖动任务失败,重来一次又是从头开始;
- 断点续传形同虚设:多数下载路径并不支持从已下载部分继续,几十 MB 的更新包每次重下;
- BT / 磁力链接没有家:Windows 上有成熟的 BT 客户端,换成鸿蒙 PC 之后,种子和磁力链接无人接管;
- 多任务管理无从谈起:排队、限速、任务分组、完成通知,这些下载管理器的「基本功」,浏览器一个都不会。
把视角再拉开一层:这些问题单独看都是「小麻烦」,合在一起就是「PC 上干不了正事」。下载是所有数字工作的入口——装环境、取资料、同步素材,入口不顺,后面全堵。鸿蒙 PC 作为新生态,用户从 Windows / macOS 迁移过来时,不会因为「生态还在早期」就降低对基础工具的期待。浏览器下载器再强,也强不过一个专业下载器,这是二十年来桌面市场反复验证过的结论。
aria2 正是为解决这些痛点而生的开源下载引擎:轻量、多协议、多连接、命令行即用,被无数下载器、NAS 套件、影视自动化工具(Sonarr / Radvin 等)当作背后的下载核心。既然如此,为什么不把 aria2 直接搬上鸿蒙 PC?
想象一个具体场景:你在鸿蒙 PC 上刷到一个 2 GB 的镜像包,浏览器下载跑了半小时还剩大半,中途接了个电话回来发现网络抖动把任务弄丢了,只能从头再来。如果这时候有一个应用,能自动把文件切成 8 段同时拉、断了能续、BT 链接直接粘贴就能下、下完的文件一键转存到「下载」文件夹——下载这件事才算真正「解决」了。这正是本项目的出发点:不是做一个「能下载」的应用,而是做一个「下载体验对齐桌面成熟工具」的应用。
二、认识两位主角
2.1 aria2:下载器背后的「引擎」
aria2 是一个 C++ 编写的多协议下载命令行工具,核心能力包括:
| 能力 | 说明 |
|---|---|
| 多协议 | HTTP / HTTPS / FTP / BitTorrent / Metalink |
| 多连接分块 | 单任务最多可开数十个连接分段下载,吃满限速服务器前的带宽 |
| 断点续传 | 控制文件记录下载状态,中断后精准续传 |
| BT / DHT | 磁力链接、种子、DHT 网络、PEX 对等交换 |
| RPC 接口 | 内置 JSON-RPC 服务,可完整远程管理全部任务 |
| 跨平台 | Linux / macOS / Windows 原生编译,静态依赖少 |
一个有意思的事实:市面上大量「下载加速」工具,核心都是 aria2。它相当于下载界的「发动机」——可靠、无感,但缺一个为鸿蒙 PC 量身定做的「驾驶舱」。
从工程视角看,aria2 的内部结构也堪称「为移植而生」:核心下载逻辑与传输层解耦,WebSocket 通信由独立的 wslay 库承担,TLS 走 OpenSSL,三者都以静态库形式交付。这意味着移植时不需要解决复杂的动态依赖问题——把 libaria2.a、libwslay.a、libssl.a、libcrypto.a 四个静态库连同入口目标文件链接进 NAPI 模块,引擎能力就完整进入了应用进程。本移植使用的版本是 aria2 1.37.0,搭配 wslay 与 OpenSSL,全部面向 aarch64 / bionic libc 交叉编译。
2.2 织流Aria下载器:给 aria2 造一个鸿蒙 PC 上的驾驶舱
「织流Aria下载器」就是一个完整的鸿蒙 PC 下载管理器:把 aria2 引擎编译进应用,配上原生 ArkTS 界面,用户不需要懂任何命令行,打开应用、粘贴链接、点「添加」,剩下的交给引擎。
整个应用的代码结构也值得一看:ArkTS 侧只有六个左右的核心文件(入口 Ability、主界面、关于页、控制器、RPC 客户端、任务模型、引擎引导、文件导出),native 侧只有两个 .cpp(子进程管理与进程内引擎)加一层薄 NAPI 注册。没有框架、没有中间层、没有过度抽象——移植项目最怕的不是代码少,而是「为了架构而架构」,把简单问题包进复杂层次。本项目的每个文件都能在几分钟内读完,每个函数都只说一件事。
应用定位非常克制——只做下载管理这一件事:任务列表、实时速度、断点续传、BT 支持、文件导出,不做浏览器、不做资源搜索、不做任何多余功能。它是一个 HAP 应用,arm64-v8a 原生运行,性能与桌面端无异。
「克制」本身也是设计决策。PC 工具类应用的信任感来自 predictability:按钮在哪里、点了发生什么、状态怎么变,全部符合直觉。所以界面采用最经典的「连接区 + 状态区 + 输入区 + 列表区」结构,没有引导页、没有弹窗轰炸、没有红点通知——下载器是拿来用的,不是拿来逛的。同理,关于页把技术架构和功能清单直接摊开给用户看:用了什么引擎、怎么通信、打的是多大的包,坦坦荡荡。对开发者用户而言,这种透明度本身就是一种professional signal。

主界面:顶部为 RPC 连接信息(地址 / 端口 / 密钥),下方是引擎状态与全局速度,任务列表支持暂停、继续、删除、导出。
2.3 为什么不直接用「AriaNg + 远程 aria2」
有人会说:局域网上跑一台 aria2,用 AriaNg 网页管理不就行了?这个方案在桌面环境确实流行,但搬到鸿蒙 PC 上有三个绕不开的问题:
- 要有第二台机器:下载器应该是单机自给自足的应用,不该要求用户家里常驻一台跑着 aria2 的 NAS 或服务器;
- 网页界面不适配 PC:AriaNg 是给桌面浏览器设计的页面,塞进 Web 容器后窗口缩放、输入法、系统暗色模式处处别扭;
- 脱离系统生态:文件导出要绕「SMB 共享」,系统文件选择器、分屏窗口这些原生能力一概用不上。
把引擎装进应用本体,才是 PC 下载器该有的形态:单机可用、原生体验、文件随手可取。
三、鸿蒙 PC 移植:先想清楚「怎么搬」,再动手
把桌面软件搬到鸿蒙 PC,最常见的失误是上来就写代码。正确顺序是先回答一个问题:搬什么、留什么、扔什么。软件移植能力通常分三个等级:
T0:原生实现
UI 与业务逻辑全部用 ArkTS / ArkUI 重写。体验最好——完整继承多窗口、分屏、无障碍等系统能力,包体最小。代价是工作量最大:协议栈、算法这类「重资产」要逐行重写,任何偏差都会在真实环境下暴露。适合界面为主、核心逻辑轻的应用。
T1:混合实现
ArkTS / ArkUI 原生写界面,C/C++ 核心源码交叉编译后链接进应用,通过 NAPI 桥接调用。核心逻辑直接复用上游代码,协议行为与桌面版完全一致;用户看到的界面又完全是原生的。代价是要打通交叉编译,并处理沙箱带来的运行时差异。
T2:容器承载
用兼容容器(Web 容器、Electron 类运行时、Qt 移植层)整体承载原程序。迁移最快、保真度最高,但包体动辄膨胀几十倍,内存和后台驻留都是负担,系统能力跟随受容器限制。
T2 不是「躺平选项」,它有明确的适用边界:业务逻辑庞大且迭代频繁、短期验证市场、或者原程序与桌面环境深度绑定(比如依赖注册表、系统服务、多进程协作)时,容器是唯一现实的选择。但对下载器这种「核心就一个引擎、界面就一个列表」的软件上 T2,等于用集装箱运一辆自行车——能到,但每个环节都在付出本可避免的代价。
三等级速查
| 维度 | T0 原生 | T1 混合 | T2 容器 |
|---|---|---|---|
| 原始代码复用 | 无 | 核心层复用 | 整体复用 |
| UI 原生程度 | 100% | 100%(UI 层原生) | 取决于容器 |
| 包体 | 最小 | 小 | 大 |
| 内存 / 启动 | 最优 | 优 | 一般 |
| 移植工作量 | 最大 | 中 | 最小 |
| 协议/算法保真 | 依赖重写质量 | 高(上游源码原样编译) | 高 |
| 系统能力跟随 | 完整 | 完整(UI 层) | 受容器限制 |
为什么选 T1
下载器是典型的「重引擎、薄界面」软件:
- 协议栈不值得重写:多连接调度、分块合并、DHT、PEX、Metalink 解析,是社区二十年的积累。T0 重写等于把所有协议坑重踩一遍——下载器失败的表象只有一个「下不动」,偏差零容忍;
- 界面不值得套容器:任务列表 + 新建 + 统计,用 ArkUI 重写成本极低;为一个薄界面背上整套运行时,T2 的账怎么算都亏;
- 结论:引擎按 T1 编译复用,界面按 T0 标准重写,全程不碰容器。
这个判断对同类「桌面 POSIX 软件 → 鸿蒙 PC」移植普遍适用:核心资产在上游源码里的,别重写;界面逻辑本来就薄的,别套壳。等级不是排名,是策略选型。
选 T1 之后的实施路径也是有套路的,本项目按四步走:
- 验证可行性:先用最小工程把上游核心编译成静态库并链接进 NAPI 模块,跑通「从 ArkTS 调用到 native 返回」的最短路径。这一步的交付物不是功能,是「这条路走得通」的结论;
- 解决运行时差异:沙箱策略(exec 受限)、文件路径(filesDir)、线程模型(后台线程生命周期),逐项在最小工程里验证降级方案,形成可复用的桥接层;
- 搭界面骨架:控制器 + 模型先行,界面后补。先把 RPC 客户端和任务模型写扎实,界面用最简列表验证数据流,再迭代视觉与交互;
- 打磨体验:响应式布局、事件刷新、日志查看器、导出流程,这些「原生细节」放在最后集中攻克,避免前期陷入像素级调整而忽略架构风险。
顺序上「先证明走得通,再谈走得好」——移植项目最大的风险不是界面不好看,而是核心方案在沙箱里根本跑不起来,那意味着前三周工作全部推倒。
四、整体架构:三层解耦

架构上只有一个核心决策值得说:UI 与引擎之间走标准 JSON-RPC,而不是直接函数调用。aria2 自带完整的 RPC 接口(aria2.addUri / aria2.tellActive / aria2.pause……),这是上游多年稳定的契约。复用它带来三个好处:
- 运行方式透明:引擎跑在子进程、跑在应用进程内、还是跑在局域网另一台机器上,UI 层代码零改动——远程模式只是把「本地引擎」换成「远程地址」;
- 协议行为等价:RPC 参数与命令行参数一一对应,桌面版 aria2 用户的学习成本直接平移;
- 桥接面最小化:NAPI 只管引擎生命周期,传输协议交给系统 WebSocket,谁都不用造轮子。
一次典型的「添加下载」时序是这样的:
用户点「添加」
→ UI 调 Aria2Rpc.call('aria2.addUri', [token, [url], options])
→ WebSocket 发出 JSON-RPC 请求(id 自增)
→ aria2 接收,创建任务,返回 gid
→ 引擎推送 aria2.onDownloadStart 事件(无 id,method 分发)
→ 控制器 1 秒轮询 tellActive/tellWaiting/tellStopped/getGlobalStat
→ 快照写回 @State,任务卡片刷新
事件(onXxx)负责「发生了什么」,轮询负责「现在什么样」——两者互补,谁也不用替代谁。
三层各自的职责边界也很清晰。UI 层只管渲染与交互:把控制器交下来的快照画成任务卡片,把用户的点击翻译成命令。模型层(Aria2Rpc / Aria2Task / EngineBootstrap / FileExporter)是纯逻辑,不 import 任何 ArkUI 组件,可以整体搬去测试。桥接层(NAPI)只回答两类问题:引擎起没起、进程活没活。任何一层的实现细节变化都被这层边界挡回去——比如引擎以后换运行方式,动的是 EngineBootstrap 一个文件。
五、第一个硬骨头:沙箱不让 exec 子进程
HarmonyOS 沙箱策略下,应用 fork/exec 子进程通常被拒绝(posix_spawn 返回 EPERM/EACCES)。而 aria2 最自然的用法恰恰是「拉起一个 aria2c 进程」。这是整个移植中第一个真正的架构级问题。
解法:双模式引擎,先 A 后 B
启动引擎
├─ 模式 A:NAPI 封装 posix_spawn 拉起 aria2c 子进程
│ └─ 成功 → 子进程模式(隔离性最好,崩溃不影响应用)
│ └─ 失败(沙箱禁 exec)→ 记录 errno,回退
└─ 模式 B:在应用后台线程直接运行 aria2::main()
└─ RPC 服务器在 App 进程内监听 127.0.0.1:6800
- 模式 A:引擎二进制随包分发,首启时从 rawfile 释放到应用私有目录(幂等,已存在则跳过),加
fsync落盘后再拉起。子进程天然隔离——引擎崩了,界面还在; - 模式 B:把 aria2 的
main()直接跑在后台线程上。对 UI 层而言两种模式完全等价:都是连127.0.0.1:6800的 WebSocket,界面上还会显示当前是「子进程」还是「进程内」模式,方便诊断。
两种模式的停机逻辑也不一样:子进程模式直接 kill(pid);进程内模式的 aria2::main() 没有干净的关闭入口,只能置位状态、随应用进程退出而终止——任务进度由 --continue 和 aria2 的控制文件在下次启动时兜底恢复。这个「不完美但可接受」的取舍,写在代码注释里,也写在文档的「已知限制」里。
模式 B 的两个致命细节
进程内跑 aria2 听起来简单(不就是调个函数吗),实际上有两个必踩的坑:
坑一:console 未初始化,启动即空指针崩溃。 aria2 内部日志对象依赖 global::cout(),进入 aria2::main() 之前必须先调 aria2::global::initConsole(false)——这是上游可执行文件入口自己做的事,照抄即可,但没人告诉你就会以「启动秒崩」的方式出现。
坑二:argv 生命周期。 后台线程持有的参数字符串必须自己拷贝一份(std::vector<std::string> 全局缓冲),再组装 char* 数组传入。直接传栈上字符串的指针,线程跑起来就是未定义行为。
核心代码其实很薄——真正的工程量都在「把参数准备对」:
static std::vector<std::string> g_args; // argv 字符串缓冲(线程生命周期内有效)
static void RunEngineThread() {
if (!g_running) return;
try {
// 与 aria2 可执行文件的 main() 保持一致:先初始化 console 输出
aria2::global::initConsole(false);
aria2::main(static_cast<int>(g_argv.size()), g_argv.data());
} catch (...) {
// 引擎异常退出
}
g_running = false;
}
这两个坑都在源码注释里留了案底,后来人看一眼注释能省半天调试。
启动的生命周期:从点击到「已连接」
把整个启动链路展开,能看到 ArkTS 侧的编排逻辑:
- 就位:
ensureBinary()检查filesDir/aria2c,不存在则从 rawfile 读出并fsync落盘(幂等,重复启动不重复拷贝); - 建目录:确保
filesDir/downloads下载目录存在; - 组装参数:
buildArgs()生成上表中的完整参数数组; - 尝试 A:
spawn.startEngine(),成功则记录 pid、置mode='spawn'; - 降级 B:失败读
spawnErrorText()拿到 errno 文本,startEngineInProc()把引擎跑进后台线程; - 等待就绪:两种模式成功后都等约 1.2 秒让 RPC 端口 listen,再发起 WebSocket 连接;
- 状态维护:界面退出时
stopLocalEngine()——子进程模式杀 pid,进程内模式置位,任务进度交给控制文件在下次启动恢复。
这条链路上每一环都有对应的界面反馈:成功显示「本地引擎运行中(子进程 / 进程内)」,失败显示具体 errno 文本。用户永远知道引擎处于什么状态,而不是面对一个沉默的失败。

引擎日志查看器:读取引擎文件日志末尾 80 行。模式 B 下 aria2 的全部调试输出(任务创建、分块调度、磁盘写入、BT 对等连接)都可在此查看,连接异常不用连电脑。
启动参数:与命令行 aria2 逐项对应
引擎参数由 ArkTS 侧组装,与命令行用法完全一致:
| 参数 | 作用 |
|---|---|
--enable-rpc --rpc-listen-port=6800 | 开启 RPC,监听 6800 |
--rpc-listen-all=false | 只绑 127.0.0.1,局域网不可达 |
--dir=<filesDir>/downloads | 下载目录收敛在沙箱内 |
--continue=true | 断点续传 |
--max-concurrent-downloads=5 | 并发任务数 |
--split=8 --min-split-size=1M | 单任务 8 连接分块,最小分块 1MB |
--log=<filesDir>/aria2.log --log-level=debug | 诊断日志落私有目录 |
--quiet=true | 关掉控制台输出,避免污染 |
一句话:这个应用就是「把命令行 aria2 装进了 HAP 里」,只是把敲键盘的动作换成了点按钮。
六、NAPI 桥接层:接口面越小越好
桥接层(libaria2spawn.so)刻意只暴露六个函数:
| 函数 | 用途 |
|---|---|
startEngine(binPath, args) | posix_spawn 拉起子进程,返回 pid 或负 errno |
stopEngine(pid) | 终止子进程 |
isEngineAlive(pid) | 存活探测 |
spawnErrorText(pid) | 失败原因文本(errno → 可读信息) |
startEngineInProc(args) | 启动进程内引擎 |
isEngineInProcRunning() | 进程内引擎状态探测 |
设计原则:跨层只传原始值(number / boolean / string),不传对象。序列化成本和类型风险都降到最低。ArkTS 侧通过 .d.ts 声明导入,调起来像调用系统模块。
NAPI 模块本身也保持了「无状态、可重入」的修养:不在 native 侧持有 UI 对象引用,不做回调进 ArkTS(全部用返回值 + 状态探测函数),全局只有引擎线程和参数缓冲两个静态量。这让桥接层的行为完全可预测——出问题永远是参数或环境,不会是「某一帧的状态错乱」。对移植项目来说,桥接层的 boring(可预测)比 clever 重要得多。
import spawn from 'libaria2spawn.so';
// 先试子进程
const pid = Number(spawn.startEngine(binPath, args));
if (pid > 0) {
this.enginePid = pid;
this.mode = 'spawn';
resolve(true);
return;
}
// 沙箱禁 exec(pid 为负 errno),回退进程内
const errText = String(spawn.spawnErrorText(pid));
console.info(`子进程拉起失败(errno ${-pid}: ${errText}),回退进程内引擎`);
const rc = Number(spawn.startEngineInProc(args));
失败时返回的不是布尔值,而是负的 errno 加一段可读文本——调试沙箱策略问题时,这个细节能省掉大量猜测。
七、RPC 客户端与任务模型:协议细节见功夫
UI 层与引擎的全部通信,是一个纯 ArkTS 实现的 JSON-RPC 2.0 客户端(约 300 行,基于 @ohos.net.webSocket)。几个值得说的实现点:
请求配对与异常兜底
自增 id + Map<id, PendingCall> 管理在途请求;连接断开时统一 reject 所有 pending,避免 UI 上悬挂的 Promise 永远不返回。aria2 的推送事件(aria2.onDownloadStart / onComplete / onError……)按 method 名分发到事件回调——这正是「事件驱动」的用武之地:任务完成不用轮询,引擎主动告知。
字段映射:字符串里藏着整数
aria2 RPC 返回的 totalLength / completedLength / downloadSpeed 全部是字符串形式的整数(为兼容 64 位)。任务模型逐字段显式 Number() 转换——直接透传会在进度计算时产生 NaN,这种 bug 在界面上表现为「进度条不动」,极难定位。
任务模型(Aria2Task)的字段与 RPC 返回的 Dict 一一对应:gid、status、长度 / 速度 / 连接数、infoHash、bitfield(BT 进度位图)、files[] 数组。显示名的推导也做了兜底链:优先 BT 任务名,其次第一个文件路径的末段,最后退化到 gid 前 8 位——任何字段缺失,界面上都有东西可显示。
控制器每秒轮询四个方法,覆盖任务与全局两个视角:
| 方法 | 回答的问题 |
|---|---|
aria2.tellActive | 正在下载的任务细节 |
aria2.tellWaiting | 排队中的任务(含暂停的) |
aria2.tellStopped | 已完成 / 出错 / 被移除的历史任务 |
aria2.getGlobalStat | 全局下载 / 上传速度 |
全局速度(界面顶部「↓ x KB/s ↑ y KB/s」)来自 getGlobalStat;任务卡片的三态分组(下载中 / 排队 / 已停止)来自前三个方法的归属。导出、暂停、删除则分别对应 aria2.forceRemove、aria2.pause、aria2.unpause 与 UI 层的 picker 调用。整个界面没有任何「猜」出来的状态——每个数字都有明确的 RPC 来源。
列表刷新:签名比对 + key 重建
ArkUI 的 ForEach 对相同 key 的项不重建内容——任务速度每秒都在变,但卡片就是不动。解法是控制器对任务集合做签名比对,数据有变化才自增 refreshTick 并作为渲染 key,强制卡片随新数据重建。既保证正确,又不引发无谓的全列表刷新。
八、文件导出:沙箱与用户可见之间的桥
下载文件落在应用私有沙箱(files/downloads/),系统文件管理器天然不可见——这是沙箱模型的安全边界,不能破。但用户最终要把文件拿出来,怎么办?
应用只申请了一个权限:INTERNET。文件外流走用户授权制:点击「导出」→ 系统 DocumentViewPicker.save() 弹出另存为对话框 → 用户选定目标位置 → 应用对该文件获得临时写授权 → 1MB 分块流式拷贝 + fsync。
const documentViewPicker = new picker.DocumentViewPicker(context);
const saveOptions = new picker.DocumentSaveOptions();
saveOptions.newFileNames = [fileName];
const uris = await documentViewPicker.save(saveOptions);
// 用户选中后,对目标 URI 打开写入,1MB 分块拷贝 + fsync
不申请存储权限,路径完全由用户确认——权限面越小,攻击面越小。这也是鸿蒙生态反复倡导的做法:能用 picker 解决的,不要用权限解决。顺带一提,安全约束不止于此:RPC 只绑回环地址(--rpc-listen-all=false),RPC 密钥由用户自填不硬编码,引擎二进制只从应用 rawfile 释放——本地监听、凭据、二进制来源,三条线全部收紧。
这套约束合起来看其实是一条清晰的原则:把「能力」变成「用户动作」。下载需要网络——这是功能本身,申请 INTERNET 无可厚非;但下载的文件最终去向、是否暴露给局域网,这些都交给用户在具体场景里确认。应用不替用户做越权决定,系统也不给应用越权的机会。移植初期最容易犯的错,是把桌面软件「默认全都能干」的习惯带过来——桌面端随手读盘、随手监听端口的操作,在沙箱里每一项都需要重新设计。某种意义上,移植的过程就是重新学习「权限边界」的过程。

下载中:ls-lr.gz(37 MB)以 353 KB/s、1 个连接稳定推进,进度条、速度、剩余量实时刷新;每个任务可独立暂停、删除,完成后出现「导出」入口。
九、PC 形态的界面适配:原生不是口号
「原生界面」不是把手机 UI 拉大到 PC 上就行。鸿蒙 PC 的窗口是可自由缩放的,本应用做了三档响应式:
- 监听
windowSizeChange,按窗口宽度分档(<600 / 600–999 / ≥1000),缩放系数 1.0 / 1.15 / 1.35; - 所有字号、间距预计算进
@State——这是 ArkUI 的一个硬约束:属性链里直接读@State数字有效,但调用私有方法计算(如.fontSize(this.calc()))会被静默忽略,不报错也不生效。第一版就踩了这个坑,表现是「大屏上部分文字没放大」,查了半天才发现是属性链里的方法调用被吃了; - 窗口最大化、分屏、还原,布局实时跟随,全屏下不会出现「手机字」。
架构上 UI 与控制器严格分层:DlController 持有全部可变状态,通过单一 onChanged() 回调把快照写回 @State。控制器完全不知道视图的存在——可测试、可替换,界面重写不影响业务逻辑。日志也做了环形缓冲:最多保留 100 条,最新的在最上面,界面上永远是一屏能看完的诊断信息。
界面本身的构成也完全按 PC 交互习惯组织:顶部是连接区(地址、端口、RPC 密钥、连接按钮)与引擎操作区(停止引擎、引擎日志、关于);其下是全局状态行(连接指示灯、引擎模式、总下载 / 上传速度)与任务统计(下载中 / 排队 / 已停止)和批量操作(全部暂停、全部继续、清除记录);再往下是新建下载输入框(URL / 磁力 / 种子路径,带「添加」与「示例」);主体是任务列表。每个任务卡片包含文件名、进度条、百分比、大小、速度、连接数和操作按钮(暂停 / 继续 / 删除 / 导出)。没有隐藏菜单、没有二级页面迷宫——所有功能一屏可达,这是 PC 工具类应用的基本修养。

任务完成:37 MB 文件 100% 落盘,界面显示沙箱内路径,点击「导出」即可通过系统另存为对话框转存到用户可见目录。
十、测试与验证:真机说话
移植工作不是「编译通过」就算完。以下验证在 HarmonyOS PC(2in1,arm64)真机完成:
| 验证项 | 结果 |
|---|---|
| 引擎启动 | 应用启动即自动完成「释放二进制 → 启动 → RPC 连接」,界面显示「本地引擎运行中」 |
| HTTP 下载 | 37 MB 文件实测 353 KB/s 稳定下载,1 连接跑满服务器限速 |
| 断点续传 | 中断后重开,从已下载分块继续,不回零 |
| BT / 磁力 | 磁力链接元信息获取、对等连接、进度推进正常 |
| 文件导出 | 另存为对话框选择目标目录后,文件完整出现在系统可见位置 |
| 窗口缩放 | 三档字号 / 间距缩放,最大化与分屏无跳变 |
| 远程模式 | 填入远程 aria2 地址与密钥,成功管理局域网内独立 aria2 |
| 进程稳定性 | 长时间下载 + 界面操作,进程无崩溃、无内存泄漏迹象 |
几项验证值得展开说:
引擎启动链路:应用一打开就自动执行「释放二进制 → 启动 → 等待端口就绪 → 连接」,用户看到的是「已连接 aria2 RPC」和全局速度就位。启动失败时界面上直接显示失败原因(含 errno 文本),不会黑屏沉默。这里有个容易被忽视的体验点:引擎就绪到 RPC 可连接之间有约 1.2 秒的窗口期,界面状态机会明确区分「引擎运行中」与「已连接 RPC」两个状态——用户能看到「起来了」和「连上了」是两回事,而不是面对一个既不能点也不知道在干嘛的中间态。
HTTP 下载:37 MB 的测试文件以 353 KB/s 稳定下载,1 个连接跑满服务器限速。这个数字本身不出彩,但它的意义在于「稳」——速度曲线不跳水、进度条匀速推进、完成后 100% 落盘。对下载器来说,可预期的速度比瞬间的峰值更重要。多连接分块能力(--split=8)在支持多线程的服务器上会进一步拉开差距,测试环境受限于单连接限速的源站,但参数已在默认配置中就位。
断点续传:这是下载器的底线能力。杀掉应用重开,任务从 .aria2 控制文件恢复,已下载的分块不会重下——验证方式是中断一个 37 MB 任务、重开、观察进度不回零。引擎侧的 --continue=true 与控制文件机制保证了这一点,UI 侧要做的只是在重新连接后正确地把任务状态映射回列表。
BT / 磁力:磁力链接添加后,引擎先获取元信息(文件名、文件列表、总大小),随后进入下载。界面会显示 BT 任务特有的信息:种子名、连接的对等节点数、做种上传速度。进度用 bitfield 位图映射成百分比——这是 aria2 RPC 给 BT 任务的原生表示,逐位翻译即可。
文件导出:另存为对话框选择目标目录后,文件完整出现在系统可见位置,且大小与源文件一致(37 MB 全量)。1MB 分块拷贝加 fsync,保证落盘完整性;中途取消则不产生半成品文件留在用户目录。
远程模式:把连接地址从 127.0.0.1 换成局域网内任意 aria2,同一个界面就是 AriaNg 的原生替代。三种拓扑(子进程 / 进程内 / 远程)共用一套 UI 代码,这正是第四章架构决策的红利。
窗口缩放:从最小窗口拖到最大化,三档缩放切换时字号与间距平滑跟随,任务卡片不重叠、不截断;分屏状态下同样成立。PC 用户对「窗口行为」的敏感度远高于移动端,这块的打磨直接决定「像不像一个 PC 应用」。

关于页:把「移植了什么、怎么移植的」直接讲给用户——前端 ArkTS + ArkUI、通信 WebSocket JSON-RPC、下载引擎 aria2 1.37.0 静态链接(C++ 静态库)、打包形态为单个 HAP(约 11 MB)。
十一、踩坑清单:后来者的路标
以下按「症状 → 根因 → 解法」整理,每条都是真实调试时间换来的:
- 症状:启动秒崩。 根因:进程内跑
aria2::main()前没初始化 console,Logger 构造时空指针。解法:照抄上游入口,先aria2::global::initConsole(false); - 症状:偶发难以复现的崩溃。 根因:后台线程用了栈上字符串的指针作 argv。解法:全局
std::vector<std::string>自持缓冲; - 症状:进度条不动。 根因:RPC 长度字段是字符串,透传后算术产生 NaN。解法:任务模型逐字段显式
Number(); - 症状:大屏上部分文字不放大。 根因:ArkUI 属性链调用私有方法被静默忽略。解法:所有动态尺寸预计算进
@State,属性链只直读; - 症状:任务速度更新了但卡片不动。 根因:
ForEach相同 key 不重建。解法:签名比对 +refreshTick作 key; - 症状:子进程模式莫名回退。 根因:沙箱禁 exec,
posix_spawn返回 EPERM/EACCES。解法:不做单点假设,双模式自动降级; - 症状:运行期行为异常、难以定位。 根因:交叉编译的
config.h与静态库不是同一次 configure 的产物。解法:头文件与二进制同源替换; - 症状:链接报错 / ABI 诡异问题。 根因:工具链 libc++ 与 NDK 不同源。解法:用与 NDK 同源 libc++ 的 clang,C++14 即可满足 aria2 要求。
- 症状:日志文件暴涨,占满沙箱空间。 根因:
--log-level=debug面向排查期,长任务下持续增长。解法:读取侧限流(末尾 64KB / 80 行),正式使用时可降为 warn 级别。 - 症状:换引擎版本后进程内模式行为异常。 根因:
main.o与libaria2.a不是同版本产物。解法:版本替换时四件套(两个静态库 +main.o+aria2c)与config.h必须同源,缺一不可。
这份清单的另一半价值在于:它记录了「哪些坑踩过了就不用再踩」。移植类项目的经验如果只留在当事人脑子里,下一个项目就会以同样的方式摔同样的跤。
十二、工程组织:CMake 与构建
移植类项目的工程质量,一半体现在能不能被下一个人原样构建出来。本工程的 native 侧组织如下:
add_library(aria2spawn SHARED
engine_spawn.cpp # posix_spawn 子进程管理
engine_inproc.cpp) # 进程内 aria2::main()
target_link_libraries(aria2spawn PUBLIC
third_party/aria2/lib/main.o # aria2 入口目标文件
third_party/aria2/lib/libaria2.a # aria2 核心
third_party/aria2/lib/libwslay.a # WebSocket
third_party/openssl/lib/libssl.a # TLS
third_party/openssl/lib/libcrypto.a # 加密基础
libace_napi.z.so # HarmonyOS NAPI
z)
三个工程细节值得说明:
- C++ 标准选 14:aria2 要求 C++11+,与 NDK 默认 libc++ 的 ABI 完全兼容,无需折腾;
main.o单独链接:进程内模式复用的正是 aria2 可执行入口的目标文件,和静态库同源交付,保证两种模式行为一致;- 依赖全部静态:除了系统 NAPI 库,不依赖任何外部动态库——HAP 里只有一个自研
.so,加载路径清晰,不会出现「能找到头文件却找不到 .so」的运行时谜团。
应用侧则遵循标准 Stage 模型:单 entry 模块,EntryAbility 承载主界面与关于页,module.json5 声明 2in1 / tablet 设备类型与唯一的 INTERNET 权限。整个工程在 DevEco Studio 中打开即编,命令行侧提供 hvigorw 脚本供 CI 复现。
十三、开源与复现
项目完全开源,仓库包含:完整 ArkTS 源码、NAPI 桥接源码、交叉编译好的 aria2 静态库与引擎二进制、构建脚本、两份文档(项目 README + OpenHarmony 移植技术说明,含 T0/T1/T2 移植等级选型论证与可复用的移植检查清单)。
复现路径:
# 1. DevEco Studio 打开工程(SDK 6.0.1(21))
# 2. 自动签名(IDE 内完成,无需手工准备证书材料)
# 3. 命令行构建(可选):
$env:DEVECO_SDK_HOME = "C:\Program Files\Huawei\DevEco Studio\sdk"
$env:JAVA_HOME = "C:\Program Files\Huawei\DevEco Studio\jbr"
hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap
引擎与静态库已随仓库分发,无需自行交叉编译;如需更换 aria2 版本,仓库文档中给出了完整链路:交叉编译 OpenSSL(纯静态)→ wslay → aria2(--host=aarch64-linux-ohos,C++14)→ 替换 libaria2.a / libwslay.a / main.o / aria2c 与配套 config.h。注意 main.o 必须与库同源——进程内模式直接引用其中的 aria2::main() 符号。
对想借鉴的开发者,仓库里的移植文档(含选型矩阵、桥接接口表、踩坑记录、移植检查清单)比应用本身更值得一看——它是「下一个桌面软件搬上鸿蒙 PC」时可以照着走的路。
仓库的提交组织也遵循「可追溯」原则:源码导入、文档、工程修正各自独立成 commit,每个 commit 说清一件事;敏感材料(签名证书、构建产物、本地缓存)全部在入库前通过 .gitignore 与配置清理挡在仓库之外——开源交付的第一课就是「什么能进仓库」比「什么进了仓库」更重要。

应用已在应用市场上架,评分 5.0。对用户它是一个开箱即用的下载管理器;对开发者,仓库里的移植文档比应用本身更值得一看。
十四、FAQ:移植中被问最多的问题
Q:为什么不直接用函数调用桥接 aria2,非要绕 RPC?
A:RPC 是上游稳定的公开契约,走它意味着运行方式彻底解耦——子进程、进程内、远程三种拓扑共用一套界面。直接函数调用则要把三种形态全部封装一遍,还要处理进程间通信,得不偿失。
Q:进程内模式引擎崩溃会带崩应用吗?
A:不会。aria2::main() 的异常被后台线程 catch,状态探测返回「未运行」,界面可感知并重启引擎。子进程模式更是天然隔离。
Q:能后台持续下载吗?
A:当前版本下载随应用进程存活。这是刻意保持的最小形态——不申请后台常驻类权限,系统资源策略交给系统裁决。对「挂机下大文件」的场景,用户可以让窗口保持前台。
Q:BT 在严格 NAT 下没速度?
A:这是 BT 协议的环境问题,不是移植缺陷。DHT 与对等交换行为与桌面版 aria2 完全一致,可尝试配置端口映射(路由器 / 防火墙层面)改善。
Q:远程模式安全吗?
A:连接安全性等同于用户对该 aria2 服务的配置——凭据由用户填写,应用不扩大也不缩小攻击面。本地引擎本身只监听回环地址。
Q:为什么界面状态要分「引擎运行中」和「已连接 RPC」?
A:这两件事在时间上不同步(引擎就绪到端口可连有窗口期),在故障上也独立(引擎起着但端口被占、连接断但引擎还活着)。拆成两个状态,用户和开发者都能精确定位问题卡在哪一环,而不是一个含糊的「连不上」。
Q:后续打算做什么?
A:功能上,任务分组、限速配置、BT 选择性下载(种子内文件勾选)都在路线图里——这些能力 aria2 引擎全部具备,缺的只是界面暴露。工程上,进程内模式的优雅停机值得继续研究(跟随上游是否有干净的关闭 API)。生态上,这套「T1 移植 + RPC 解耦」的架构会沉淀成模板,复用到下一个「重引擎、薄界面」的桌面软件上。
十五、关于鸿蒙 PC 生态的一点观察
做这个项目的过程中,一个越来越强的感受是:鸿蒙 PC 当下的软件缺口,大多不是「缺创意」,而是「缺搬运」。大量在 Windows / macOS 上被反复验证的开源软件,其核心引擎本来就能跨平台编译,真正卡住移植的往往是三样东西:沙箱运行时差异(本项目是 exec 受限)、界面层的原生重写成本、以及「值不值得搬」的判断。
这个项目想证明的是第三件事:判断不值钱,路线才值钱。当「T0 / T1 / T2 选型矩阵 + NAPI 双模式桥接 + RPC 契约解耦 + picker 文件流」这套组合被完整走通一次,下一个下载器、播放器、同步工具要搬上鸿蒙 PC 时,可复用的不是代码,而是这张路线图。生态的繁荣从来不是靠某一个爆款应用,而是靠「搬运」的门槛被踩平。
写代码的人经常高估单点技术的难度,低估工程组织与决策记录的价值。这个项目最后沉淀下来的两份文档(README 与移植技术说明),篇幅超过了核心桥接代码本身——这不是偶然,是刻意为之。
十六、总结:移植的方法论比代码更值钱
回顾整个过程,最有价值的不是「把 aria2 跑起来了」这个结果,而是可复用的方法论:
- 先选型再动手:T0 / T1 / T2 不是等级高低,是策略差异。「重引擎、薄界面」的软件走 T1,是工作量与体验的最优交点;
- 核心复用、界面重写:协议栈二十年沉淀不值得重写,薄界面不值得套容器——把 T0 的「全部重写」收敛为「只重写该重写的」;
- 契约先行:UI 与引擎走标准 RPC,运行方式(子进程 / 进程内 / 远程)彻底解耦,换来的是三种拓扑共用一套界面;
- 沙箱思维:exec 被禁就降级,文件外流走 picker,权限能少一个是一个——沙箱不是障碍,是设计约束;
- 原生是细节:响应式缩放、事件驱动刷新、列表重建策略,用户体验藏在每一处 ArkUI 的正确用法里。
鸿蒙 PC 的软件生态还在早期,大量桌面软件的移植空间刚刚打开。对后来者只有一句话:先想清楚搬什么、留什么、扔什么,再写第一行代码。
最后留个收尾的观察。这个项目从「一份 aria2 源码」到「上架可用的 PC 应用」,真正花时间的不是某个技术难点——双模式引擎的坑一周内就能踩平——而是持续回答一类问题:这个能力在沙箱里应该是什么形态?这个状态用户应不应该看到?这个决定三年后回看会不会后悔?技术决定项目的下限,而这些「形态与边界」的回答决定项目的上限。下载器是互联网最古老的应用形态之一,把它认真搬进一个新生态,本身就是对这个生态的一次投票。等到某天鸿蒙 PC 上的下载体验不再需要用户「想办法克服」,这条迁移之路才算真正走到了头。
十七、写在最后:把「搬运」变成可复制的能力
如果说这个项目除了应用本身还留下了什么,那就是一条被完整验证过的路径:上游 C++ 引擎 → 交叉编译静态库 → NAPI 双模式桥接 → JSON-RPC 契约解耦 → ArkTS 原生界面 → picker 文件流。这条路径上的每一环都有明确的决策记录和踩坑注释,下一个项目照着走,可以绕开八成暗礁。
对鸿蒙 PC 生态而言,下载器只是开始。播放器、同步工具、编译链、科学计算——大量「重引擎、薄界面」的桌面软件都在排队等着被搬进来。搬运的门槛每被踩低一分,生态的繁荣就多一分底气。
如果你也在做鸿蒙 PC 的软件移植,希望这份记录能帮你少走几个弯路:选型时对照三等级矩阵,动手前翻一遍踩坑清单,收尾时把决策写进文档。也欢迎拿去仓库里的移植技术说明作参考,直接在它的基础上迭代出属于你的路线图。
这,可能比一个下载器本身更值得记录。
更多推荐




所有评论(0)