Flutter 语音房礼物下载方案(完整版)
场景:语音房礼物资源下载,文件类型为 mp4(~10MB)和 webp(~1MB)
核心能力:网络自适应 · 多文件并行 · 单文件分片 · 断点续传 · 智能调度
目录
- 一、整体架构
- 二、网络质量探测
- 三、下载调度引擎
- 四、分片下载
- 五、断点续传
- 六、失败重试与容错
- 七、存储管理
- 八、预加载策略
- 九、监控与埋点
- 十、Flutter 网络优化深度
- 十一、关键设计决策汇总
一、整体架构
┌──────────────────────────────────────────────────────────────┐
│ 礼物业务层 │
│ (礼物列表展示、播放渲染、用户触发) │
├──────────────────────────────────────────────────────────────┤
│ 下载调度引擎 │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ 网络探测器 │ │ 优先级队列 │ │ 并发度/分片策略控制 │ │
│ └────────────┘ └────────────┘ └──────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 分片下载层 │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ 分片管理器 │ │ 断点续传 │ │ 分片合并(Isolate)+校验│ │
│ └────────────┘ └────────────┘ └──────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 网络优化层(第十章) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ HTTPDNS │ │ HTTP/2 │ │ 连接预热 │ │ TLS Session │ │
│ │ + 预解析 │ │ 多路复用 │ │ TCP预连接 │ │ 复用 + 1.3 │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 弱网自适应│ │ Dio 专用 │ │ 流式传输 │ │ 自适应超时 │ │
│ │ + 降级 │ │ 实例+拦截 │ │ Stream │ │ + 速率检测 │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 传输层 │
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
│ │ HTTP Range 请求管理 │ │ CDN 签名 URL 管理 + 刷新 │ │
│ └──────────────────────┘ └─────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 存储层 │
│ ┌────────────┐ ┌─────────────┐ ┌────────────────────┐ │
│ │ 完成文件缓存 │ │ 元数据 SQLite │ │ 临时分片文件 │ │
│ └────────────┘ └─────────────┘ └────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
数据流:
用户触发送礼 / 预加载触发
↓
检查本地缓存是否已有文件 ── 命中 → 直接使用
↓ 未命中
检查是否有未完成的分片 ── 有 → 断点续传流程
↓ 无
探测网络质量 → 决定并发参数
↓
进入优先级队列 → 调度引擎分配连接
↓
HEAD 请求获取文件信息(大小/ETag/是否支持Range)
↓
计算分片方案 → 多分片并行下载
↓
所有分片完成 → 合并 → 校验 MD5 → 存入缓存目录
↓
通知业务层 → 播放/渲染礼物
二、网络质量探测
2.1 探测维度
| 指标 | 采集方式 | 作用 |
|---|---|---|
| 带宽估算 | 用一个小文件(~50KB 探测文件)计算实际下载速率 | 决定并发数和分片大小 |
| RTT 延迟 | 每次 HTTP 请求的首字节时间(TTFB) | 延迟高时减少分片并发数(每个分片都有握手开销) |
| 网络类型 | Connectivity 插件获取 WiFi / 5G / 4G / 3G | 粗粒度初始策略 |
| 丢包率/抖动 | 连续多次小请求的成功率和耗时方差 | 判断网络稳定性 |
2.2 网络质量分级
| 等级 | 判定条件(参考值) | 标签 |
|---|---|---|
| 优秀 | 带宽 > 5MB/s,RTT < 50ms | WiFi / 5G 稳定 |
| 良好 | 带宽 2-5MB/s,RTT 50-150ms | WiFi / 4G 正常 |
| 一般 | 带宽 500KB-2MB/s,RTT 150-300ms | 4G 弱信号 |
| 差 | 带宽 < 500KB/s,RTT > 300ms | 3G / 弱网 |
2.3 探测时机
| 时机 | 方式 | 说明 |
|---|---|---|
| 进入语音房前 | 主动探测 | 冷启动做一次完整探测 |
| 下载过程中 | 搭便车采样 | 取最近 5 个分片的平均速率做滑动窗口,实时修正参数 |
| 网络切换时 | 被动触发 | WiFi ↔ 蜂窝切换后立即重新探测 |
核心原则:不要频繁主动探测(浪费流量),主要依赖"搭便车"——从实际分片下载行为中采集真实速率。
三、下载调度引擎
3.1 两层并发模型
第一层:文件级并发 —— 同时下载几个文件
├── 文件 A (mp4, 10MB)
│ └── 第二层:分片并发 —— 这个文件分几片同时下
│ ├── chunk 0 [0, 2MB) │ ├── chunk 1 [2MB, 4MB) │ ├── chunk 2 [4MB, 6MB) │ ├── chunk 3 [6MB, 8MB) │ └── chunk 4 [8MB, 10MB) ├── 文件 B (webp, 1MB) │ ├── chunk 0 [0, 512KB) │ └── chunk 1 [512KB, 1MB) └── 文件 C (mp4, 8MB) → 等待调度...
3.2 参数根据网络质量动态调整
| 网络等级 | 文件并发数 | 单文件分片并发数 | 分片大小 | 总连接数上限 |
|---|---|---|---|---|
| 优秀 | 3-4 | 4-5 | 2MB | 16 |
| 良好 | 2-3 | 3-4 | 1MB | 10 |
| 一般 | 1-2 | 2-3 | 512KB | 6 |
| 差 | 1 | 1-2 | 256KB | 3 |
总连接数上限的意义:所有文件的分片并发数总和不超过此值。防止在弱网下开太多连接反而互相抢带宽。
3.3 分片大小的取舍
| 分片过小(< 256KB) | 分片过大(> 4MB) |
|---|---|
| HTTP 头部 + TCP 握手开销占比过高 | 单片失败时重试成本高 |
| 请求次数太多 | 弱网下容易超时 |
| 频繁的 DB 状态更新 | 断点续传粒度太粗 |
计算公式:
chunkSize = clamp(估算带宽 × 目标单片下载时间, 256KB, 4MB)
目标单片下载时间 = 3-5 秒(平衡响应性和效率)
举例:
- 带宽 4MB/s → 4MB/s × 4s = 16MB → clamp → 4MB
- 带宽 1MB/s → 1MB/s × 4s = 4MB → clamp → 4MB
- 带宽 200KB/s → 200KB/s × 4s = 800KB → clamp → 800KB → 取 512KB 对齐
3.4 优先级调度
优先级权重公式
W = α × 紧急度 + β × (1 / 文件大小) + γ × 热度 + δ × 已完成比例
α=0.5 β=0.15 γ=0.15 δ=0.2
| 因子 | 含义 | 设计目的 |
|---|---|---|
| 紧急度 | 用户正在触发 = 1.0,预加载 = 0.2 | 用户触发的礼物必须最快展示 |
| 1/文件大小 | webp(1MB) 得分高于 mp4(10MB) | 小文件优先完成,用户更快看到效果 |
| 热度 | 房间内高频赠送的礼物得分高 | 高概率被用到的优先 |
| 已完成比例 | 已下载 90% 的文件得分高 | 避免所有文件都半成品,优先收尾 |
抢占机制
- 用户触发的礼物直接置顶,权重设为最大
- 可以借用低优先级文件的分片连接数
- 被抢占的文件暂停排队,不丢失已下载进度
3.5 带宽分配策略
不是简单平分带宽,而是通过控制分片并发数间接分配:
| 文件类型 | 分配策略 | 实现方式 |
|---|---|---|
| 用户正在触发的礼物 | 60-70% 带宽 | 分配 4 个分片并发 |
| 预加载礼物 | 30-40% 带宽 | 限制 1-2 个分片并发 |
| 网络变差时 | 全部让给紧急文件 | 暂停所有预加载 |
四、分片下载
4.1 前提:CDN 是否支持 Range 请求
断点续传和分片下载的基础是 HTTP Range 请求。主流 CDN 全部支持:
| CDN 厂商 | 支持 Range | 默认开启 |
|---|---|---|
| 阿里云 CDN | 支持 | 是 |
| 腾讯云 CDN | 支持 | 是 |
| AWS CloudFront | 支持 | 是 |
| Cloudflare | 支持 | 是 |
| 七牛云 | 支持 | 是 |
验证方法:
# 1. 确认是否支持 Range
curl -I https://your-cdn.com/gift/001.mp4
# 响应头包含 Accept-Ranges: bytes → 支持
# 2. 实际请求一个范围
curl -H "Range: bytes=0-1023" -o /dev/null -w "%{http_code}" https://your-cdn.com/gift/001.mp4
# 返回 206 → 支持
# 返回 200 → 不支持(忽略了 Range)
必须满足的完整链路:
Flutter 客户端 ──Range 请求──→ CDN 节点 ──→ 源站(OSS/S3/Nginx)
↑ ↑ ↑
你的代码 全部支持 这里也必须支持
三个环节任意一个不支持 Range,分片下载就退化为整文件单连接下载。
4.2 分片下载完整流程
┌─ 1. HEAD 请求 ─────────────────────────────────────────────────┐
│ GET https://cdn.xxx.com/gift/001.mp4 │
│ → 响应: │
│ Content-Length: 10485760 (文件大小 10MB) │
│ Accept-Ranges: bytes (支持分片) │
│ ETag: "a1b2c3d4e5" (文件版本标识) │
│ Content-Type: video/mp4 │
└────────────────────────────────────────────────────────────────┘
↓
┌─ 2. 判断是否需要分片 ──────────────────────────────────────────┐
│ 文件 < 1MB → 不分片,单连接下载 │
│ 文件 >= 1MB 且支持 Range → 按策略分片 │
│ 不支持 Range → 退化为单连接整文件下载 │
└────────────────────────────────────────────────────────────────┘
↓
┌─ 3. 计算分片方案 ──────────────────────────────────────────────┐
│ 示例:10MB 文件,网络良好,分片大小 2MB │
│ │
│ chunk 0: Range: bytes=0-2097151 (0~2MB) │
│ chunk 1: Range: bytes=2097152-4194303 (2~4MB) │
│ chunk 2: Range: bytes=4194304-6291455 (4~6MB) │
│ chunk 3: Range: bytes=6291456-8388607 (6~8MB) │
│ chunk 4: Range: bytes=8388608-10485759 (8~10MB) │
└────────────────────────────────────────────────────────────────┘
↓
┌─ 4. 并行下载分片 ──────────────────────────────────────────────┐
│ │
│ [并发槽1] chunk 0 ████████████ done ✅ │
│ [并发槽2] chunk 1 ████████░░░░ 75% │
│ [并发槽3] chunk 2 ██████░░░░░░ 55% │
│ [等待中] chunk 3 ░░░░░░░░░░░░ pending │
│ [等待中] chunk 4 ░░░░░░░░░░░░ pending │
│ │
│ chunk 0 完成 → 并发槽1 立即启动 chunk 3 │
│ 实时记录每个分片的下载进度到 DB │
└────────────────────────────────────────────────────────────────┘
↓
┌─ 5. 合并分片 ──────────────────────────────────────────────────┐
│ 按 chunkIndex 顺序读取临时文件 → 流式追加写入最终文件 │
│ (不是一次性全部加载进内存) │
└────────────────────────────────────────────────────────────────┘
↓
┌─ 6. 完整性校验 ────────────────────────────────────────────────┐
│ 计算最终文件 MD5 → 与服务端提供的 hash 比对 │
│ 通过 → 删除临时分片,标记完成 │
│ 失败 → 清理所有文件,重新下载 │
└────────────────────────────────────────────────────────────────┘
4.3 具体分片举例
| 场景 | 文件大小 | 网络 | 分片大小 | 分片数 | 并发数 | 预估耗时 |
|---|---|---|---|---|---|---|
| mp4 + 优秀网络 | 10MB | 5MB/s | 2MB | 5 | 4 | ~2.5s |
| mp4 + 一般网络 | 10MB | 1MB/s | 512KB | 20 | 2 | ~10s |
| mp4 + 差网络 | 10MB | 200KB/s | 256KB | 40 | 1 | ~50s |
| webp + 优秀网络 | 1MB | 5MB/s | 不分片 | 1 | 1 | ~0.2s |
| webp + 差网络 | 1MB | 200KB/s | 512KB | 2 | 1 | ~5s |
4.4 分片状态数据模型
每个分片在 SQLite 中持久化一行记录:
| 字段 | 类型 | 说明 |
|---|---|---|
| fileId | String | 礼物文件唯一标识 |
| fileUrl | String | 下载地址(不含签名参数) |
| fileSize | int | 文件总大小(字节) |
| fileETag | String | 文件版本标识(ETag) |
| fileMd5 | String | 文件 MD5(用于最终校验) |
| chunkIndex | int | 分片序号 |
| rangeStart | int | 分片起始字节 |
| rangeEnd | int | 分片结束字节 |
| downloadedBytes | int | 该分片已下载字节数 |
| status | enum | pending / downloading / done / failed |
| retryCount | int | 已重试次数 |
| tempFilePath | String | 分片临时文件路径 |
| createdAt | int | 创建时间戳 |
| updatedAt | int | 最后更新时间戳 |
五、断点续传
5.1 断点续传完整流程
App 重启 / 网络恢复
↓
从 SQLite 查询所有 status != done 的文件
↓
对每个文件执行续传检查:
┌─ 步骤 1:签名 URL 检查 ──────────────────────────────────┐
│ │
│ CDN URL 通常带签名: │
│ https://cdn.xxx.com/gift/001.mp4?token=abc&expire=xxx │
│ │
│ 检查 expire 是否过期 │
│ ├── 未过期 → 继续使用 │
│ └── 已过期 → 调业务接口获取新的签名 URL │
│ (文件没变,只是签名换了,Range 请求依然有效) │
└──────────────────────────────────────────────────────────┘
↓
┌─ 步骤 2:文件版本校验 ───────────────────────────────────┐
│ │
│ 发送 HEAD 请求,检查 ETag 是否与记录的一致 │
│ ├── ETag 一致 → 文件没变,可以续传 │
│ └── ETag 变了 → 文件已被更新,废弃所有分片,重新下载 │
│ │
│ 或者用 If-Range 头自动处理: │
│ 请求头: If-Range: "旧ETag" │
│ └── 文件没变 → 服务端返回 206,续传 │
│ └── 文件变了 → 服务端返回 200,整文件重新下载 │
└──────────────────────────────────────────────────────────┘
↓
┌─ 步骤 3:逐个分片恢复 ──────────────────────────────────┐
│ │
│ chunk 0: status=done → 跳过 ✅ │
│ chunk 1: status=done → 跳过 ✅ │
│ chunk 2: status=downloading, downloadedBytes=800KB │
│ → 从 rangeStart + 800KB 处继续 │
│ → Range: bytes=4994304-6291455 │
│ chunk 3: status=pending → 正常下载 │
│ chunk 4: status=failed → 重置 retryCount,重新下载 │
└──────────────────────────────────────────────────────────┘
5.2 分片内的断点续传
不只是分片之间可以续传,每个分片内部也支持续传:
- 每个分片的
downloadedBytes实时更新(每接收 64KB 数据更新一次 DB,不要太频繁影响性能) - 分片恢复时,实际请求的 Range 起始位置 =
rangeStart + downloadedBytes - 分片临时文件以 append 模式写入
5.3 必须处理的三个工程问题
问题一:签名 URL 过期
时间线:
T0: 开始下载,URL 有效期 30 分钟
T0+15min: 下载了 50%,App 切后台
T0+40min: 用户回到 App,URL 已过期
处理:
1. 每次续传前检查 URL 中的 expire 参数
2. 过期 → 调用业务接口 /api/gift/url?giftId=xxx 获取新签名 URL
3. 用新 URL + 旧的 Range 参数继续下载
4. 注意:新旧 URL 的路径和文件必须相同,只是签名参数不同
问题二:文件版本变更
风险场景:
T0: 下载 gift_001.mp4 前 5MB
T1: 运营更换了 gift_001.mp4 的内容(同 URL 不同内容)
T2: 续传后面的 5MB → 前后内容不匹配 → 文件损坏
防御:
1. 首次 HEAD 请求时记录 ETag
2. 续传前 HEAD 请求比对 ETag
3. ETag 变了 → 废弃所有已下载分片 → 完全重新下载
4. 最终的 MD5 校验作为最后防线
问题三:CDN 压缩干扰
极少出现但需要防御:
如果 CDN 对文件启用了 gzip 压缩(响应头 Content-Encoding: gzip)
→ 压缩后的字节流无法按 Range 精确切分
→ 分片下载的数据拼接后解压失败
检测:
HEAD 请求时检查 Content-Encoding
如果是 gzip/br → 退化为单连接整文件下载
实际情况:
CDN 默认不压缩 mp4/webp 等已压缩格式,只压缩 HTML/CSS/JS
所以几乎不会遇到
六、失败重试与容错
6.1 分片级重试
| 策略 | 细节 |
|---|---|
| 最大重试次数 | 单分片 3 次 |
| 退避策略 | 指数退避 + 随机抖动:1s ± 0.3s → 2s ± 0.6s → 4s ± 1.2s |
| 连接超时 | 10 秒 |
| 读超时 | 动态计算:分片大小 / 最低预期速率 × 2(最少 15 秒) |
| 局部失败 | 单分片失败不影响其他分片继续下载 |
6.2 文件级容错
| 场景 | 处理 |
|---|---|
| 单分片重试 3 次仍失败 | 标记该分片 failed,继续下载其他分片 |
| 超过 50% 的分片失败 | 暂停该文件,重新探测网络,调整策略后整体重试 |
| 所有分片重试耗尽仍失败 | 标记文件为 failed,上报监控,移出队列 |
| 用户再次触发该礼物 | 重新进入队列,清理旧的失败记录,从头开始 |
6.3 网络中断处理
网络状态监听(Connectivity 插件)
网络断开:
1. 暂停所有正在进行的 HTTP 请求
2. 保留所有分片进度(已持久化在 DB 中)
3. UI 层可展示"网络已断开,将在恢复后继续下载"
网络恢复:
1. 等待 2 秒稳定期(避免网络抖动导致频繁重启)
2. 重新探测网络质量 → 可能要调整并发参数
3. 按优先级恢复下载队列
4. 每个文件走断点续传流程(检查 URL、ETag)
网络切换(WiFi → 蜂窝):
1. 弹窗提示"当前使用移动数据,是否继续下载?"(可配置)
2. 用户同意 → 降低并发参数,继续下载
3. 用户拒绝 → 暂停所有下载,等 WiFi 恢复
6.4 异常边界处理
| 异常 | 处理 |
|---|---|
| 磁盘空间不足 | 下载前检查剩余空间 ≥ 文件大小 × 1.5(分片 + 合并需要额外空间),不足则清理缓存或提示用户 |
| 下载中 App 被杀 | 下次启动时自动从 DB 恢复未完成的任务 |
| 服务端 5xx 错误 | 按重试策略处理,3 次后标记失败 |
| 服务端 403/404 | 不重试,直接标记失败,上报异常 |
| MD5 校验失败 | 删除所有分片和合并文件,重新下载 |
七、存储管理
7.1 目录结构
app_sandbox/
└── gift_cache/
├── meta.db ← SQLite 数据库
│ ├── table: download_tasks 文件级任务信息
│ ├── table: chunk_records 分片级记录
│ └── table: network_stats 网络质量历史记录
│
├── completed/ ← 已完成的文件(最终使用)
│ ├── gift_001.mp4
│ ├── gift_002.webp
│ ├── gift_003.mp4
│ └── ...
│
└── temp/ ← 下载中的分片临时文件
├── gift_004_chunk_0.tmp
├── gift_004_chunk_1.tmp
├── gift_004_chunk_2.tmp
└── ...
7.2 缓存淘汰策略
| 维度 | 策略 |
|---|---|
| 总缓存上限 | 200MB(可通过服务端配置下发) |
| 淘汰算法 | LRU + 热度权重 |
| 保护机制 | 最近 24 小时内使用过的文件不淘汰 |
| 清理时机 | 每次新文件下载完成后检查总大小;App 启动时检查 |
| 临时文件清理 | 超过 24 小时未更新的分片临时文件自动清理 |
| 淘汰顺序 | 最久未使用 → 文件最大 → 热度最低 |
7.3 文件完整性保障(四层校验)
第 1 层(下载前):服务端接口返回文件的 MD5 和大小
↓
第 2 层(下载中):每个分片验证 Content-Length 匹配
↓
第 3 层(下载后):合并后整文件 MD5 校验
↓
第 4 层(使用前):播放/渲染前快速校验文件头魔数
mp4 → 检查 ftyp box
webp → 检查 RIFF 头 + WEBP 标识
八、预加载策略
8.1 预加载时机
| 时机 | 行为 | 优先级 |
|---|---|---|
| 进入语音房 | 拉取房间礼物列表 → 按热度排序 → 预加载 Top N | 中 |
| 房间空闲期 | WiFi + 前台 + 无用户操作 → 后台预加载更多 | 低 |
| 礼物列表更新 | 服务端推送新礼物 → 差量预加载新增的 | 中 |
| 蜂窝网络 | 降低或完全不预加载(节省流量) | 跳过 |
8.2 智能预加载
| 策略 | 依据 |
|---|---|
| 用户偏好 | 用户历史送礼记录 → 优先预加载常送的礼物类型 |
| 房间场景 | PK 房 → 预加载 PK 礼物;生日房 → 预加载生日礼物 |
| 文件类型 | webp 优先于 mp4(体积小,完成快) |
8.3 预加载与按需下载的冲突处理
场景:文件 A 正在预加载(低优先级,1 个分片并发)
↓
用户触发了礼物 A
↓
处理:
1. 不中断、不重新下载
2. 直接提升文件 A 的优先级为最高
3. 增加其分片并发数(从 1 → 4)
4. 抢占其他预加载文件的连接数
5. 已完成的分片保留,只加速未完成的部分
九、监控与埋点
9.1 核心指标
| 指标 | 计算方式 | 告警阈值 |
|---|---|---|
| 文件下载成功率 | 成功数 / 总请求数 | < 95% |
| 分片失败率 | 失败分片数 / 总分片数 | > 5% |
| 平均下载耗时 | 按网络等级分桶统计 | P99 > 30s |
| 首帧展示时间 | 用户触发 → 礼物开始播放 | P95 > 5s |
| 缓存命中率 | 命中次数 / 总请求次数 | < 70% |
| 断点续传成功率 | 续传成功 / 续传尝试 | < 90% |
| MD5 校验失败率 | 校验失败 / 下载完成数 | > 0.1% |
9.2 每次下载的埋点数据
| 字段 | 说明 |
|---|---|
| giftId | 礼物 ID |
| fileType | mp4 / webp |
| fileSize | 文件大小 |
| networkLevel | 网络等级 |
| networkType | WiFi / 4G / 5G |
| chunkCount | 分片数 |
| concurrency | 并发数 |
| totalTime | 总耗时 |
| retryCount | 总重试次数 |
| isResumed | 是否断点续传 |
| result | success / fail / cancelled |
| failReason | 失败原因 |
十、Flutter 网络优化深度
本章将 Flutter 网络优化的知识体系融入礼物下载场景,覆盖从 DNS 解析到字节写入磁盘的全链路。
10.1 网络请求全链路耗时分析
一个分片下载请求从发出到数据落盘,经历的完整链路:
┌──────────────────────────────────────────────────────────────────────┐
│ 一次分片下载的耗时拆解 │
├──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┤
│ DNS 解析 │ TCP 握手 │ TLS 握手 │ 请求发送 │ 首字节等待 │ 数据传输 │
│ (TTDNS) │ (TCP RTT) │ (TLS RTT) │ │ (TTFB) │ (Transfer) │
│ 50-200ms │ 1 RTT │ 1-2 RTT │ <1ms │ 10-50ms │ 与大小成正比 │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘
优化目标:尽量消除或缩短前面几个阶段,让时间集中在有效的数据传输上
关键认识:对于一个 2MB 的分片,在良好网络下传输本身只需 ~0.4s,但 DNS + TCP + TLS 握手可能就要 200-500ms。分片越小,这种"固定税"的占比越高,这也是分片不能太小的根本原因。
10.2 DNS 优化
10.2.1 问题
- 系统 DNS 解析依赖运营商 LocalDNS,可能被劫持、污染、解析慢
- 每次冷启动后首次请求都要等 DNS 解析
- 不同运营商解析到不同 CDN 节点,可能不是最优节点
10.2.2 HTTPDNS
| 维度 | 传统 LocalDNS | HTTPDNS |
|---|---|---|
| 解析方式 | UDP 递归查询 | HTTP 直接向 DNS 服务商请求 |
| 劫持风险 | 高(运营商劫持) | 低(HTTPS 加密) |
| 解析精度 | 运营商粒度 | 可精确到客户端 IP |
| 缓存控制 | 运营商控制 TTL | 客户端可控 |
| Flutter 方案 | 系统默认 | 阿里云/腾讯云 HTTPDNS SDK |
在礼物下载中的应用:
- 进入语音房时,提前通过 HTTPDNS 解析 CDN 域名,缓存 IP
- Dio 请求时直接用 IP + Host 头,跳过系统 DNS
- 缓存多个 IP,主 IP 不通时自动切换备用 IP
10.2.3 DNS 预解析
时机:App 启动 / 进入语音房
预解析域名列表:
├── cdn.xxx.com ← 礼物资源 CDN
├── api.xxx.com ← 业务接口
└── static.xxx.com ← 其他静态资源
结果缓存到内存 Map<String, List<String>>:
cdn.xxx.com → [1.2.3.4, 5.6.7.8]
TTL 管理:
├── 默认缓存 5 分钟
├── 解析失败时使用上次缓存结果(兜底)
└── 网络切换时清空缓存重新解析
10.3 连接层优化
10.3.1 HTTP/2 多路复用
HTTP/1.1 下载 4 个分片:
连接1 ──── chunk0 ────────────────────
连接2 ──── chunk1 ────────────────────
连接3 ──── chunk2 ────────────────────
连接4 ──── chunk3 ────────────────────
→ 4 条 TCP 连接,4 次 TLS 握手
HTTP/2 下载 4 个分片:
连接1 ──┬─ stream1: chunk0 ──────────
├─ stream2: chunk1 ────────── 同一条 TCP 连接
├─ stream3: chunk2 ────────── 复用 TLS 会话
└─ stream4: chunk3 ──────────
→ 1 条 TCP 连接,1 次 TLS 握手
| 维度 | HTTP/1.1 | HTTP/2 |
|---|---|---|
| 连接数 | 每个分片一个连接(或连接池复用) | 单连接多路复用 |
| 头部开销 | 每次完整发送 | HPACK 压缩,增量发送 |
| 握手次数 | N 次 TCP+TLS | 1 次 |
| 队头阻塞 | HTTP 层有 | HTTP 层无(TCP 层仍有) |
| CDN 支持 | 全部 | 主流全部支持 |
在礼物下载中的收益:
- 同一个 CDN 域名的所有分片请求复用一条连接
- 省去大量重复的 TCP + TLS 握手时间
- 特别适合分片并发场景——不需要真的开 4 条 TCP 连接也能 4 片并行
Dio 开启 HTTP/2:使用 dio_http2_adapter 替换默认适配器,或使用 cronet_http(基于 Chromium 网络栈)。
10.3.2 连接池管理
即使使用 HTTP/1.1,也要合理管理连接池:
| 参数 | 建议值 | 说明 |
|---|---|---|
| maxConnectionsPerHost | 6-8 | 同一域名最大连接数(HTTP/1.1 场景) |
| idleTimeout | 15 秒 | 空闲连接保持时间 |
| connectionTimeout | 10 秒 | 建立连接超时 |
关键点:
- 所有下载请求共享同一个 Dio 实例(共享连接池)
- 不要每次下载
new Dio(),否则连接池无法复用 - 连接池对象在 Isolate 间不可共享——如果用 Isolate 做下载,每个 Isolate 需要自己的 Dio 实例
10.3.3 TLS 会话复用(Session Resumption)
首次 TLS 握手:
Client → ServerHello ┐
Server → Certificate ├ 2 RTT(TLS 1.2)或 1 RTT(TLS 1.3)
Client → Finished ┘
后续请求复用 Session:
Client → SessionTicket ┐
Server → Finished ┘ 1 RTT(TLS 1.2)或 0 RTT(TLS 1.3)
- TLS 1.3 的 0-RTT 恢复:首次握手后客户端缓存 PSK(Pre-Shared Key),后续连接发送 Early Data,不需要等待服务端响应就开始传数据
- Dio 底层的
dart:ioHttpClient 默认支持 TLS Session 缓存 - 前提:CDN 服务端启用 TLS 1.3(主流 CDN 默认已启用)
10.3.4 证书锁定(Certificate Pinning)
- 防止中间人攻击篡改下载文件
- 在 Dio 中通过
SecurityContext设置可信证书 - 或使用证书公钥 Pin(更灵活,证书轮换时只换证书不换公钥)
- 注意:证书锁定会导致抓包调试困难,需要 Debug 模式下关闭
10.4 Dio 配置优化(针对礼物下载)
10.4.1 专用 Dio 实例
为礼物下载创建独立的 Dio 实例,与业务 API 请求隔离:
全局 Dio 实例规划:
├── apiDio ← 业务接口(JSON 短连接,超时短)
├── downloadDio ← 礼物下载(大文件长连接,超时长,不同拦截器)
└── uploadDio ← 上传场景(如果有)
为什么隔离?
- 下载的超时时间、重试策略与 API 请求完全不同
- 避免大文件下载占满连接池,影响 API 请求响应速度
- 拦截器不同(下载不需要 token 刷新、不需要 JSON 解析)
10.4.2 超时策略
| 阶段 | API 请求 | 分片下载 |
|---|---|---|
| connectTimeout | 10s | 10s |
| sendTimeout | 10s | 不限 |
| receiveTimeout | 15s | 动态计算 |
分片下载的 receiveTimeout 计算:
receiveTimeout = max(分片大小 / 最低可接受速率, 15秒)
示例:
2MB 分片 / 100KB/s 最低速率 = 20s → receiveTimeout = 20s
256KB 分片 / 100KB/s = 2.5s → receiveTimeout = 15s(取最小值)
10.4.3 拦截器设计
downloadDio 拦截器链:
├── LogInterceptor ← 仅 Debug 模式开启,记录请求/响应头
├── RetryInterceptor ← 自动重试(指数退避)
├── NetworkQualityInterceptor ← 采集 TTFB、传输速率,更新网络质量模型
├── SignUrlInterceptor ← 请求前检查 URL 签名是否过期,过期则刷新
└── ProgressInterceptor ← 采集下载进度,更新 DB
NetworkQualityInterceptor 的细节:
- onResponse 时记录:
响应时间 - 请求时间 = TTFB - 在 onReceiveProgress 回调中计算:
已接收字节 / 耗时 = 实时速率 - 每完成一个分片,将该分片的速率加入滑动窗口
- 窗口大小为最近 5 个分片的平均速率
- 速率显著变化时通知调度引擎调整并发参数
10.4.4 ResponseType 选择
分片下载必须使用 ResponseType.stream,而非 ResponseType.bytes:
| ResponseType | 行为 | 内存占用 |
|---|---|---|
| bytes | 等全部数据接收完再返回 Uint8List | 整个分片大小(2MB → 内存峰值 2MB) |
| stream | 返回 ResponseBody.stream,数据流式到达 | 缓冲区大小(~64KB) |
stream 模式的好处:
- 内存占用从 O(分片大小) 降到 O(缓冲区大小)
- 可以边接收边写入磁盘
- 可以实时上报进度
- 4 个分片并发时,bytes 模式可能占 8MB 内存,stream 模式只占 ~256KB
10.5 Isolate 与线程模型
10.5.1 Flutter 的单线程问题
Flutter 主 Isolate(UI 线程):
├── Widget 构建和渲染 ← 不能被阻塞,否则掉帧
├── 动画更新(60/120fps) ← 16ms/8ms 内必须完成
├── 事件处理
└── 异步任务调度
如果在主 Isolate 做这些事:
├── MD5 计算(10MB 文件 → ~100-200ms 阻塞) ← 会掉帧!
├── 分片合并(多次文件读写) ← 会掉帧!
├── SQLite 大量写入 ← 可能卡顿
└── gzip 解压缩 ← 会掉帧!
10.5.2 需要放到 Isolate 的操作
| 操作 | 耗时 | 是否需要 Isolate |
|---|---|---|
| 网络请求本身 | 异步 IO,不阻塞 | 不需要(Dart 异步即可) |
| 流式写入磁盘 | 异步 IO | 不需要 |
| MD5 计算 | CPU 密集,10MB ~200ms | 需要 |
| 分片合并 | IO 密集,可能 100ms+ | 需要(大文件时) |
| 文件头校验 | 读几个字节,<1ms | 不需要 |
| SQLite 写入 | 通常 <5ms | 不需要(sqflite 已在后台线程) |
| 数据压缩/解压 | CPU 密集 | 需要 |
10.5.3 Isolate 使用策略
方案一:compute() —— 简单一次性任务
适合:MD5 计算、文件合并
特点:每次创建新 Isolate,有启动开销(~50-100ms)
方案二:长驻 Isolate + SendPort/ReceivePort
适合:需要频繁调用的场景
特点:Isolate 常驻,通过消息传递任务,避免重复创建
方案三:IsolatePool(自定义线程池)
适合:大量分片并行下载时的 CPU 密集操作
特点:预创建 N 个 Isolate,任务队列分发
本方案推荐:
├── MD5 计算 → compute()(一次性任务,不频繁)
├── 分片合并 → compute()(同上)
└── 如果同时下载 10+ 文件都在做 MD5 → IsolatePool
10.5.4 Isolate 间数据传递的注意事项
- Isolate 间不共享内存,通过 SendPort 传递消息
- 大数据传递(如文件字节数组)会有拷贝开销
- 优化:传递文件路径(String)而非文件内容(Uint8List),让目标 Isolate 自己读文件
TransferableTypedData:零拷贝传递 TypedData(Dart 2.15+),传递后原 Isolate 不再持有
10.6 数据传输优化
10.6.1 请求头优化
减少不必要的请求头,每个字节在弱网下都很珍贵:
精简后的分片下载请求头:
GET /gift/001.mp4 HTTP/2
Host: cdn.xxx.com
Range: bytes=2097152-4194303
If-Range: "a1b2c3d4e5"
Accept-Encoding: identity ← 明确告诉服务端不要压缩(mp4/webp 已压缩)
不需要的头:
✗ Cookie(CDN 不需要)
✗ Authorization(签名在 URL 参数中)
✗ Accept-Language
✗ User-Agent(除非 CDN 做了 UA 校验)
10.6.2 Accept-Encoding: identity
对于 mp4/webp 这种已压缩的文件格式,必须告诉服务端不要做额外压缩:
- 设置
Accept-Encoding: identity - 如果服务端返回了
Content-Encoding: gzip,Range 请求会失效 - CDN 通常不会压缩媒体文件,但加上这个头作为显式保障
10.6.3 响应数据流式处理
传统方式(内存不友好):
网络数据 → 全部加载到内存(Uint8List) → 一次性写入磁盘
峰值内存:= 分片大小
流式处理(推荐):
网络数据 → 64KB 缓冲区 → 立即写入磁盘 → 缓冲区复用
峰值内存:≈ 64KB
实现关键:
Dio 设置 ResponseType.stream
→ 获取 ResponseBody.stream(Stream<Uint8List>)
→ stream.listen() 逐块接收
→ 每块立即 file.writeAsBytes(chunk, mode: FileMode.append)
→ 同时更新下载进度
10.6.4 TCP 窗口与缓冲区
- TCP 接收窗口:操作系统层面自动调节(TCP Window Scaling),Flutter 层面不需要手动调整
- Socket 缓冲区:Dart 的
dart:ioHttpClient 默认缓冲区大小通常够用 - 但要注意:如果分片并发数太多,每个 Socket 都有接收缓冲区,总内存占用 = 并发数 × 缓冲区大小
10.7 弱网优化专项
10.7.1 弱网场景的特征
弱网不只是"慢",还包括:
├── 高延迟:RTT > 300ms,握手时间长
├── 高丢包:TCP 频繁重传,有效吞吐量远低于带宽
├── 抖动大:速率忽快忽慢,超时阈值难以设定
├── 连接不稳定:TCP 连接频繁断开
└── DNS 解析慢:可能 > 1s
10.7.2 弱网下的特殊策略
| 优化项 | 措施 | 原理 |
|---|---|---|
| 减少连接数 | 文件并发 1,分片并发 1-2 | 连接少 → 每个连接分到的带宽多 → 减少超时 |
| 缩小分片 | 256KB | 单片失败成本低,重试快 |
| 增加超时 | connectTimeout 15s,receiveTimeout 动态上调 | 弱网下握手和传输都慢 |
| 优先完成小文件 | webp 优先于 mp4 | 让用户尽快看到部分礼物效果 |
| 降级策略 | 显示静态图替代动画 | 网络极差时不下载 mp4,用 webp 占位 |
| 预热连接 | 提前建立 TCP 连接(不发数据) | 下载时省去握手时间 |
| HTTPDNS | 跳过系统 DNS | 弱网下 DNS 解析可能特别慢 |
10.7.3 自适应超时
固定超时在弱网下不合理:
自适应超时计算:
baseTimeout = 分片大小 / 当前估算速率 × 2 (2 倍余量)
minTimeout = 15 秒
maxTimeout = 120 秒
timeout = clamp(baseTimeout, minTimeout, maxTimeout)
动态调整:
如果连续 2 个分片都接近超时 → 下一个分片超时再延长 50%
如果连续 3 个分片都很快完成 → 可以适当缩短超时
10.7.4 速率检测与自动降级
下载过程中持续监控速率:
速率 > 2MB/s → 维持当前策略
速率 1-2MB/s → 正常
速率 500KB-1MB → 降低并发数
速率 < 500KB → 降到最低配置(1文件×1分片×256KB)
速率 < 50KB → 暂停下载,提示用户网络极差
对于用户触发的礼物 → 显示静态占位图
速率回升时自动恢复(但不立即恢复到最高配置,渐进式提升):
50KB → 200KB → 恢复到"差"配置
200KB → 500KB → 恢复到"一般"配置
有 2 秒滞后期,避免速率抖动导致频繁切换
10.8 连接预热与预建连
10.8.1 TCP 预连接
进入语音房时的预热流程:
1. DNS 预解析 cdn.xxx.com → 1.2.3.4
2. TCP 预连接 1.2.3.4:443(SYN → SYN-ACK → ACK)
3. TLS 预握手(完成 TLS 握手,但不发送业务数据)
4. 保持连接在池中等待
用户触发礼物下载时:
→ 跳过 DNS + TCP + TLS → 直接发送 GET Range 请求
→ 省去 200-500ms
10.8.2 HTTP/2 连接预热
HTTP/2 下只需要预热一条连接,后续所有分片都复用这条连接:
预热时机:
├── 进入语音房时(最佳)
├── 礼物列表 API 返回后(如果礼物 CDN 域名和 API 域名不同)
└── 首个预加载任务启动时
预热方式:
向 CDN 发一个极小的 HEAD 请求(获取某个文件信息)
目的不是获取数据,而是建立 TCP + TLS 连接
后续所有分片请求都能立即使用这条连接
10.9 内存管理优化
10.9.1 下载过程的内存控制
内存消耗点:
├── 网络接收缓冲区:并发数 × ~64KB = 256KB(4并发)
├── 文件写入缓冲区:并发数 × ~64KB = 256KB
├── Dio Response 对象:并发数 × ~1KB
├── SQLite 缓存:< 100KB
├── 分片元数据:每文件 < 10KB
└── 总计:< 1MB(流式处理下)
如果不用流式处理(ResponseType.bytes):
├── 4 个 2MB 分片 → 8MB 内存峰值
├── 加上 Dart GC 的内存碎片 → 可能触发 10MB+ 的内存波动
└── 语音房本身已有音频缓冲区和 UI 渲染开销,这很危险
10.9.2 大文件合并的内存控制
错误做法:
chunk0_bytes = File(chunk0).readAsBytesSync(); // 2MB 进内存
chunk1_bytes = File(chunk1).readAsBytesSync(); // 又 2MB
finalFile.writeAsBytesSync(chunk0_bytes + chunk1_bytes); // 4MB 临时拼接
正确做法(流式合并):
final sink = finalFile.openWrite();
for (chunk in sortedChunks) {
await chunk.openRead().pipe(sink); // 流式传输,内存只占缓冲区大小
}
await sink.close();
内存差异:
错误做法:10MB 文件 → 峰值 ~20MB(原始分片 + 合并后文件同时在内存)
正确做法:10MB 文件 → 峰值 ~128KB(读写缓冲区)
10.9.3 下载完成后的内存释放
- 分片下载完成后,立即关闭 Stream 和 File Handle
- 合并完成后,立即删除临时分片文件(释放磁盘空间)
- Dio Response 不要持有引用,用完即丢
- 如果使用了 Uint8List 做中间处理,及时置为 null(帮助 GC)
10.10 网络安全
10.10.1 传输安全
| 措施 | 说明 |
|---|---|
| HTTPS 强制 | 所有请求必须 HTTPS,拒绝 HTTP 降级 |
| 证书锁定 | 防止中间人攻击替换文件 |
| URL 签名 | CDN URL 带时效签名,防止盗链 |
| MD5 校验 | 防止传输过程中数据被篡改 |
| TLS 1.3 | 比 TLS 1.2 更安全、更快 |
10.10.2 防篡改链路
服务端:
1. 文件上传时计算 MD5,存入数据库
2. 礼物列表 API 返回 fileUrl + fileMd5 + fileSize
3. API 响应本身通过 HTTPS + Token 认证保障
客户端:
1. API 请求带 Token → 确保获取的 MD5 是真实的
2. CDN 下载走 HTTPS → 传输不被篡改
3. 下载完校验 MD5 → 确保文件完整
4. 使用前校验文件头 → 确保文件格式正确
攻击者要成功篡改文件,需要同时:
✗ 突破 HTTPS → 替换 API 返回的 MD5
✗ 突破 HTTPS → 替换 CDN 传输的文件
✗ 或者攻破服务端 → 那已经是另一个层面的安全问题了
10.11 Flutter 特有的网络相关注意事项
10.11.1 Platform Channel 开销
- 如果使用原生插件做下载(如
flutter_downloader),每次进度回调都是一次 Platform Channel 调用 - Platform Channel 有序列化/反序列化开销,频率太高会卡 UI
- 建议:进度回调做节流(throttle),最多每 100ms 回调一次 UI
10.11.2 后台下载
Flutter App 切后台时的下载行为:
iOS:
├── 默认:App 切后台约 30s 后暂停所有网络请求
├── Background Fetch:最多 30s 执行时间
├── Background URLSession(NSURLSession):
│ 系统托管下载,App 被杀也能继续
│ 需要通过原生代码实现,Flutter 层做 Platform Channel 桥接
└── 如果不做后台下载,进度保存在 DB,前台恢复时断点续传
Android:
├── 前台服务(Foreground Service)+ 通知栏进度条
├── WorkManager:适合不紧急的预加载
└── 直接在 Service 中用 OkHttp/HttpURLConnection 下载
语音房场景的特殊性:
语音房通常有前台服务(音频播放),App 切后台不会立即被杀
可以继续下载,但建议降低并发数(让出资源给音频流)
10.11.3 网络状态监听
connectivity_plus 插件:
├── 获取当前网络类型:WiFi / Mobile / None
├── 监听网络变化:onConnectivityChanged
└── 局限:只知道有没有网,不知道网络质量
进一步探测:
├── WiFi 有信号但无法上网 → 需要实际请求才能发现
├── 检测方式:向已知 CDN 发一个 HEAD 请求,超时则认为无法上网
└── 不要用 ping(某些网络环境禁止 ICMP)
网络变化时的处理:
WiFi → 蜂窝:
1. 暂停下载
2. 弹窗询问用户(可配置是否自动切换)
3. 用户同意 → 重新探测网络质量 → 降低并发 → 继续
蜂窝 → WiFi:
1. 重新探测网络质量
2. 提升并发参数
3. 恢复被暂停的预加载任务
有网 → 断网:
1. 暂停所有下载
2. 保留进度
3. 监听网络恢复
断网 → 有网:
1. 等待 2s 稳定期
2. 探测网络质量
3. 断点续传流程
10.11.4 Dart 异步模型与下载的配合
Dart 是单线程事件循环模型:
Event Queue:
├── UI 事件
├── Timer 事件
├── IO 完成事件 ← 网络数据到达、文件写入完成
└── Microtask 事件
网络 IO 本身不阻塞事件循环(底层由操作系统异步处理)
但以下操作会阻塞:
├── 同步文件读写(readAsBytesSync) ← 避免使用
├── 大量数据处理(MD5、压缩) ← 放到 Isolate
├── JSON 序列化大对象 ← 放到 Isolate
└── 复杂的集合操作 ← 量大时注意
最佳实践:
├── 所有文件操作用异步版本(readAsBytes, writeAsBytes)
├── CPU 密集操作 → compute() / Isolate
├── 进度更新不要太频繁 → setState 做节流
└── Stream.listen 的回调中不要做重操作
10.12 网络优化效果量化
| 优化项 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
| DNS 预解析 | 首次请求 +100-200ms | 0ms | 省去 DNS 等待 |
| HTTPDNS | 可能被劫持到远端节点 | 解析到最近节点 | 延迟可降 50%+ |
| HTTP/2 复用 | 4 分片 = 4 次 TLS 握手 (800ms) | 1 次 TLS (200ms) | 省 600ms |
| 连接预热 | 首次下载 +200-500ms | 0ms | 省去握手时间 |
| 流式写入 | 2MB 分片峰值内存 2MB | 峰值 64KB | 内存降 97% |
| 自适应并发 | 固定 4 并发弱网超时 | 弱网 1 并发成功 | 弱网成功率提升 |
| 分片级续传 | 中断后从头下载 | 从中断点继续 | 省流量省时间 |
| Isolate MD5 | 10MB MD5 阻塞 UI 200ms | UI 无感知 | 消除卡顿 |
十一、关键设计决策汇总
| 决策点 | 选择 | 为什么 |
|---|---|---|
| 分片 vs 整文件 | 大文件(≥1MB)分片,小文件不分片 | 大文件分片提升并发利用率和容错性;小文件分片得不偿失 |
| 并发度动态 vs 固定 | 动态调整 | 网络波动大,固定值无法适应 |
| 分片大小固定 vs 动态 | 动态(256KB-4MB) | 兼顾弱网容错和强网效率 |
| 网络探测方式 | 搭便车实时采样为主 | 减少额外流量浪费 |
| 优先级策略 | 可抢占的优先级队列 | 用户触发的礼物必须最快展示 |
| 元数据持久化 | SQLite | 可靠、支持复杂查询、事务性保证分片状态一致 |
| 分片临时存储 | 独立临时文件 | 便于管理和清理,合并时流式读写不占内存 |
| 文件版本校验 | ETag + If-Range | HTTP 标准机制,CDN 天然支持 |
| 签名 URL 处理 | 续传前检查过期并刷新 | 防止长时间断点后 URL 过期 |
| 缓存淘汰 | LRU + 热度 + 24h 保护 | 平衡存储空间和用户体验 |
| 校验方式 | 四层校验(接口→分片→整文件→文件头) | 层层防御,从概率上杜绝文件损坏 |
| DNS 方案 | HTTPDNS + 预解析 | 避免 DNS 劫持,减少解析延迟 |
| HTTP 协议 | 优先 HTTP/2 | 单连接多路复用,省去重复握手 |
| 连接管理 | 独立 Dio 实例 + 连接预热 | 与业务 API 隔离,预热省去首次握手时间 |
| 响应处理 | ResponseType.stream 流式写入 | 内存从 O(分片大小) 降到 O(64KB) |
| CPU 密集操作 | compute() / Isolate | 避免 MD5 计算、文件合并阻塞 UI 线程 |
| 弱网策略 | 自适应降级 + 静态图兜底 | 极差网络下也能给用户反馈 |
| TLS 版本 | TLS 1.3 | 更安全 + 支持 0-RTT 恢复 |
附:关键交互时序
用户点击送礼
│
▼
[业务层] 检查 completed/ 目录 ── 有文件 → 直接播放 ✅
│ 无文件
▼
[业务层] 检查 DB 有无未完成任务 ── 有 → 走断点续传
│ 无
▼
[业务层] 调接口获取: fileUrl(带签名) + fileSize + fileMd5
│
▼
[调度引擎] 设置优先级=最高,入队
│
▼
[调度引擎] 分配连接数,抢占低优先级任务
│
▼
[分片层] HEAD 请求 → 获取 Content-Length + ETag + Accept-Ranges
│
▼
[分片层] 计算分片方案 → 写入 DB → 启动并行下载
│
├── chunk 0: GET + Range → 206 → 写入 temp 文件 → 更新 DB
├── chunk 1: GET + Range → 206 → 写入 temp 文件 → 更新 DB
├── chunk 2: GET + Range → 206 → 写入 temp 文件 → 更新 DB
└── ...
│ 全部完成
▼
[分片层] 流式合并分片 → 写入 completed/ 目录
│
▼
[分片层] MD5 校验 ── 通过 → 清理 temp → 通知业务层
│ 失败 → 清理所有 → 重新下载
▼
[业务层] 播放礼物动画 🎁