场景:语音房礼物资源下载,文件类型为 mp4(~10MB)和 webp(~1MB) 核心能力:网络自适应 · 多文件并行 · 单文件分片 · 断点续传 · 智能调度
scss 体验AI代码助手 代码解读复制代码┌──────────────────────────────────────────────────────────────┐
│ 礼物业务层 │
│ (礼物列表展示、播放渲染、用户触发) │
├──────────────────────────────────────────────────────────────┤
│ 下载调度引擎 │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ 网络探测器 │ │ 优先级队列 │ │ 并发度/分片策略控制 │ │
│ └────────────┘ └────────────┘ └──────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 分片下载层 │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ 分片管理器 │ │ 断点续传 │ │ 分片合并(Isolate)+校验│ │
│ └────────────┘ └────────────┘ └──────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 网络优化层(第十章) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ HTTPDNS │ │ HTTP/2 │ │ 连接预热 │ │ TLS Session │ │
│ │ + 预解析 │ │ 多路复用 │ │ TCP预连接 │ │ 复用 + 1.3 │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 弱网自适应│ │ Dio 专用 │ │ 流式传输 │ │ 自适应超时 │ │
│ │ + 降级 │ │ 实例+拦截 │ │ Stream │ │ + 速率检测 │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 传输层 │
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
│ │ HTTP Range 请求管理 │ │ CDN 签名 URL 管理 + 刷新 │ │
│ └──────────────────────┘ └─────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 存储层 │
│ ┌────────────┐ ┌─────────────┐ ┌────────────────────┐ │
│ │ 完成文件缓存 │ │ 元数据 SQLite │ │ 临时分片文件 │ │
│ └────────────┘ └─────────────┘ └────────────────────┘ │
└──────────────────────────────────────────────────────────────┘数据流:
markdown 体验AI代码助手 代码解读复制代码用户触发送礼 / 预加载触发
↓
检查本地缓存是否已有文件 ── 命中 → 直接使用
↓ 未命中
检查是否有未完成的分片 ── 有 → 断点续传流程
↓ 无
探测网络质量 → 决定并发参数
↓
进入优先级队列 → 调度引擎分配连接
↓
HEAD 请求获取文件信息(大小/ETag/是否支持Range)
↓
计算分片方案 → 多分片并行下载
↓
所有分片完成 → 合并 → 校验 MD5 → 存入缓存目录
↓
通知业务层 → 播放/渲染礼物指标 | 采集方式 | 作用 |
|---|---|---|
带宽估算 | 用一个小文件(~50KB 探测文件)计算实际下载速率 | 决定并发数和分片大小 |
RTT 延迟 | 每次 HTTP 请求的首字节时间(TTFB) | 延迟高时减少分片并发数(每个分片都有握手开销) |
网络类型 | Connectivity 插件获取 WiFi / 5G / 4G / 3G | 粗粒度初始策略 |
丢包率/抖动 | 连续多次小请求的成功率和耗时方差 | 判断网络稳定性 |
等级 | 判定条件(参考值) | 标签 |
|---|---|---|
优秀 | 带宽 > 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 / 弱网 |
时机 | 方式 | 说明 |
|---|---|---|
进入语音房前 | 主动探测 | 冷启动做一次完整探测 |
下载过程中 | 搭便车采样 | 取最近 5 个分片的平均速率做滑动窗口,实时修正参数 |
网络切换时 | 被动触发 | WiFi ↔ 蜂窝切换后立即重新探测 |
核心原则:不要频繁主动探测(浪费流量),主要依赖"搭便车"——从实际分片下载行为中采集真实速率。
css 体验AI代码助手 代码解读复制代码第一层:文件级并发 —— 同时下载几个文件
├── 文件 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-4 | 4-5 | 2MB | 16 |
良好 | 2-3 | 3-4 | 1MB | 10 |
一般 | 1-2 | 2-3 | 512KB | 6 |
差 | 1 | 1-2 | 256KB | 3 |
总连接数上限的意义:所有文件的分片并发数总和不超过此值。防止在弱网下开太多连接反而互相抢带宽。
分片过小(< 256KB) | 分片过大(> 4MB) |
|---|---|
HTTP 头部 + TCP 握手开销占比过高 | 单片失败时重试成本高 |
请求次数太多 | 弱网下容易超时 |
频繁的 DB 状态更新 | 断点续传粒度太粗 |
计算公式:
ini 体验AI代码助手 代码解读复制代码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 对齐ini 体验AI代码助手 代码解读复制代码W = α × 紧急度 + β × (1 / 文件大小) + γ × 热度 + δ × 已完成比例
α=0.5 β=0.15 γ=0.15 δ=0.2因子 | 含义 | 设计目的 |
|---|---|---|
紧急度 | 用户正在触发 = 1.0,预加载 = 0.2 | 用户触发的礼物必须最快展示 |
1/文件大小 | webp(1MB) 得分高于 mp4(10MB) | 小文件优先完成,用户更快看到效果 |
热度 | 房间内高频赠送的礼物得分高 | 高概率被用到的优先 |
已完成比例 | 已下载 90% 的文件得分高 | 避免所有文件都半成品,优先收尾 |
不是简单平分带宽,而是通过控制分片并发数间接分配:
文件类型 | 分配策略 | 实现方式 |
|---|---|---|
用户正在触发的礼物 | 60-70% 带宽 | 分配 4 个分片并发 |
预加载礼物 | 30-40% 带宽 | 限制 1-2 个分片并发 |
网络变差时 | 全部让给紧急文件 | 暂停所有预加载 |
断点续传和分片下载的基础是 HTTP Range 请求。主流 CDN 全部支持:
CDN 厂商 | 支持 Range | 默认开启 |
|---|---|---|
阿里云 CDN | 支持 | 是 |
腾讯云 CDN | 支持 | 是 |
AWS CloudFront | 支持 | 是 |
Cloudflare | 支持 | 是 |
七牛云 | 支持 | 是 |
验证方法:
bash 体验AI代码助手 代码解读复制代码# 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)必须满足的完整链路:
markdown 体验AI代码助手 代码解读复制代码Flutter 客户端 ──Range 请求──→ CDN 节点 ──→ 源站(OSS/S3/Nginx)
↑ ↑ ↑
你的代码 全部支持 这里也必须支持三个环节任意一个不支持 Range,分片下载就退化为整文件单连接下载。
ini 体验AI代码助手 代码解读复制代码┌─ 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 比对 │
│ 通过 → 删除临时分片,标记完成 │
│ 失败 → 清理所有文件,重新下载 │
└────────────────────────────────────────────────────────────────┘场景 | 文件大小 | 网络 | 分片大小 | 分片数 | 并发数 | 预估耗时 |
|---|---|---|---|---|---|---|
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 |
每个分片在 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 | 最后更新时间戳 |
ini 体验AI代码助手 代码解读复制代码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,重新下载 │
└──────────────────────────────────────────────────────────┘不只是分片之间可以续传,每个分片内部也支持续传:
downloadedBytes 实时更新(每接收 64KB 数据更新一次 DB,不要太频繁影响性能)rangeStart + downloadedBytesmarkdown 体验AI代码助手 代码解读复制代码时间线:
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 的路径和文件必须相同,只是签名参数不同markdown 体验AI代码助手 代码解读复制代码风险场景:
T0: 下载 gift_001.mp4 前 5MB
T1: 运营更换了 gift_001.mp4 的内容(同 URL 不同内容)
T2: 续传后面的 5MB → 前后内容不匹配 → 文件损坏
防御:
1. 首次 HEAD 请求时记录 ETag
2. 续传前 HEAD 请求比对 ETag
3. ETag 变了 → 废弃所有已下载分片 → 完全重新下载
4. 最终的 MD5 校验作为最后防线bash 体验AI代码助手 代码解读复制代码极少出现但需要防御:
如果 CDN 对文件启用了 gzip 压缩(响应头 Content-Encoding: gzip)
→ 压缩后的字节流无法按 Range 精确切分
→ 分片下载的数据拼接后解压失败
检测:
HEAD 请求时检查 Content-Encoding
如果是 gzip/br → 退化为单连接整文件下载
实际情况:
CDN 默认不压缩 mp4/webp 等已压缩格式,只压缩 HTML/CSS/JS
所以几乎不会遇到策略 | 细节 |
|---|---|
最大重试次数 | 单分片 3 次 |
退避策略 | 指数退避 + 随机抖动:1s ± 0.3s → 2s ± 0.6s → 4s ± 1.2s |
连接超时 | 10 秒 |
读超时 | 动态计算:分片大小 / 最低预期速率 × 2(最少 15 秒) |
局部失败 | 单分片失败不影响其他分片继续下载 |
场景 | 处理 |
|---|---|
单分片重试 3 次仍失败 | 标记该分片 failed,继续下载其他分片 |
超过 50% 的分片失败 | 暂停该文件,重新探测网络,调整策略后整体重试 |
所有分片重试耗尽仍失败 | 标记文件为 failed,上报监控,移出队列 |
用户再次触发该礼物 | 重新进入队列,清理旧的失败记录,从头开始 |
markdown 体验AI代码助手 代码解读复制代码网络状态监听(Connectivity 插件)
网络断开:
1. 暂停所有正在进行的 HTTP 请求
2. 保留所有分片进度(已持久化在 DB 中)
3. UI 层可展示"网络已断开,将在恢复后继续下载"
网络恢复:
1. 等待 2 秒稳定期(避免网络抖动导致频繁重启)
2. 重新探测网络质量 → 可能要调整并发参数
3. 按优先级恢复下载队列
4. 每个文件走断点续传流程(检查 URL、ETag)
网络切换(WiFi → 蜂窝):
1. 弹窗提示"当前使用移动数据,是否继续下载?"(可配置)
2. 用户同意 → 降低并发参数,继续下载
3. 用户拒绝 → 暂停所有下载,等 WiFi 恢复异常 | 处理 |
|---|---|
磁盘空间不足 | 下载前检查剩余空间 ≥ 文件大小 × 1.5(分片 + 合并需要额外空间),不足则清理缓存或提示用户 |
下载中 App 被杀 | 下次启动时自动从 DB 恢复未完成的任务 |
服务端 5xx 错误 | 按重试策略处理,3 次后标记失败 |
服务端 403/404 | 不重试,直接标记失败,上报异常 |
MD5 校验失败 | 删除所有分片和合并文件,重新下载 |
less 体验AI代码助手 代码解读复制代码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
└── ...维度 | 策略 |
|---|---|
总缓存上限 | 200MB(可通过服务端配置下发) |
淘汰算法 | LRU + 热度权重 |
保护机制 | 最近 24 小时内使用过的文件不淘汰 |
清理时机 | 每次新文件下载完成后检查总大小;App 启动时检查 |
临时文件清理 | 超过 24 小时未更新的分片临时文件自动清理 |
淘汰顺序 | 最久未使用 → 文件最大 → 热度最低 |
css 体验AI代码助手 代码解读复制代码第 1 层(下载前):服务端接口返回文件的 MD5 和大小
↓
第 2 层(下载中):每个分片验证 Content-Length 匹配
↓
第 3 层(下载后):合并后整文件 MD5 校验
↓
第 4 层(使用前):播放/渲染前快速校验文件头魔数
mp4 → 检查 ftyp box
webp → 检查 RIFF 头 + WEBP 标识时机 | 行为 | 优先级 |
|---|---|---|
进入语音房 | 拉取房间礼物列表 → 按热度排序 → 预加载 Top N | 中 |
房间空闲期 | WiFi + 前台 + 无用户操作 → 后台预加载更多 | 低 |
礼物列表更新 | 服务端推送新礼物 → 差量预加载新增的 | 中 |
蜂窝网络 | 降低或完全不预加载(节省流量) | 跳过 |
策略 | 依据 |
|---|---|
用户偏好 | 用户历史送礼记录 → 优先预加载常送的礼物类型 |
房间场景 | PK 房 → 预加载 PK 礼物;生日房 → 预加载生日礼物 |
文件类型 | webp 优先于 mp4(体积小,完成快) |
css 体验AI代码助手 代码解读复制代码场景:文件 A 正在预加载(低优先级,1 个分片并发)
↓
用户触发了礼物 A
↓
处理:
1. 不中断、不重新下载
2. 直接提升文件 A 的优先级为最高
3. 增加其分片并发数(从 1 → 4)
4. 抢占其他预加载文件的连接数
5. 已完成的分片保留,只加速未完成的部分指标 | 计算方式 | 告警阈值 |
|---|---|---|
文件下载成功率 | 成功数 / 总请求数 | < 95% |
分片失败率 | 失败分片数 / 总分片数 | > 5% |
平均下载耗时 | 按网络等级分桶统计 | P99 > 30s |
首帧展示时间 | 用户触发 → 礼物开始播放 | P95 > 5s |
缓存命中率 | 命中次数 / 总请求次数 | < 70% |
断点续传成功率 | 续传成功 / 续传尝试 | < 90% |
MD5 校验失败率 | 校验失败 / 下载完成数 | > 0.1% |
字段 | 说明 |
|---|---|
giftId | 礼物 ID |
fileType | mp4 / webp |
fileSize | 文件大小 |
networkLevel | 网络等级 |
networkType | WiFi / 4G / 5G |
chunkCount | 分片数 |
concurrency | 并发数 |
totalTime | 总耗时 |
retryCount | 总重试次数 |
isResumed | 是否断点续传 |
result | success / fail / cancelled |
failReason | 失败原因 |
本章将 Flutter 网络优化的知识体系融入礼物下载场景,覆盖从 DNS 解析到字节写入磁盘的全链路。
一个分片下载请求从发出到数据落盘,经历的完整链路:
scss 体验AI代码助手 代码解读复制代码┌──────────────────────────────────────────────────────────────────────┐
│ 一次分片下载的耗时拆解 │
├──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┤
│ 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。分片越小,这种"固定税"的占比越高,这也是分片不能太小的根本原因。
维度 | 传统 LocalDNS | HTTPDNS |
|---|---|---|
解析方式 | UDP 递归查询 | HTTP 直接向 DNS 服务商请求 |
劫持风险 | 高(运营商劫持) | 低(HTTPS 加密) |
解析精度 | 运营商粒度 | 可精确到客户端 IP |
缓存控制 | 运营商控制 TTL | 客户端可控 |
Flutter 方案 | 系统默认 | 阿里云/腾讯云 HTTPDNS SDK |
在礼物下载中的应用:
javascript 体验AI代码助手 代码解读复制代码时机: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 分钟
├── 解析失败时使用上次缓存结果(兜底)
└── 网络切换时清空缓存重新解析yaml 体验AI代码助手 代码解读复制代码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 支持 | 全部 | 主流全部支持 |
在礼物下载中的收益:
Dio 开启 HTTP/2:使用 dio_http2_adapter 替换默认适配器,或使用 cronet_http(基于 Chromium 网络栈)。
即使使用 HTTP/1.1,也要合理管理连接池:
参数 | 建议值 | 说明 |
|---|---|---|
maxConnectionsPerHost | 6-8 | 同一域名最大连接数(HTTP/1.1 场景) |
idleTimeout | 15 秒 | 空闲连接保持时间 |
connectionTimeout | 10 秒 | 建立连接超时 |
关键点:
new Dio(),否则连接池无法复用arduino 体验AI代码助手 代码解读复制代码首次 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)dart:io HttpClient 默认支持 TLS Session 缓存SecurityContext 设置可信证书为礼物下载创建独立的 Dio 实例,与业务 API 请求隔离:
javascript 体验AI代码助手 代码解读复制代码全局 Dio 实例规划:
├── apiDio ← 业务接口(JSON 短连接,超时短)
├── downloadDio ← 礼物下载(大文件长连接,超时长,不同拦截器)
└── uploadDio ← 上传场景(如果有)为什么隔离?
阶段 | API 请求 | 分片下载 |
|---|---|---|
connectTimeout | 10s | 10s |
sendTimeout | 10s | 不限 |
receiveTimeout | 15s | 动态计算 |
分片下载的 receiveTimeout 计算:
ini 体验AI代码助手 代码解读复制代码receiveTimeout = max(分片大小 / 最低可接受速率, 15秒)
示例:
2MB 分片 / 100KB/s 最低速率 = 20s → receiveTimeout = 20s
256KB 分片 / 100KB/s = 2.5s → receiveTimeout = 15s(取最小值)rust 体验AI代码助手 代码解读复制代码downloadDio 拦截器链:
├── LogInterceptor ← 仅 Debug 模式开启,记录请求/响应头
├── RetryInterceptor ← 自动重试(指数退避)
├── NetworkQualityInterceptor ← 采集 TTFB、传输速率,更新网络质量模型
├── SignUrlInterceptor ← 请求前检查 URL 签名是否过期,过期则刷新
└── ProgressInterceptor ← 采集下载进度,更新 DBNetworkQualityInterceptor 的细节:
响应时间 - 请求时间 = TTFB已接收字节 / 耗时 = 实时速率分片下载必须使用 ResponseType.stream,而非 ResponseType.bytes:
ResponseType | 行为 | 内存占用 |
|---|---|---|
bytes | 等全部数据接收完再返回 Uint8List | 整个分片大小(2MB → 内存峰值 2MB) |
stream | 返回 ResponseBody.stream,数据流式到达 | 缓冲区大小(~64KB) |
stream 模式的好处:
体验AI代码助手 代码解读复制代码Flutter 主 Isolate(UI 线程):
├── Widget 构建和渲染 ← 不能被阻塞,否则掉帧
├── 动画更新(60/120fps) ← 16ms/8ms 内必须完成
├── 事件处理
└── 异步任务调度
如果在主 Isolate 做这些事:
├── MD5 计算(10MB 文件 → ~100-200ms 阻塞) ← 会掉帧!
├── 分片合并(多次文件读写) ← 会掉帧!
├── SQLite 大量写入 ← 可能卡顿
└── gzip 解压缩 ← 会掉帧!操作 | 耗时 | 是否需要 Isolate |
|---|---|---|
网络请求本身 | 异步 IO,不阻塞 | 不需要(Dart 异步即可) |
流式写入磁盘 | 异步 IO | 不需要 |
MD5 计算 | CPU 密集,10MB ~200ms | 需要 |
分片合并 | IO 密集,可能 100ms+ | 需要(大文件时) |
文件头校验 | 读几个字节,<1ms | 不需要 |
SQLite 写入 | 通常 <5ms | 不需要(sqflite 已在后台线程) |
数据压缩/解压 | CPU 密集 | 需要 |
scss 体验AI代码助手 代码解读复制代码方案一:compute() —— 简单一次性任务
适合:MD5 计算、文件合并
特点:每次创建新 Isolate,有启动开销(~50-100ms)
方案二:长驻 Isolate + SendPort/ReceivePort
适合:需要频繁调用的场景
特点:Isolate 常驻,通过消息传递任务,避免重复创建
方案三:IsolatePool(自定义线程池)
适合:大量分片并行下载时的 CPU 密集操作
特点:预创建 N 个 Isolate,任务队列分发
本方案推荐:
├── MD5 计算 → compute()(一次性任务,不频繁)
├── 分片合并 → compute()(同上)
└── 如果同时下载 10+ 文件都在做 MD5 → IsolatePoolTransferableTypedData:零拷贝传递 TypedData(Dart 2.15+),传递后原 Isolate 不再持有减少不必要的请求头,每个字节在弱网下都很珍贵:
sql 体验AI代码助手 代码解读复制代码精简后的分片下载请求头:
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 校验)对于 mp4/webp 这种已压缩的文件格式,必须告诉服务端不要做额外压缩:
Accept-Encoding: identityContent-Encoding: gzip,Range 请求会失效scss 体验AI代码助手 代码解读复制代码传统方式(内存不友好):
网络数据 → 全部加载到内存(Uint8List) → 一次性写入磁盘
峰值内存:= 分片大小
流式处理(推荐):
网络数据 → 64KB 缓冲区 → 立即写入磁盘 → 缓冲区复用
峰值内存:≈ 64KB
实现关键:
Dio 设置 ResponseType.stream
→ 获取 ResponseBody.stream(Stream<Uint8List>)
→ stream.listen() 逐块接收
→ 每块立即 file.writeAsBytes(chunk, mode: FileMode.append)
→ 同时更新下载进度dart:io HttpClient 默认缓冲区大小通常够用arduino 体验AI代码助手 代码解读复制代码弱网不只是"慢",还包括:
├── 高延迟:RTT > 300ms,握手时间长
├── 高丢包:TCP 频繁重传,有效吞吐量远低于带宽
├── 抖动大:速率忽快忽慢,超时阈值难以设定
├── 连接不稳定:TCP 连接频繁断开
└── DNS 解析慢:可能 > 1s优化项 | 措施 | 原理 |
|---|---|---|
减少连接数 | 文件并发 1,分片并发 1-2 | 连接少 → 每个连接分到的带宽多 → 减少超时 |
缩小分片 | 256KB | 单片失败成本低,重试快 |
增加超时 | connectTimeout 15s,receiveTimeout 动态上调 | 弱网下握手和传输都慢 |
优先完成小文件 | webp 优先于 mp4 | 让用户尽快看到部分礼物效果 |
降级策略 | 显示静态图替代动画 | 网络极差时不下载 mp4,用 webp 占位 |
预热连接 | 提前建立 TCP 连接(不发数据) | 下载时省去握手时间 |
HTTPDNS | 跳过系统 DNS | 弱网下 DNS 解析可能特别慢 |
固定超时在弱网下不合理:
ini 体验AI代码助手 代码解读复制代码自适应超时计算:
baseTimeout = 分片大小 / 当前估算速率 × 2 (2 倍余量)
minTimeout = 15 秒
maxTimeout = 120 秒
timeout = clamp(baseTimeout, minTimeout, maxTimeout)
动态调整:
如果连续 2 个分片都接近超时 → 下一个分片超时再延长 50%
如果连续 3 个分片都很快完成 → 可以适当缩短超时bash 体验AI代码助手 代码解读复制代码下载过程中持续监控速率:
速率 > 2MB/s → 维持当前策略
速率 1-2MB/s → 正常
速率 500KB-1MB → 降低并发数
速率 < 500KB → 降到最低配置(1文件×1分片×256KB)
速率 < 50KB → 暂停下载,提示用户网络极差
对于用户触发的礼物 → 显示静态占位图
速率回升时自动恢复(但不立即恢复到最高配置,渐进式提升):
50KB → 200KB → 恢复到"差"配置
200KB → 500KB → 恢复到"一般"配置
有 2 秒滞后期,避免速率抖动导致频繁切换markdown 体验AI代码助手 代码解读复制代码进入语音房时的预热流程:
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-500msHTTP/2 下只需要预热一条连接,后续所有分片都复用这条连接:
体验AI代码助手 代码解读复制代码预热时机:
├── 进入语音房时(最佳)
├── 礼物列表 API 返回后(如果礼物 CDN 域名和 API 域名不同)
└── 首个预加载任务启动时
预热方式:
向 CDN 发一个极小的 HEAD 请求(获取某个文件信息)
目的不是获取数据,而是建立 TCP + TLS 连接
后续所有分片请求都能立即使用这条连接ini 体验AI代码助手 代码解读复制代码内存消耗点:
├── 网络接收缓冲区:并发数 × ~64KB = 256KB(4并发)
├── 文件写入缓冲区:并发数 × ~64KB = 256KB
├── Dio Response 对象:并发数 × ~1KB
├── SQLite 缓存:< 100KB
├── 分片元数据:每文件 < 10KB
└── 总计:< 1MB(流式处理下)
如果不用流式处理(ResponseType.bytes):
├── 4 个 2MB 分片 → 8MB 内存峰值
├── 加上 Dart GC 的内存碎片 → 可能触发 10MB+ 的内存波动
└── 语音房本身已有音频缓冲区和 UI 渲染开销,这很危险scss 体验AI代码助手 代码解读复制代码错误做法:
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(读写缓冲区)措施 | 说明 |
|---|---|
HTTPS 强制 | 所有请求必须 HTTPS,拒绝 HTTP 降级 |
证书锁定 | 防止中间人攻击替换文件 |
URL 签名 | CDN URL 带时效签名,防止盗链 |
MD5 校验 | 防止传输过程中数据被篡改 |
TLS 1.3 | 比 TLS 1.2 更安全、更快 |
markdown 体验AI代码助手 代码解读复制代码服务端:
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 传输的文件
✗ 或者攻破服务端 → 那已经是另一个层面的安全问题了flutter_downloader),每次进度回调都是一次 Platform Channel 调用css 体验AI代码助手 代码解读复制代码Flutter App 切后台时的下载行为:
iOS:
├── 默认:App 切后台约 30s 后暂停所有网络请求
├── Background Fetch:最多 30s 执行时间
├── Background URLSession(NSURLSession):
│ 系统托管下载,App 被杀也能继续
│ 需要通过原生代码实现,Flutter 层做 Platform Channel 桥接
└── 如果不做后台下载,进度保存在 DB,前台恢复时断点续传
Android:
├── 前台服务(Foreground Service)+ 通知栏进度条
├── WorkManager:适合不紧急的预加载
└── 直接在 Service 中用 OkHttp/HttpURLConnection 下载
语音房场景的特殊性:
语音房通常有前台服务(音频播放),App 切后台不会立即被杀
可以继续下载,但建议降低并发数(让出资源给音频流)arduino 体验AI代码助手 代码解读复制代码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. 断点续传流程arduino 体验AI代码助手 代码解读复制代码Dart 是单线程事件循环模型:
Event Queue:
├── UI 事件
├── Timer 事件
├── IO 完成事件 ← 网络数据到达、文件写入完成
└── Microtask 事件
网络 IO 本身不阻塞事件循环(底层由操作系统异步处理)
但以下操作会阻塞:
├── 同步文件读写(readAsBytesSync) ← 避免使用
├── 大量数据处理(MD5、压缩) ← 放到 Isolate
├── JSON 序列化大对象 ← 放到 Isolate
└── 复杂的集合操作 ← 量大时注意
最佳实践:
├── 所有文件操作用异步版本(readAsBytes, writeAsBytes)
├── CPU 密集操作 → compute() / Isolate
├── 进度更新不要太频繁 → setState 做节流
└── Stream.listen 的回调中不要做重操作优化项 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
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 恢复 |
ini 体验AI代码助手 代码解读复制代码用户点击送礼
│
▼
[业务层] 检查 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 → 通知业务层
│ 失败 → 清理所有 → 重新下载
▼
[业务层] 播放礼物动画 🎁原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。