为什么下载功能一开始只是拉文件,后面却会越做越重?

0 阅读6分钟

很多下载功能刚开始都很轻:点一下,拉个文件,进度条走完就结束。可一旦任务要落库、要恢复、要排队、要校验,文件下完以后还要继续往后交付,下载就不再只是一个按钮动作,而会慢慢长成一套围绕任务全过程运转的系统。

如果一个项目里的下载任务,会被单独放进一张表里,而且这张表还在版本迁移里一路加字段,这件事本身就已经说明很多问题了。

这个项目里的下载任务表,后面就是这么长出来的:

  • 6 -> 7 新增 download_tasks
  • 7 -> 8 增加 versionCode / fileMd5 / downloadType
  • 8 -> 9 增加 pckId
  • 9 -> 10 调整 pckId 类型
  • 10 -> 11 增加 isUpdating
  • 11 -> 12 增加 isShow

如果下载真的只是“点一下、拉个文件、进度条走完就结束”,这张表根本没必要长成这个样子。

这些字段一路加上来,也不是为了显得系统复杂,而是下载任务开始被要求回答越来越多问题:

  • 它对应哪个版本
  • 文件下完以后要不要做 MD5 校验
  • 这是普通下载、更新下载,还是快玩下载
  • 这个任务要不要继续出现在某些列表里
  • 应用重启以后,系统还认不认这个任务

我这次重新看这条线,真正让我改观的也不是下载按钮和进度条,而是这张表已经把一个事实提前写进数据库了:

下载早就不是页面里的一个瞬时动作了。

1. 一张不断长字段的下载表,比下载按钮更早暴露问题

项目刚开始做下载时,最容易想到的画面通常都很简单:

  • 点一个下载按钮
  • 文件开始往下拉
  • 进度条往前走
  • 失败了重试,必要时再补个暂停和继续

这种理解本身没有问题。

问题在于,业务继续往前走以后,系统很快就不会只问“有没有下下来”。

它会开始追着你问:

  • 这个下载对应哪个版本
  • 这是更新任务,还是普通安装包
  • 校验按大小就够,还是还得做 MD5
  • 下载完成以后,是接安装,还是接快玩配置
  • 这个任务以后还要不要继续展示

也就是说,下载任务身上开始挂的,不再只是一个 URL 和一个进度值,而是一串后面还要继续被别的流程使用的信息。

所以我现在再看下载能力,第一眼已经不是去看下载器本身,而是先看:

  • 任务有没有落库
  • 表结构是不是还在继续长
  • 任务类型是不是已经开始分叉

因为这些信号往往比页面里的按钮更早暴露出一个事实:

下载已经开始从一个动作,变成一类会被系统长期记住的任务。

2. 真正把下载拖重的,是任务不能只活在当前页面

一个只活在当前页面里的下载,其实很轻。

页面关掉就算了,用户重新进来再点一次,也不一定会出什么大问题。

但只要业务开始要求这些事情同时成立,重量立刻就会上来:

  • 应用被杀掉以后,任务还得认
  • 重启以后,要把历史任务读回来
  • 下载中的任务要改成暂停
  • 排队中的任务要重新回到队列里
  • 系统还得继续尝试启动下一个任务

这时候你面对的,已经不是“下载按钮点下去会不会动”,而是另一个问题:

一个任务能不能跨页面、跨会话、跨重启继续被系统接住。

这类要求一出现,下载就不再是当前页面里的临时动作了。

它开始有自己的生命周期,要处理自己的恢复,还得想办法把旧状态重新续上。

所以很多项目一开始只想做个下载器,最后却越做越重。不是团队故意把事情搞复杂,而是业务已经明确要求下载任务“不能只活在当下”。

3. 下载真正发重时,往往先长在队列、写库顺序和恢复上

这次我重新看 GameDownloadService,最能说明它已经不是工具层的,不是 dio.download(...) 本身,而是下面这些东西:

  • _downloadQueue
  • maxConcurrentDownloads = 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 对不对
  • 安装包能不能走安装逻辑
  • 快玩包能不能写入对应配置
  • 最终结果能不能回传给服务端

而且一旦不同下载类型开始走不同收尾逻辑,下载就更不可能只是一个工具动作了。

项目里至少已经能看到这样的区分:

  • quickGame
  • androidGame

这意味着系统不再只关心“文件下来了没”,而是开始关心:

  • 下来的到底是什么
  • 后面应该接哪一段逻辑
  • 这条任务最后该往哪个方向收

所以真正把下载推成一套任务系统的,并不是那次“开始下载”,而是后面这一整串问题:

  • 任务怎么创建
  • 任务怎么入库
  • 任务怎么恢复
  • 任务怎么排队
  • 任务怎么校验
  • 任务怎么把结果交给下一段流程

当这些问题同时出现时,下载处理的就已经不再是“文件能不能下完”,而是一个任务从创建到交付的全过程。