动态资源包复盘:语音房互动表情的下载、MD5 校验与内容寻址缓存
项目 social-app
1. 背景与问题
这是一款面向日本市场的语音社交 App。语音房里,坐在麦位上的用户可以给别人发互动表情:一段全屏或局部的动画,比如扔出一个道具、放一束烟花。这些动画有两种形态:
- 单段动画:一个 MP4(带透明通道的视频动画协议);
- 组合动画:一份配置 JSON 加多段 MP4,按配置分段播放。
它们的特点是体积大、上新频繁、由运营在后台配置。打进安装包不现实:包体会膨胀,而且每次上新都得发版。所以需要一套机制:后台配置新资源,客户端按需下载、校验、缓存,之后直接复用。
麦位互动表情的资源管理器:下载、校验、解压、缓存,以及后来的下载日志。
2. 整体设计
2.1 触发时机:播放时按需下载
表情面板只请求分组和列表,不预先下载压缩包;第一次在房间里收到或播放某个表情时才下载。好处是不浪费流量,热门表情很快就会在大多数设备上缓存命中;代价是某个表情第一次出现时要等下载。
2.2 内容寻址:用 MD5 做缓存键
后台为每个资源配置下载地址和内容 MD5。客户端以 MD5 作为缓存键:
用内容 MD5 做键,一次解决三件事:
- 地址无关:下载地址换了 CDN 域名或加了参数,只要内容没变就能命中缓存;
- 内容变了一定重下:运营替换了同一个表情的动画,MD5 随之变化,自然走下载;
- 完整性校验:下载结果和配置里的 MD5 对不上,说明传输损坏或被篡改,直接丢弃。
3. 关键实现
3.1 下载与校验
下载使用独立的下载会话:允许蜂窝网络,在串行队列里执行,缓存目录也与其它下载隔离。下载按顺序执行,独立的会话和目录也让它不会和其它下载任务互相影响。
// 示意,非原始代码
func resource(md5: String, kind: Kind, done: @escaping (Resource?) -> Void) {
if let r = parse(dir: cacheDir(md5), kind: kind) { return done(r) }
downloader.download(zipURL(md5)) { file in // 串行队列,允许蜂窝
guard let file, fileMD5(file) == md5 else {
log.warning("resource verify failed") // 校验失败不缓存
return done(nil)
}
unzip(file, to: cacheDir(md5))
done(parse(dir: cacheDir(md5), kind: kind))
}
}
func parse(dir: URL, kind: Kind) -> Resource? {
switch kind {
case .single: return exists(dir / "约定的文件名.mp4") ? .single(dir) : nil
case .composed: return readConfig(dir / "配置.json").map { .composed($0, dir) }
}
}
3.2 两种形态的解析
| 形态 | 目录内容 | 命中条件 |
|---|---|---|
| 单段动画 | 一个约定文件名的 MP4 | 该文件存在 |
| 组合动画 | 配置 JSON + 多段 MP4 | 配置能读出且能解析 |
组合动画按配置里的分段依次播放;播放前会检查配置里的格式版本,当前客户端解析不了的格式就不播放,这是给旧客户端留的兼容兜底。
3.3 和更早的素材下载方案对比
App 里更早还有一套「打招呼素材」的下载逻辑。它用下载地址的 MD5 做目录名,在偏好设置里记下最近一次的地址,地址变了才重新下载,没有对文件内容做校验。
两者的差别:按地址缓存时,「地址变了但内容没变」会白白重下,「地址没变但内容变了」会一直用旧文件,下载损坏也发现不了;按内容寻址则把这三种情况都处理对了。打招呼素材只改了下载时机,并把下载挪到了子线程。
3.4 线程与并发
- 下载在串行队列:下载任务按顺序执行,降低多个任务同时写缓存目录的概率。
- 相同资源的并发请求:房间里短时间内多人发同一个表情时,可能有多个请求同时查缓存未命中。串行下载只能让它们排队,第二个请求仍可能把同一个包再下载一次。更省的做法是按 MD5 维护一张「在途任务表」:同一个 MD5 在途时只追加回调,下载完成后一起分发,也就是对单个资源做 in-flight 合并。
// 示意:按 MD5 合并在途下载
var inflight: [String: [(Resource?) -> Void]] = [:] // 只在串行队列里读写
func fetch(md5: String, kind: Kind, done: @escaping (Resource?) -> Void) {
queue.async {
if inflight[md5] != nil { inflight[md5]!.append(done); return }
inflight[md5] = [done]
resource(md5: md5, kind: kind) { r in
queue.async {
let cbs = inflight.removeValue(forKey: md5) ?? []
DispatchQueue.main.async { cbs.forEach { $0(r) } }
}
}
}
}
4. 边界与失败处理
4.1 「点了没动画」
下载失败或校验失败时,资源管理器回调空结果,播放侧直接返回。用户侧的表现是「对方发了表情,这边没有动画」。这类问题可以这样排查:
- 怎么复现:清掉缓存目录,在弱网或用代理工具截断下载的情况下,让另一台设备发同一个表情。
- 怎么定位:看是「下载失败」「MD5 不一致」「解压后解析失败」还是「格式版本不支持」中的哪一种。这也是给下载链路补分阶段日志的价值所在:麦位表情的下载链路按阶段记日志,每一步的结果都能对上。
- 怎么修:按阶段决定策略,网络失败可重试一次,MD5 不一致直接丢弃并上报。
4.2 解压不是原子操作
当前实现在校验通过后直接解压到最终的 MD5 目录。如果解压到一半 App 被杀,会留下一个半成品目录。下次查缓存时,如果半成品里碰巧有约定的 MP4,或者配置 JSON 已经写出来,就会被当成命中,用到不完整的资源。
改进方法如上:先解压到临时目录,确认完整后再一次性改名。同一卷内的重命名是原子的,查缓存的一方永远只能看到「没有」或「完整」两种状态。
4.3 其它边界
- 缓存清理:目前没有按最近使用淘汰,也没有总大小上限,长期使用后缓存只增不减。
- 格式版本不支持时静默不播:对用户没有任何提示,可以考虑显示一个静态占位,或提示升级。
5. 演进
- 早期:打招呼素材按地址缓存,没有内容校验。
- 新建资源管理器,支持麦位互动表情的下载,以及带透明通道的视频动画。
- 表情面板的分组、列表和标签接入。
- 补上麦位表情下载日志。
从按地址缓存到按内容寻址,本质是把「缓存是否有效」的判断,从「上次用的是哪个地址」换成了「内容本身是什么」。
5.1 两种缓存放在一起
按地址和按内容,差在「变了的是地址还是文件」。
下载、校验、解压是一条状态,半截目录不能当成成功。
单段和组合只在解析时分叉,下载路径相同。
播放才下载,面板只拉列表。
6. 复盘与可改进之处
做对了的地方
- 内容寻址让缓存的正确性不依赖地址,换 CDN、加参数、替换动画都能处理对。
- 校验和缓存是同一个键,下载完成即完成校验,没有额外的一致性维护成本。
- 按需下载,运营在后台配好新表情,客户端无需发版就能用上。
还可以做得更好
- 原子解压:临时目录 + 重命名,杜绝半成品命中。
- 缓存淘汰:记录每个目录的最近使用时间,按 LRU 或总大小上限清理。
- 热门资源预热:在 Wi-Fi 下预下载面板第一页的表情,减少第一次播放的等待。
- 断点续传:大资源按字节范围(
Range请求头)续传,弱网下不必每次从头下载。 - 校验算法:MD5 用于完整性校验足够,若要防篡改可以换成 SHA-256 并配合签名。
- 失败可见:失败时展示占位并区分原因上报,让「点了没动画」变成可统计、可定位的问题。