# Flutter 语音房礼物下载方案(完整版)

23 阅读34分钟

Flutter 语音房礼物下载方案(完整版)

场景:语音房礼物资源下载,文件类型为 mp4(~10MB)和 webp(~1MB)
核心能力:网络自适应 · 多文件并行 · 单文件分片 · 断点续传 · 智能调度


目录


一、整体架构

┌──────────────────────────────────────────────────────────────┐
│                        礼物业务层                              │
│              (礼物列表展示、播放渲染、用户触发)                    │
├──────────────────────────────────────────────────────────────┤
│                       下载调度引擎                              │
│  ┌────────────┐  ┌────────────┐  ┌──────────────────────┐   │
│  │  网络探测器  │  │  优先级队列  │  │  并发度/分片策略控制   │   │
│  └────────────┘  └────────────┘  └──────────────────────┘   │
├──────────────────────────────────────────────────────────────┤
│                       分片下载层                                │
│  ┌────────────┐  ┌────────────┐  ┌──────────────────────┐   │
│  │  分片管理器  │  │  断点续传    │  │ 分片合并(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 < 50msWiFi / 5G 稳定
良好带宽 2-5MB/s,RTT 50-150msWiFi / 4G 正常
一般带宽 500KB-2MB/s,RTT 150-300ms4G 弱信号
带宽 < 500KB/s,RTT > 300ms3G / 弱网

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-44-52MB16
良好2-33-41MB10
一般1-22-3512KB6
11-2256KB3

总连接数上限的意义:所有文件的分片并发数总和不超过此值。防止在弱网下开太多连接反而互相抢带宽。

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 + 优秀网络10MB5MB/s2MB54~2.5s
mp4 + 一般网络10MB1MB/s512KB202~10s
mp4 + 差网络10MB200KB/s256KB401~50s
webp + 优秀网络1MB5MB/s不分片11~0.2s
webp + 差网络1MB200KB/s512KB21~5s

4.4 分片状态数据模型

每个分片在 SQLite 中持久化一行记录:

字段类型说明
fileIdString礼物文件唯一标识
fileUrlString下载地址(不含签名参数)
fileSizeint文件总大小(字节)
fileETagString文件版本标识(ETag)
fileMd5String文件 MD5(用于最终校验)
chunkIndexint分片序号
rangeStartint分片起始字节
rangeEndint分片结束字节
downloadedBytesint该分片已下载字节数
statusenumpending / downloading / done / failed
retryCountint已重试次数
tempFilePathString分片临时文件路径
createdAtint创建时间戳
updatedAtint最后更新时间戳

五、断点续传

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.dbSQLite 数据库
    │   ├── 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. 增加其分片并发数(从 144. 抢占其他预加载文件的连接数
  5. 已完成的分片保留,只加速未完成的部分

九、监控与埋点

9.1 核心指标

指标计算方式告警阈值
文件下载成功率成功数 / 总请求数< 95%
分片失败率失败分片数 / 总分片数> 5%
平均下载耗时按网络等级分桶统计P99 > 30s
首帧展示时间用户触发 → 礼物开始播放P95 > 5s
缓存命中率命中次数 / 总请求次数< 70%
断点续传成功率续传成功 / 续传尝试< 90%
MD5 校验失败率校验失败 / 下载完成数> 0.1%

9.2 每次下载的埋点数据

字段说明
giftId礼物 ID
fileTypemp4 / webp
fileSize文件大小
networkLevel网络等级
networkTypeWiFi / 4G / 5G
chunkCount分片数
concurrency并发数
totalTime总耗时
retryCount总重试次数
isResumed是否断点续传
resultsuccess / fail / cancelled
failReason失败原因

十、Flutter 网络优化深度

本章将 Flutter 网络优化的知识体系融入礼物下载场景,覆盖从 DNS 解析到字节写入磁盘的全链路。

10.1 网络请求全链路耗时分析

一个分片下载请求从发出到数据落盘,经历的完整链路:

┌──────────────────────────────────────────────────────────────────────┐
│                        一次分片下载的耗时拆解                          │
├──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┤
│ DNS 解析  │ TCP 握手  │ TLS 握手  │ 请求发送  │ 首字节等待 │ 数据传输     │
│ (TTDNS)  │ (TCP RTT) │ (TLS RTT) │          │ (TTFB)   │ (Transfer)  │
│ 50-200ms1 RTT    │ 1-2 RTT   │  <1ms10-50ms  │ 与大小成正比  │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘

优化目标:尽量消除或缩短前面几个阶段,让时间集中在有效的数据传输上

关键认识:对于一个 2MB 的分片,在良好网络下传输本身只需 ~0.4s,但 DNS + TCP + TLS 握手可能就要 200-500ms。分片越小,这种"固定税"的占比越高,这也是分片不能太小的根本原因。


10.2 DNS 优化

10.2.1 问题
  • 系统 DNS 解析依赖运营商 LocalDNS,可能被劫持、污染、解析慢
  • 每次冷启动后首次请求都要等 DNS 解析
  • 不同运营商解析到不同 CDN 节点,可能不是最优节点
10.2.2 HTTPDNS
维度传统 LocalDNSHTTPDNS
解析方式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.1HTTP/2
连接数每个分片一个连接(或连接池复用)单连接多路复用
头部开销每次完整发送HPACK 压缩,增量发送
握手次数N 次 TCP+TLS1 次
队头阻塞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,也要合理管理连接池:

参数建议值说明
maxConnectionsPerHost6-8同一域名最大连接数(HTTP/1.1 场景)
idleTimeout15 秒空闲连接保持时间
connectionTimeout10 秒建立连接超时

关键点

  • 所有下载请求共享同一个 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.3Client → 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:io HttpClient 默认支持 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 请求分片下载
connectTimeout10s10s
sendTimeout10s不限
receiveTimeout15s动态计算

分片下载的 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-LanguageUser-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:io HttpClient 默认缓冲区大小通常够用
  • 但要注意:如果分片并发数太多,每个 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 = 分片大小 / 当前估算速率 × 22 倍余量)
minTimeout = 15maxTimeout = 120timeout = 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. 用户同意 → 重新探测网络质量 → 降低并发 → 继续
    
  蜂窝 → WiFi1. 重新探测网络质量
    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-200ms0ms省去 DNS 等待
HTTPDNS可能被劫持到远端节点解析到最近节点延迟可降 50%+
HTTP/2 复用4 分片 = 4 次 TLS 握手 (800ms)1 次 TLS (200ms)省 600ms
连接预热首次下载 +200-500ms0ms省去握手时间
流式写入2MB 分片峰值内存 2MB峰值 64KB内存降 97%
自适应并发固定 4 并发弱网超时弱网 1 并发成功弱网成功率提升
分片级续传中断后从头下载从中断点继续省流量省时间
Isolate MD510MB MD5 阻塞 UI 200msUI 无感知消除卡顿

十一、关键设计决策汇总

决策点选择为什么
分片 vs 整文件大文件(≥1MB)分片,小文件不分片大文件分片提升并发利用率和容错性;小文件分片得不偿失
并发度动态 vs 固定动态调整网络波动大,固定值无法适应
分片大小固定 vs 动态动态(256KB-4MB)兼顾弱网容错和强网效率
网络探测方式搭便车实时采样为主减少额外流量浪费
优先级策略可抢占的优先级队列用户触发的礼物必须最快展示
元数据持久化SQLite可靠、支持复杂查询、事务性保证分片状态一致
分片临时存储独立临时文件便于管理和清理,合并时流式读写不占内存
文件版本校验ETag + If-RangeHTTP 标准机制,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 → 通知业务层
     │                失败 → 清理所有 → 重新下载
     ▼
[业务层] 播放礼物动画 🎁