很多下载功能刚开始都很轻:点一下,拉个文件,进度条走完就结束。可一旦任务要落库、要恢复、要排队、要校验,文件下完以后还要继续往后交付,下载就不再只是一个按钮动作,而会慢慢长成一套围绕任务全过程运转的系统。
如果一个项目里的下载任务,会被单独放进一张表里,而且这张表还在版本迁移里一路加字段,这件事本身就已经说明很多问题了。
这个项目里的下载任务表,后面就是这么长出来的:
6 -> 7新增download_tasks7 -> 8增加versionCode / fileMd5 / downloadType8 -> 9增加pckId9 -> 10调整pckId类型10 -> 11增加isUpdating11 -> 12增加isShow
如果下载真的只是“点一下、拉个文件、进度条走完就结束”,这张表根本没必要长成这个样子。
这些字段一路加上来,也不是为了显得系统复杂,而是下载任务开始被要求回答越来越多问题:
- 它对应哪个版本
- 文件下完以后要不要做 MD5 校验
- 这是普通下载、更新下载,还是快玩下载
- 这个任务要不要继续出现在某些列表里
- 应用重启以后,系统还认不认这个任务
我这次重新看这条线,真正让我改观的也不是下载按钮和进度条,而是这张表已经把一个事实提前写进数据库了:
下载早就不是页面里的一个瞬时动作了。
1. 一张不断长字段的下载表,比下载按钮更早暴露问题
项目刚开始做下载时,最容易想到的画面通常都很简单:
- 点一个下载按钮
- 文件开始往下拉
- 进度条往前走
- 失败了重试,必要时再补个暂停和继续
这种理解本身没有问题。
问题在于,业务继续往前走以后,系统很快就不会只问“有没有下下来”。
它会开始追着你问:
- 这个下载对应哪个版本
- 这是更新任务,还是普通安装包
- 校验按大小就够,还是还得做 MD5
- 下载完成以后,是接安装,还是接快玩配置
- 这个任务以后还要不要继续展示
也就是说,下载任务身上开始挂的,不再只是一个 URL 和一个进度值,而是一串后面还要继续被别的流程使用的信息。
所以我现在再看下载能力,第一眼已经不是去看下载器本身,而是先看:
- 任务有没有落库
- 表结构是不是还在继续长
- 任务类型是不是已经开始分叉
因为这些信号往往比页面里的按钮更早暴露出一个事实:
下载已经开始从一个动作,变成一类会被系统长期记住的任务。
2. 真正把下载拖重的,是任务不能只活在当前页面
一个只活在当前页面里的下载,其实很轻。
页面关掉就算了,用户重新进来再点一次,也不一定会出什么大问题。
但只要业务开始要求这些事情同时成立,重量立刻就会上来:
- 应用被杀掉以后,任务还得认
- 重启以后,要把历史任务读回来
- 下载中的任务要改成暂停
- 排队中的任务要重新回到队列里
- 系统还得继续尝试启动下一个任务
这时候你面对的,已经不是“下载按钮点下去会不会动”,而是另一个问题:
一个任务能不能跨页面、跨会话、跨重启继续被系统接住。
这类要求一出现,下载就不再是当前页面里的临时动作了。
它开始有自己的生命周期,要处理自己的恢复,还得想办法把旧状态重新续上。
所以很多项目一开始只想做个下载器,最后却越做越重。不是团队故意把事情搞复杂,而是业务已经明确要求下载任务“不能只活在当下”。
3. 下载真正发重时,往往先长在队列、写库顺序和恢复上
这次我重新看 GameDownloadService,最能说明它已经不是工具层的,不是 dio.download(...) 本身,而是下面这些东西:
_downloadQueuemaxConcurrentDownloads = 1_tryStartNextInQueue()_dbWriteQueue
这些名字本身就在说明,系统开始关心的已经不是“能不能下载”,而是:
- 任务之间有没有先后
- 同时最多能跑几个
- 队列里的任务什么时候接上
- 多次状态写回数据库时,会不会互相覆盖
像这段逻辑就很典型:
while (_activeDownloadCount < maxConcurrentDownloads &&
_downloadQueue.isNotEmpty) {
final gameId = _downloadQueue.removeAt(0);
final progress = _progressMap[gameId];
if (progress == null) {
continue;
}
if (progress.status != GameDownloadStatus.queued) {
continue;
}
unawaited(
startDownload(
gameId: gameId,
downloadUrl: progress.downloadUrl!,
savePath: progress.savePath!,
packageName: progress.packageName!,
...
),
);
}
这段代码不是在回答“怎么下载”,而是在回答:
- 谁先跑
- 谁继续等
- 谁有资格接上
而 _dbWriteQueue 这一层更能说明问题。
它意味着系统已经知道,同一个任务会在很多时机反复改状态:
- 开始下载
- 进度更新
- 暂停
- 继续
- 完成
- 失败
如果这些状态写回数据库的顺序打架,前面所有进度展示都会开始失真。
所以下载系统真正开始发重的时候,往往不是文件本身,而是:
- 队列是不是稳
- 恢复是不是接得上
- 写库顺序会不会把状态写乱
走到这里,它已经明显是系统问题了。
4. 文件下完只是中段,后面还有校验、分流和继续交付
很多下载实现做到“文件下完、进度 100%、界面显示完成”就停了。
但这条线真正成熟的地方,恰恰在于它没有停在这里。
项目里的完整性校验逻辑就特别说明问题:
final hasSize = progress.totalBytes > 0;
final hasMd5 = progress.fileMd5 != null && progress.fileMd5!.isNotEmpty;
if (hasSize) {
if (fileSize != progress.totalBytes) {
return false;
}
}
if (hasMd5) {
final localMd5 = await _calculateFileMd5(progress.savePath!);
if (localMd5.toLowerCase() != progress.fileMd5!.toLowerCase()) {
return false;
}
}
这段代码真正证明的是:
下载完成,不等于结果已经可以继续往后交付。
系统后面还得继续确认:
- 文件大小对不对
- MD5 对不对
- 安装包能不能走安装逻辑
- 快玩包能不能写入对应配置
- 最终结果能不能回传给服务端
而且一旦不同下载类型开始走不同收尾逻辑,下载就更不可能只是一个工具动作了。
项目里至少已经能看到这样的区分:
quickGameandroidGame
这意味着系统不再只关心“文件下来了没”,而是开始关心:
- 下来的到底是什么
- 后面应该接哪一段逻辑
- 这条任务最后该往哪个方向收
所以真正把下载推成一套任务系统的,并不是那次“开始下载”,而是后面这一整串问题:
- 任务怎么创建
- 任务怎么入库
- 任务怎么恢复
- 任务怎么排队
- 任务怎么校验
- 任务怎么把结果交给下一段流程
当这些问题同时出现时,下载处理的就已经不再是“文件能不能下完”,而是一个任务从创建到交付的全过程。