Skip to content

项目案例:download_service.dart 的下载 / 校验 / 原子更新 / 失败恢复 ​

一句话定位 ​

这一页不把 download_service.dart 当成“下文件的工具类”,而是把它看成一个端上资源更新状态机:拉远端版本 → 下载到临时文件 → 校验完整性 → 原子替换正式文件 → 失败时保留旧版本继续服务。

代码索引 ​

主题Lab 说明源码
Flutter 更新状态机download-service-update-casedownload_service_update_case_demo.dart
共享校验器样板resource-package-verifierresource_package_verifier.mjs
Android 原子晋升样板atomic-resource-promotionAtomicResourcePromotionActivity.kt

为什么这个案例值得单独拆 ​

很多端上下载逻辑一开始都很简单:

  1. 调接口拿到下载地址
  2. GET 文件并直接写到目标路径
  3. 下载完成后通知 UI “更新成功”

但一旦文件变成:

  • 离线包 / 模型文件 / 配置包 / 资源 ZIP
  • 大文件、弱网、多次中断、磁盘可能不足
  • 需要做到“新版本没准备好前,旧版本绝不能被破坏”

它就不再是普通下载,而是一个小型更新系统。这里最容易被问到的判断题是:

  • 为什么离线包 / 模型文件 / 资源 ZIP 不能按普通图片缓存那样直接覆盖?
    • 一句话答:因为它们会被业务长期读取,新版本未校验前直接覆盖会污染当前可用版本,导致白屏或资源缺失。
  • 为什么大文件、弱网、多次中断场景下,“下完字节”仍不等于“版本可切换”?
    • 一句话答:因为还缺 size / hash / manifest 校验与 staging → active 的原子切换,任何一步失败都不应宣布更新成功。
  • 为什么失败恢复不能只靠一个“重试按钮”?
    • 一句话答:因为失败恢复的关键是保留旧版本继续服务、识别坏临时文件、决定是否允许自动重试,而不是只把网络请求再发一遍。

download_service.dart 这类代码真正要解决的是:

  • 下载成功不等于可用:文件可能只下完了字节,还没做 hash / ZIP / manifest 校验。
  • 新包可用前不能覆盖旧包:否则会出现半更新、白屏、资源缺失。
  • 失败恢复不能只靠重试按钮:需要保留旧版本、清理坏临时文件、下次还能继续决策。
  • 缓存不能只看“有没有这个文件”:还要区分当前生效版本、下载中的临时版本、已经废弃但可回滚的版本。

先把链路拆成五段 ​

一个典型的 download_service.dart 下载更新链路,建议拆成五段:

  1. 远端元信息阶段:拉版本号、文件大小、hash、ETag、更新时间、解压目标目录。
  2. 临时下载阶段:写入 .part / .tmp 文件,必要时记录断点与已下载字节数。
  3. 完整性校验阶段:校验长度、hash、ZIP 结构、manifest、业务版本匹配。
  4. 原子切换阶段:把新目录 / 新文件替换成正式版本,并让读路径一次性切到新版本。
  5. 恢复与清理阶段:失败时保留旧版本继续服务,清理损坏临时文件,决定是否重试。

这五段如果混在一个 download() 方法里,后续通常会出现:

  • UI 只能收到一个模糊的 success / fail
  • 失败原因无法区分是网络、校验、解压还是落盘问题
  • 为了“修 bug”不断加 try-catch,最后没有清晰状态边界

更稳的写法是显式建模状态:

dart
enum DownloadStage {
  idle,
  fetchingMetadata,
  downloading,
  verifying,
  promoting,
  completed,
  failed,
}

这样 UI、日志、埋点、失败恢复都能围绕阶段展开,而不是围绕一个大而全的方法名展开。

典型目录与文件角色 ​

以离线资源 ZIP 为例,端上通常至少要区分三类路径:

text
app_data/
  resources/
    active/
      manifest.json
      bundle.zip
      extracted/
    staging/
      bundle_v42.zip.part
      bundle_v42.zip
      extracted_v42/
    backup/
      v41/

角色分工:

  • active/:当前正在被业务读取的正式版本。
  • staging/:下载、校验、解压都在这里进行,失败可随时丢弃。
  • backup/:必要时保留上一版本,用于快速回滚或排查事故。

如果直接把网络流写进 active/:

  • 下载到 70% 崩掉时,正式文件已经被污染。
  • ZIP 还没校验就被业务侧读取,可能触发解压失败或资源缺失。
  • 即使重新下载成功,也很难判断当前读到的到底是哪一版。

最小代码骨架:把“下载成功”和“可切换”分开 ​

下面不是仓库现有源码,而是 download_service.dart 这类实现最值得保留的骨架:

