Appearance
项目案例:download_service.dart 的下载 / 校验 / 原子更新 / 失败恢复
一句话定位
这一页不把 download_service.dart 当成“下文件的工具类”,而是把它看成一个端上资源更新状态机:拉远端版本 → 下载到临时文件 → 校验完整性 → 原子替换正式文件 → 失败时保留旧版本继续服务。
代码索引
为什么这个案例值得单独拆
很多端上下载逻辑一开始都很简单:
- 调接口拿到下载地址
GET文件并直接写到目标路径- 下载完成后通知 UI “更新成功”
但一旦文件变成:
- 离线包 / 模型文件 / 配置包 / 资源 ZIP
- 大文件、弱网、多次中断、磁盘可能不足
- 需要做到“新版本没准备好前,旧版本绝不能被破坏”
它就不再是普通下载,而是一个小型更新系统。这里最容易被问到的判断题是:
- 为什么离线包 / 模型文件 / 资源 ZIP 不能按普通图片缓存那样直接覆盖?
- 一句话答:因为它们会被业务长期读取,新版本未校验前直接覆盖会污染当前可用版本,导致白屏或资源缺失。
- 为什么大文件、弱网、多次中断场景下,“下完字节”仍不等于“版本可切换”?
- 一句话答:因为还缺 size / hash / manifest 校验与 staging → active 的原子切换,任何一步失败都不应宣布更新成功。
- 为什么失败恢复不能只靠一个“重试按钮”?
- 一句话答:因为失败恢复的关键是保留旧版本继续服务、识别坏临时文件、决定是否允许自动重试,而不是只把网络请求再发一遍。
download_service.dart 这类代码真正要解决的是:
- 下载成功不等于可用:文件可能只下完了字节,还没做 hash / ZIP / manifest 校验。
- 新包可用前不能覆盖旧包:否则会出现半更新、白屏、资源缺失。
- 失败恢复不能只靠重试按钮:需要保留旧版本、清理坏临时文件、下次还能继续决策。
- 缓存不能只看“有没有这个文件”:还要区分当前生效版本、下载中的临时版本、已经废弃但可回滚的版本。
先把链路拆成五段
一个典型的 download_service.dart 下载更新链路,建议拆成五段:
- 远端元信息阶段:拉版本号、文件大小、hash、ETag、更新时间、解压目标目录。
- 临时下载阶段:写入
.part/.tmp文件,必要时记录断点与已下载字节数。 - 完整性校验阶段:校验长度、hash、ZIP 结构、manifest、业务版本匹配。
- 原子切换阶段:把新目录 / 新文件替换成正式版本,并让读路径一次性切到新版本。
- 恢复与清理阶段:失败时保留旧版本继续服务,清理损坏临时文件,决定是否重试。
这五段如果混在一个 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);
}这段骨架强调三个边界:
- 下载目标先写临时路径,不碰正式目录。
- 校验通过前不宣布成功。
- 切换动作集中在
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 字段缺失
- 解压目录结构可能不符合当前读取逻辑
- 资源包版本与业务配置版本可能不兼容
所以更稳的顺序通常是:
- 校验文件长度
- 校验 hash
- 解压到 staging 目录
- 校验 manifest / 入口文件 / 关键资源存在
- 校验资源版本和客户端期望是否兼容
2.3 校验失败时要把失败类型打清楚
不要只抛一个 Exception('download failed')。至少应区分:
networkFailurediskWriteFailuresizeMismatchhashMismatchunzipFailuremanifestInvalidpromotionFailure
这样后面才能决定:
- 是否允许自动重试
- 是否直接删掉临时文件
- 是否需要强制回源重下
- 是否要上报告警,怀疑 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 版本
更稳的清理规则:
- 永远保留 active
- 保留最新一个 backup
- 删除过期 staging 残留
- 对更老的历史版本按 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-case | Flutter 更新状态机:metadata、download、verify、promote | download_service_update_case_demo.dart |
| resource-package-verifier | 共享校验器:size / hash / manifest 三层验证 | resource_package_verifier.mjs |
| atomic-resource-promotion | Android 侧 staging → active 原子晋升与 backup 保留 | AtomicResourcePromotionActivity.kt |
复习检查题
为什么
download_service.dart不应只被理解成“文件下载工具”?答:因为它真正承载的是端上资源更新协议,不只是把字节下载下来,还要负责版本判断、完整性校验、原子切换、失败恢复和缓存治理。只把它当下载工具,通常会漏掉最关键的“旧版本保护”和“新版本可用性证明”。
为什么要把下载目标先写到
.part或 staging 目录,而不是直接写到正式路径?答:因为正式路径往往正在被业务读取。直接写正式路径会让下载中的半成品污染当前线上版本;而
.part/ staging 能明确表示“尚不可读”,失败时也能安全清理,不影响 active 旧版本继续服务。为什么 hash 校验通过后还可能需要 manifest / 解压校验?
答:hash 只能证明文件字节与服务端声明一致,但不能直接证明它符合当前业务读取约定。ZIP 结构、入口文件、manifest 字段和资源版本兼容性仍然可能有问题,所以还要做面向业务可用性的二次校验。
原子更新最核心要守住的约束是什么?
答:业务在任何时刻都只能读到一个完整版本,不能看到半旧半新的混合状态。因此下载、校验、解压都应在 staging 完成,切换动作集中在最后一步一次性完成,必要时还要有 backup / 指针来支持回滚。
本地缓存策略为什么不能只按 LRU 删除“看起来最老的文件”?
答:因为更新系统里不同文件角色不同:active 是当前生效版本,staging 是进行中的候选版本,backup 是回滚保底,这些都不能被普通 LRU 误删。清理时必须先识别角色,再对非生效、非进行中的历史版本做裁剪。
速记
download_service.dart本质上是“端上更新状态机”,不是普通下载器。- 下载成功 ≠ 文件可用;必须补 size / hash / manifest 校验。
- staging、active、backup 三层分开,才能守住原子替换和失败恢复。
- 旧版本保护优先级高于“这次必须更新成功”。
- 缓存命中要建立在可验证性上,不能只看文件是否存在。