动态资源包复盘:语音房互动表情的下载、MD5 校验与内容寻址缓存

项目 social-app

  • 缓存
  • 资源下发
  • MD5
  • 文件系统
  • 语音房

1. 背景与问题

这是一款面向日本市场的语音社交 App。语音房里,坐在麦位上的用户可以给别人发互动表情:一段全屏或局部的动画,比如扔出一个道具、放一束烟花。这些动画有两种形态:

  • 单段动画:一个 MP4(带透明通道的视频动画协议);
  • 组合动画:一份配置 JSON 加多段 MP4,按配置分段播放。

它们的特点是体积大、上新频繁、由运营在后台配置。打进安装包不现实:包体会膨胀,而且每次上新都得发版。所以需要一套机制:后台配置新资源,客户端按需下载、校验、缓存,之后直接复用。

麦位互动表情的资源管理器:下载、校验、解压、缓存,以及后来的下载日志。


2. 整体设计

2.1 触发时机:播放时按需下载

资源下载服务资源管理器语音房表情面板资源下载服务资源管理器语音房表情面板收到某人发的互动表情消息alt[命中][未命中]只拉分组和列表 不预下载资源包取这个表情的资源 带 MD5 和类型按 MD5 查本地目录返回本地文件下载资源压缩包压缩包校验 MD5 解压到 MD5 目录 再解析返回本地文件检查格式版本后播放

表情面板只请求分组和列表,不预先下载压缩包;第一次在房间里收到或播放某个表情时才下载。好处是不浪费流量,热门表情很快就会在大多数设备上缓存命中;代价是某个表情第一次出现时要等下载。

2.2 内容寻址:用 MD5 做缓存键

后台为每个资源配置下载地址和内容 MD5。客户端以 MD5 作为缓存键:

存在单段 约定的 MP4 在组合 配置 JSON 能读不能不存在
不一致一致
按校验和找表情目录已在?按类型能解析吗直接使用下载压缩包文件 MD5 与配置一致吗丢弃 不缓存 回调空解压到 MD5 目录再解析一次

用内容 MD5 做键,一次解决三件事:

  1. 地址无关:下载地址换了 CDN 域名或加了参数,只要内容没变就能命中缓存;
  2. 内容变了一定重下:运营替换了同一个表情的动画,MD5 随之变化,自然走下载;
  3. 完整性校验:下载结果和配置里的 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 做目录名,在偏好设置里记下最近一次的地址,地址变了才重新下载,没有对文件内容做校验。

按内容寻址
否是
内容的 MD5 作为目录目录可解析吗下载 校验 解压直接使用 内容必然一致
按地址缓存
变了没变
地址的 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 「点了没动画」

下载失败或校验失败时,资源管理器回调空结果,播放侧直接返回。用户侧的表现是「对方发了表情,这边没有动画」。这类问题可以这样排查:

  1. 怎么复现:清掉缓存目录,在弱网或用代理工具截断下载的情况下,让另一台设备发同一个表情。
  2. 怎么定位:看是「下载失败」「MD5 不一致」「解压后解析失败」还是「格式版本不支持」中的哪一种。这也是给下载链路补分阶段日志的价值所在:麦位表情的下载链路按阶段记日志,每一步的结果都能对上。
  3. 怎么修:按阶段决定策略,网络失败可重试一次,MD5 不一致直接丢弃并上报。

4.2 解压不是原子操作

当前实现在校验通过后直接解压到最终的 MD5 目录。如果解压到一半 App 被杀,会留下一个半成品目录。下次查缓存时,如果半成品里碰巧有约定的 MP4,或者配置 JSON 已经写出来,就会被当成命中,用到不完整的资源。

否是
校验通过解压到临时目录解压完成并可解析删除临时目录一次性重命名为 MD5 目录之后只看到完整目录

改进方法如上:先解压到临时目录,确认完整后再一次性改名。同一卷内的重命名是原子的,查缓存的一方永远只能看到「没有」或「完整」两种状态。

4.3 其它边界

  • 缓存清理:目前没有按最近使用淘汰,也没有总大小上限,长期使用后缓存只增不减。
  • 格式版本不支持时静默不播:对用户没有任何提示,可以考虑显示一个静态占位,或提示升级。

5. 演进

  • 早期:打招呼素材按地址缓存,没有内容校验。
  • 新建资源管理器,支持麦位互动表情的下载,以及带透明通道的视频动画。
  • 表情面板的分组、列表和标签接入。
  • 补上麦位表情下载日志。

从按地址缓存到按内容寻址,本质是把「缓存是否有效」的判断,从「上次用的是哪个地址」换成了「内容本身是什么」。


5.1 两种缓存放在一起

按地址和按内容,差在「变了的是地址还是文件」。

按内容
校验和变了重新下载只是地址变了命中旧目录
按地址
地址变了重新下载

下载、校验、解压是一条状态,半截目录不能当成成功。

一致不一致
未缓存下载中校验解压失败可播放

单段和组合只在解析时分叉,下载路径相同。

资源包校验和目录单段一段动画组合配置多段动画

播放才下载,面板只拉列表。

是否
收到表情目录可解析?播放下载并校验打开面板只拉分组和列表

6. 复盘与可改进之处

做对了的地方

  • 内容寻址让缓存的正确性不依赖地址,换 CDN、加参数、替换动画都能处理对。
  • 校验和缓存是同一个键,下载完成即完成校验,没有额外的一致性维护成本。
  • 按需下载,运营在后台配好新表情,客户端无需发版就能用上。

还可以做得更好

  1. 原子解压:临时目录 + 重命名,杜绝半成品命中。
  2. 缓存淘汰:记录每个目录的最近使用时间,按 LRU 或总大小上限清理。
  3. 热门资源预热:在 Wi-Fi 下预下载面板第一页的表情,减少第一次播放的等待。
  4. 断点续传:大资源按字节范围(Range 请求头)续传,弱网下不必每次从头下载。
  5. 校验算法:MD5 用于完整性校验足够,若要防篡改可以换成 SHA-256 并配合签名。
  6. 失败可见:失败时展示占位并区分原因上报,让「点了没动画」变成可统计、可定位的问题。