dart
Future<DownloadResult> updateBundle() async {
  final meta = await api.fetchBundleMeta();
  final tempFile = File('${stagingDir.path}/${meta.version}.zip.part');
  final finalTempFile = File('${stagingDir.path}/${meta.version}.zip');

  await downloader.download(
    url: meta.url,
    destination: tempFile,
    expectedBytes: meta.size,
  );

  await tempFile.rename(finalTempFile.path);
  await verifier.verifySha256(finalTempFile, meta.sha256);

  final extractedDir = Directory('${stagingDir.path}/extracted_${meta.version}');
  await unzipper.extract(finalTempFile, extractedDir);
  await verifier.verifyManifest(extractedDir, expectedVersion: meta.version);

  await promoter.promoteAtomically(
    candidateDir: extractedDir,
    activeDir: Directory('${resourceRoot.path}/active'),
  );

  return DownloadResult.success(version: meta.version);
}

这段骨架强调三个边界:

  1. 下载目标先写临时路径,不碰正式目录。
  2. 校验通过前不宣布成功。
  3. 切换动作集中在 promoteAtomically(),不要把 rename / delete / copy 分散到多处。

1. 下载阶段:关注“可恢复写入”,不是只求把流收完 ​

对应 Lab:download-service-update-case · download_service_update_case_demo.dart

下载阶段最容易被低估,因为代码表面上像这样:

dart
final response = await client.send(request);
await response.stream.pipe(file.openWrite());

但真实工程里至少要补四个判断:

1.1 先决定是否真的要下 ​

先拉元信息后,最好先判断:

  • 当前 active 版本是否已经等于远端版本
  • 本地是否已有同版本且 hash 已验证通过
  • 是否只是上次下载到一半,可继续续传
  • 设备是否满足网络 / 存储条件

否则会出现“每次冷启动都全量重下”的浪费。

1.2 目标文件必须是临时文件 ​

常见写法:

dart
final tempPath = '${stagingDir.path}/${meta.version}.zip.part';

这样做的好处是:

  • 看到 .part 就知道它不可直接读取。
  • 下载中断时,清理策略只扫临时文件,不误删正式版本。
  • 下载完成后 rename 为 .zip,形成“从未完成态到已完成态”的显式边界。

1.3 断点续传不是默认安全,需要和服务端能力对齐 ​

如果 download_service.dart 想支持 Range 续传,至少要确认:

  • 服务端支持 Accept-Ranges
  • 续传依据的是同一个资源版本,而不是 CDN 已切新包
  • ETag / Last-Modified 还能证明本地残片没过期
  • 最终还会做全量 hash 校验,而不是“续传完就算成功”

否则最危险的情况是:

  • 前 60% 是旧文件
  • 后 40% 是新文件
  • 总长度看起来还对
  • 最后产出一个不可用但不易第一时间发现的坏包

1.4 进度上报要能区分“下载慢”和“卡在校验 / 解压” ​

很多 UI 只显示一个 0~100% 的进度条,但资源更新里最好拆成:

  • 下载进度
  • 校验进度 / 当前阶段
  • 解压进度
  • 原子切换完成

否则用户看到 100% 后还要等十几秒,会误以为卡死。

2. 校验阶段:这是“能不能切换”的闸门 ​

对应 Lab:resource-package-verifier · resource_package_verifier.mjs

download_service.dart 最大的价值,通常不在 HTTP,而在校验策略。

2.1 最低限度:长度 + hash ​

典型元信息:

json
{
  "version": 42,
  "size": 12582912,
  "sha256": "7f0c...",
  "url": "https://cdn.example.com/bundles/v42.zip"
}

端上至少要验证:

  • 文件长度是否等于 size
  • sha256 是否匹配

原因是:

  • 只看 HTTP 200 无法说明内容正确
  • 只看长度无法避免内容错乱
  • 只看文件存在无法区分完整包和脏包

2.2 ZIP / manifest 校验比 hash 更贴近“业务可用” ​

即使 hash 对了,也不代表业务一定能读:

  • ZIP 可以完整,但 manifest 字段缺失
  • 解压目录结构可能不符合当前读取逻辑
  • 资源包版本与业务配置版本可能不兼容

所以更稳的顺序通常是:

  1. 校验文件长度
  2. 校验 hash
  3. 解压到 staging 目录
  4. 校验 manifest / 入口文件 / 关键资源存在
  5. 校验资源版本和客户端期望是否兼容

2.3 校验失败时要把失败类型打清楚 ​

不要只抛一个 Exception('download failed')。至少应区分:

  • networkFailure
  • diskWriteFailure
  • sizeMismatch
  • hashMismatch
  • unzipFailure
  • manifestInvalid
  • promotionFailure

