前言
一、问题的本质:Aria2 不是为你设计的
要理解为什么进度同步这么难,先得明白 Aria2 的设计哲学:
· Aria2 是一个独立的下载进程,它有自己的生命周期、内存管理、任务队列
· 它通过 JSON-RPC 协议对外提供"瞬时快照"——你问它的时候,它告诉你此刻的状态
· 它不会主动通知你"我完成了",除非你配置了钩子脚本(--on-download-complete)
· 小文件下载完成后,Aria2 会在极短时间内自动清理任务(从活动列表里删除)
这意味着什么?我们的 App 就像一个盲人,每隔 200 毫秒去摸一下 Aria2 的脉,然后试图从这些碎片化的信息里推断出"文件到底下完没有"。
二、两个核心矛盾
矛盾一:小文件「追不上」
· Aria2 下载一个 150KB 的文件只需要 50-100ms
· 你的轮询间隔是 200ms
· Aria2 下完后立刻清理任务
· 你去问的时候,Aria2 说:"没这个任务了"
· 你不知道它是"完成了"还是"崩溃了"
矛盾二:大文件「看不清」
· Aria2 可以预分配空文件(--file-allocation=falloc),一个 700MB 的文件,在下载第一秒就占了 700MB 的磁盘空间
· 你检查本地文件大小:700MB ✓
· 你以为下载完了,但实际上是空的
三、失败方案盘点
方案 核心思路 为什么失败了
补帧(检查本地文件大小) 轮询不到任务时,去本地文件夹找文件,看大小是否达标 Aria2 预分配空壳,文件大小从一开始就是满的;小文件被 Aria2 自己清理了,文件找不到
停滞检测(连续N次进度不变就认为完成) 每次轮询记录进度值,如果连续3次都一样就判定完成 暂停任务也会触发停滞;大文件下载过程中短暂卡顿也会误触发
文件观察者协程 启动一个独立协程,每 100ms 轮询本地文件是否出现 和补帧一样,对预分配空壳误判;文件名不匹配导致永远找不到
钩子脚本(--on-download-complete) 让 Aria2 完成后执行脚本,写入信号文件 理论上最可靠,但文件路径匹配、权限、脚本执行环境都可能出问题
改为只用 OkHttp 直接放弃 Aria2 下载普通文件 虽然最稳,但失去了 Aria2 的优势;用户感知不到"高速"
四、最终方案:只信两个铁证
所有失败方案的共同点是:我们在用自己的逻辑去"猜" Aria2 是不是完成了。 最终方案的核心转变是:不再猜,只信 Aria2 自己亲口说的,或者数据本身铁证如山的那一刻。完成 = (Aria2说"我好了") 或 (已下载量 ≥ 总大小 且 总大小 > 0)
4.1 第一个铁证:status == "complete"
Aria2 的 tellStatus 返回的 status 字段,当且仅当文件完全下载并通过校验后,才会变成 "complete"。这个字段是 Aria2 内部的结论,不是瞬时状态,比任何外部分析都可靠。
4.2 第二个铁证:completedLength >= totalLength
如果轮询拿到的 completedLength 已经追上了 totalLength,那无论状态字段是什么,文件都已经完整了。这是数学上的铁证。
4.3 兜底:查不到任务就是完成了
如果 tellStatus 返回 null(任务已被 Aria2 清理),说明 Aria2 已经把这个任务从活动列表里删了。Aria2 只在两种情况下会清理任务:下载完成 或 用户手动删除。所以查不到就按完成处理。
五、完整代码private suspend fun updateDownloadTaskProgress(gid: String, taskId: Long) { val result = rpcCall("aria2.tellStatus", """["$gid"]""") val db = appContext?.let { AppDatabase.getInstance(it) } ?: return val task = db.downloadDao().getTaskById(taskId) ?: return // 兜底:查不到任务 → 完成 if (result == null) { unregisterDownloadTask(gid) db.downloadDao().updateTask(task.copy( state = DownloadState.COMPLETED, completeTime = System.currentTimeMillis() )) sendDownloadCompleteNotification(task) return } val statusObj = JsonParser.parseString(result).asJsonObject .getAsJsonObject("result") ?: return val status = statusObj.get("status")?.asString ?: return val completedLength = parseLong(statusObj.get("completedLength")) val totalLength = parseLong(statusObj.get("totalLength")) val downloadSpeed = parseLong(statusObj.get("downloadSpeed")) // 两个铁证 val isComplete = status == "complete" || (totalLength > 0 && completedLength >= totalLength) if (isComplete) { unregisterDownloadTask(gid) db.downloadDao().updateTask(task.copy( state = DownloadState.COMPLETED, downloadedSize = completedLength, totalSize = totalLength, downloadSpeed = 0, completeTime = System.currentTimeMillis() )) sendDownloadCompleteNotification(task) return } // 正常更新进度(不改变状态) val newState = when (status) { "paused" -> DownloadState.PAUSED "active" -> if (task.state == DownloadState.PAUSED) DownloadState.PAUSED else DownloadState.DOWNLOADING else -> task.state } db.downloadDao().updateTask(task.copy( downloadedSize = completedLength, totalSize = if (totalLength > 0) totalLength else task.totalSize, downloadSpeed = downloadSpeed, state = newState )) } private fun parseLong(element: JsonElement?): Long { if (element == null) return 0L return try { element.asLong } catch (e: Exception) { 0L } }
六、方法论总结:为什么之前的方案会失败?
6.1 不要用文件系统状态来推断下载完成
下载完成是一个逻辑事件,不是一个物理状态。文件在磁盘上和文件完整是两回事。Aria2 可能会预分配、重命名、分段写入。只有 Aria2 自己知道文件是不是真正完整。
6.2 不要用时间来推断下载完成
"连续 3 次没变化就认为完成了"——这在暂停、网络波动、大文件长时间不变下载速度的情况下都会误判。时间不能用来推断逻辑事件。
6.3 不要试图比 Aria2 更聪明
我们写的补帧、停滞检测、文件观察者,本质上都是在试图比 Aria2 更早知道"文件好了"。Aria2 做了这么多年,它的完成判定一定比我们写的任何逻辑都准确。我们只需要等它告诉我们。
七、还有哪些可选优化?
7.1 使用 --on-download-complete 钩子
如果你想要 100% 不遗漏的完成通知,可以加上钩子脚本。Aria2 每完成一个文件就执行这个脚本,把文件路径写入一个信号文件,App 端监控这个文件即可。
7.2 多文件/BT 下载的特殊处理
如果你用 Aria2 下载磁力/种子,且用户只选了部分文件,需要用选中文件的总大小来计算进度,而不是用根节点的 totalLength。
7.3 动态轮询间隔
小文件用 100ms,大文件用 500ms,既能保证小文件进度跟得上,也不会因频繁轮询消耗过多性能。
八、结语
Aria2 是一个强大但"高冷"的下载引擎。它不会主动告诉你任何事情,只在被问的时候给出一个瞬时快照。理解这一点,就能理解为什么之前所有的"智能推断"方案都会在边界上失败。
最终的方案很简单:不推断,只相信。 Aria2 说完成了,或者数据自己证明了完成,那才是真正的完成。
希望这篇文章能帮你少走我踩过的坑。如果以后再遇到 Aria2 相关的问题,回来看看这篇就够了。



Comments NOTHING