这样后面才能决定:

  • 是否允许自动重试
  • 是否直接删掉临时文件
  • 是否需要强制回源重下
  • 是否要上报告警,怀疑 CDN 或构建产物有问题

3. 原子更新阶段:真正避免“半更新事故”的核心 ​

对应 Lab:atomic-resource-promotion · AtomicResourcePromotionActivity.kt

很多人以为原子更新只是“rename 一下”,但真正要守住的是:业务读路径在任何时刻都只能看到一个完整版本。

3.1 为什么不能边下载边覆盖正式目录 ​

如果正式目录已经被页面、WebView、解压器或模型加载器读取中,边写边覆盖会造成:

  • 有的文件还是旧版
  • 有的文件已经变成新版
  • 索引文件和真实资源版本不一致
  • App 重启前后表现不同,排障极难

3.2 更稳的切换思路:候选目录准备完成后再一次性切换 ​

示意流程:

text
active_v41/   <- 当前线上可读
staging_v42/  <- 下载 + 校验 + 解压都在这里

校验全部通过后:
1. active_v41 -> backup_v41
2. staging_v42 -> active_v42
3. 更新 current_version 指针

如果平台 / 文件系统允许目录 rename,这通常是最干净的做法;如果不方便,也应确保“新目录先准备完整,再切指针”,而不是逐文件覆盖。

3.3 “当前生效版本”最好有独立指针 ​

例如单独写一个元数据文件:

json
{
  "activeVersion": 42,
  "activatedAt": 1722432000
}

读取逻辑统一先看这个指针,而不是“扫目录里最新文件名”。

好处:

  • 崩溃恢复时容易判断哪版才是真正生效版本
  • 回滚时只需切指针或切目录别名
  • 多套资源并存时,读取逻辑不需要猜测

4. 失败恢复:重点是“旧版本继续可用” ​

资源更新最怕的不是“这次没更上”,而是“这次没更上还把旧版本弄坏了”。

4.1 失败时的默认策略应该是保守的 ​

建议默认策略:

  • 网络失败:保留 .part,记录断点,等待重试条件
  • hash / manifest 失败:删除本次 staging 内容,保留 active 旧版本
  • 原子切换失败:不要删除 active,优先回滚并打错误日志
  • 清理失败:标记待清理,但不要影响 active 继续服务

4.2 恢复逻辑最好在启动时做一次巡检 ​

典型巡检问题:

  • 是否存在遗留 .part 文件
  • 是否存在 staging 目录但没有完成激活
  • activeVersion 指针指向的目录是否存在
  • backup 是否过多,是否要按策略淘汰

启动巡检比等用户再次点击下载更靠谱,因为很多故障发生在上次异常退出之后。

4.3 重试要有上限和退避 ​

如果校验失败是由服务端产物本身错误引起的,无脑重试只会:

  • 白白耗流量
  • 连续写盘
  • 让监控噪音更大
  • 把一个“应立即告警”的产物问题伪装成普通网络波动

所以至少应区分:

  • 网络类失败:可指数退避重试
  • 数据一致性失败:暂停自动重试,等待新元信息或人工修复

5. 本地缓存策略:不要把“缓存”和“更新目录”混成一层 ​

download_service.dart 周边最常见的设计错误,就是把缓存目录当万能抽屉。

5.1 最少要区分三种缓存角色 ​

角色作用生命周期
临时下载缓存.part / .tmp,用于下载中断恢复短,失败可删
候选版本缓存已下完、待校验 / 待切换的版本中,只有通过后才能晋升
生效版本缓存当前业务正在读取的版本长,由版本指针管理

如果三者混在一个目录:

  • 清理策略会很难写
  • 很容易误删 active
  • 很难判断哪个版本真正可读

5.2 LRU 只能清理“非生效、非进行中”的旧版本 ​

端上空间不足时,常见做法是按最后访问时间删旧缓存。但在更新系统里,LRU 不能无脑全删,必须排除:

  • 当前 active 版本
  • 当前 downloading / verifying 的 staging 版本
  • 作为回滚保底的最近一个 backup 版本

更稳的清理规则:

  1. 永远保留 active
  2. 保留最新一个 backup
  3. 删除过期 staging 残留
  4. 对更老的历史版本按 LRU 或版本号裁剪

5.3 “命中缓存”必须建立在可验证性上 ​

不要因为本地有 v42.zip 就直接判定缓存命中,至少要满足:

  • 元信息版本一致
  • 文件长度一致
  • hash 已确认通过,或上次验证结果有可靠落盘记录

否则缓存命中会把坏包长期留在本地,问题比重新下载更隐蔽。

Android / Flutter / Backend 对照 ​

维度Flutter 侧更关注什么Android / iOS 宿主更关注什么Backend / CDN 更关注什么
元信息拉取版本状态、UI 展示、重试入口网络切换、生命周期恢复版本发布、ETag、缓存控制
下载进度上报、状态机驱动文件句柄、后台能力、磁盘空间Range 支持、带宽、回源成本
校验错误可视化、失败原因透传hash / 解压效率、文件系统可靠性构建产物稳定性、manifest 正确性
原子切换何时通知页面刷新rename / 指针更新 / 回滚版本兼容性与回滚策略
失败恢复UI 告知与手动重试启动巡检、残留清理异常产物下线、重新发布

关键点不是 Flutter 一层把所有事都做完,而是:

  • Flutter 适合表达状态机与用户反馈
  • 宿主文件系统层 适合做稳定落盘、原子切换、启动巡检
  • Backend / CDN 负责提供可信元信息与可追溯版本

常见事故与修法 ​

事故线上现象根因修法
下载完成立刻覆盖 active新版本失败后页面白屏 / 资源丢失没有 staging 与 active 分层先下载到 staging,校验后再原子切换
只校验 HTTP 200文件能下完但解压失败把“传输完成”当“内容正确”补 size + sha256 + manifest 校验
断点续传后不做全量 hash偶发出现难复现坏包拼接了不同版本残片续传后仍做全量校验,ETag 不一致时重下
清理缓存时误删 active下次启动资源全丢缓存目录角色没分开active / staging / backup 分目录管理
UI 只认一个 success / fail用户无法判断到底卡在哪状态机过粗显式拆 downloading / verifying / promoting
启动不巡检残留文件上次异常退出后一直卡死在错误状态没有恢复入口启动时检查 .part、staging、指针一致性

与相近概念对比 ​

概念它解决什么在本案例里不能替代什么
普通 HTTP 下载把远端字节拉到本地完整性校验与版本切换
本地缓存复用已下载内容,减少带宽原子更新协议
断点续传减少重下成本内容正确性的最终证明
ZIP 解压生成可读目录结构版本激活与回滚策略
文件 rename单次切换动作整体更新状态机

对应实验 ​

Lab说明源码
download-service-update-caseFlutter 更新状态机:metadata、download、verify、promotedownload_service_update_case_demo.dart
resource-package-verifier共享校验器:size / hash / manifest 三层验证resource_package_verifier.mjs
atomic-resource-promotionAndroid 侧 staging → active 原子晋升与 backup 保留AtomicResourcePromotionActivity.kt

复习检查题 ​

  1. 为什么 download_service.dart 不应只被理解成“文件下载工具”?

    答:因为它真正承载的是端上资源更新协议,不只是把字节下载下来,还要负责版本判断、完整性校验、原子切换、失败恢复和缓存治理。只把它当下载工具,通常会漏掉最关键的“旧版本保护”和“新版本可用性证明”。

  2. 为什么要把下载目标先写到 .part 或 staging 目录,而不是直接写到正式路径?

    答:因为正式路径往往正在被业务读取。直接写正式路径会让下载中的半成品污染当前线上版本;而 .part / staging 能明确表示“尚不可读”,失败时也能安全清理,不影响 active 旧版本继续服务。

  3. 为什么 hash 校验通过后还可能需要 manifest / 解压校验?

    答:hash 只能证明文件字节与服务端声明一致,但不能直接证明它符合当前业务读取约定。ZIP 结构、入口文件、manifest 字段和资源版本兼容性仍然可能有问题,所以还要做面向业务可用性的二次校验。

  4. 原子更新最核心要守住的约束是什么?

    答:业务在任何时刻都只能读到一个完整版本,不能看到半旧半新的混合状态。因此下载、校验、解压都应在 staging 完成,切换动作集中在最后一步一次性完成,必要时还要有 backup / 指针来支持回滚。

  5. 本地缓存策略为什么不能只按 LRU 删除“看起来最老的文件”?

    答:因为更新系统里不同文件角色不同:active 是当前生效版本,staging 是进行中的候选版本,backup 是回滚保底,这些都不能被普通 LRU 误删。清理时必须先识别角色,再对非生效、非进行中的历史版本做裁剪。

速记 ​

  • download_service.dart 本质上是“端上更新状态机”,不是普通下载器。
  • 下载成功 ≠ 文件可用;必须补 size / hash / manifest 校验。
  • staging、active、backup 三层分开,才能守住原子替换和失败恢复。
  • 旧版本保护优先级高于“这次必须更新成功”。
  • 缓存命中要建立在可验证性上,不能只看文件是否存在。

站点构建时间:2026/8/24 23:43:17