19、网络流媒体播放:HTTP Live Streaming(HLS)、DASH 协议、缓存策略

说到网络流媒体,我估计做 Android 多媒体开发的同行们,十有八九都跟 HLS 和 DASH 打过交道。这两个协议,说白了就是解决「怎么让用户流畅地看视频」这个核心问题。我自己在几年前接手过一个直播项目,那时候对 HLS 的理解还停留在「切文件、拉文件」的层面,结果线上出了不少卡顿和起播慢的问题。嗯,从那以后,我算是把这两个协议彻底啃了一遍。

今天我们就来聊聊 HLS 和 DASH 的核心原理,以及在实际开发中怎么设计缓存策略。你想想看,用户在地铁里刷视频,网络忽快忽慢,如果播放器不能智能切换码率、不能合理缓存,那体验基本就崩了。

19.1 HLS 协议:苹果的「切片」哲学

HLS(HTTP Live Streaming)是苹果搞出来的。它的思路很简单:把一整段视频切成很多小片段,每个片段几秒钟。播放器拿到一个索引文件(m3u8),就知道去哪里下载这些片段。

我习惯把 HLS 的架构分成三层:

  • 索引层:m3u8 文件,里面记录了视频片段的 URL 列表。
  • 切片层:实际的 TS 或 fMP4 文件,每个文件几秒到十几秒。
  • 播放层:播放器按顺序下载并播放这些切片。

举个例子,一个典型的 m3u8 文件长这样:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
segment_000.ts
#EXTINF:10.0,
segment_001.ts
#EXTINF:10.0,
segment_002.ts

这里有个坑,我曾经踩过:EXT-X-TARGETDURATION 这个字段,它表示每个切片的最大时长。如果服务器端生成的切片时长不稳定,播放器可能会提前请求下一个切片,导致 404 错误。所以,我建议在服务端做切片时,尽量保持时长一致,误差控制在 0.5 秒以内。

注意:HLS 的延迟问题。由于切片时长通常为 6-10 秒,加上播放器缓冲,端到端延迟可能在 15-30 秒。如果你做的是低延迟直播,HLS 可能不太合适,可以考虑 WebRTC 或 LL-HLS。

19.2 DASH 协议:MPEG 的「自适应」方案

DASH(Dynamic Adaptive Streaming over HTTP)是 MPEG 推出的标准。跟 HLS 相比,DASH 更灵活,支持多种编码格式和封装方式。它的核心是 MPD(Media Presentation Description)文件,相当于 HLS 的 m3u8。

我个人觉得 DASH 最大的优势是码率自适应做得更精细。MPD 文件里可以描述多个码率的视频流,播放器根据当前网络带宽,动态选择最合适的码率。举个例子:

<AdaptationSet mimeType="video/mp4">
  <Representation id="1" bandwidth="500000" width="640" height="360">
    <SegmentTemplate duration="2" startNumber="1" media="seg_$Number$.m4s" />
  </Representation>
  <Representation id="2" bandwidth="1000000" width="1280" height="720">
    <SegmentTemplate duration="2" startNumber="1" media="seg_$Number$.m4s" />
  </Representation>
</AdaptationSet>

这里有两个码率:500kbps 和 1000kbps。播放器会监测下载速度,如果带宽足够,就切到高码率;如果网络变差,就降回低码率。我在项目中遇到过一个问题:切换码率时画面会闪一下。后来发现是解码器在切换分辨率时需要重新初始化。解决办法是使用 fMP4 格式,它支持在同一个解码会话中切换分辨率,减少黑屏时间。

核心区别:HLS 是苹果生态的「亲儿子」,兼容性好,但延迟偏高;DASH 是开放标准,灵活性高,但客户端实现复杂度也更高。如果你只做 Android 端,DASH 是不错的选择;如果还要支持 iOS,HLS 几乎是必须的。

19.3 缓存策略:让播放更流畅

缓存策略,说白了就是「什么时候下载、下载多少、什么时候扔掉」。我见过不少开发者直接把所有切片都下载到本地,结果内存爆了。嗯,这里要注意,缓存不是越多越好,而是要恰到好处

我总结了一套比较实用的缓存策略,分为三个层面:

19.3.1 内存缓存

内存缓存用于存放即将播放的切片。我习惯用 LRU(最近最少使用) 算法来管理。比如,设置最大缓存 50MB,当新切片加入时,如果超过限制,就移除最久没被访问的切片。

public class VideoCacheManager {
    private final LruCache<String, byte[]> cache;

    public VideoCacheManager(int maxSize) {
        // 最大缓存 50MB
        this.cache = new LruCache<>(maxSize);
    }

    public void put(String url, byte[] data) {
        cache.put(url, data);
    }

    public byte[] get(String url) {
        return cache.get(url);
    }
}

这里有个细节:不要缓存整个视频。你想想看,用户可能只看了前 30 秒就划走了,缓存整个视频完全是浪费。我一般只缓存当前播放位置前后各 30 秒的切片。

19.3.2 磁盘缓存

磁盘缓存用于持久化存储,适合用户反复观看的内容。比如,用户看了一个教程视频,下次打开时可以直接从磁盘读取,不用重新下载。

我建议使用 OkHttp 的缓存机制,它支持磁盘缓存,而且可以设置缓存大小和过期时间。举个例子:

Cache diskCache = new Cache(cacheDir, 100 * 1024 * 1024); // 100MB
OkHttpClient client = new OkHttpClient.Builder()
        .cache(diskCache)
        .build();

不过要注意,HLS 和 DASH 的切片文件通常不设置 Cache-Control 头,所以 OkHttp 默认不会缓存。你需要手动配置拦截器,或者自己实现一个简单的文件缓存。

19.3.3 预加载策略

预加载是提升起播速度的关键。我习惯在用户点击播放按钮之前,就开始下载第一个切片。比如,在列表页展示视频封面时,后台悄悄下载第一个切片。这样用户点击播放时,几乎可以秒开。

我曾经在项目中用 ExoPlayer 的 PreloadManager 来实现预加载。它支持同时预加载多个视频,而且可以控制预加载的优先级。核心代码大概这样:

PreloadManager preloadManager = new PreloadManager.Builder(context)
        .setMaxPreloadCount(3) // 最多预加载 3 个视频
        .setPreloadDuration(5000) // 每个视频预加载 5 秒
        .build();

preloadManager.startPreload(videoUrl);
小技巧:预加载时不要下载整个切片,只下载前几个字节就够了。因为播放器在起播时,只需要拿到视频的头部信息(比如关键帧位置)就能开始解码。我一般预加载 2-3 个切片,每个切片只下载前 10KB 数据。

19.4 知识体系总览

下面这张图,我把 HLS、DASH 和缓存策略的核心逻辑串起来了。你可以看到,从协议选择到缓存实现,每一步都有对应的技术决策。

网络流媒体播放知识体系 协议选择 HLS(苹果生态) vs DASH(开放标准) 核心机制 HLS:m3u8 + TS/fMP4 切片 DASH:MPD + fMP4 分段 码率自适应:带宽监测 + 动态切换 缓存策略 内存缓存 LRU 算法,50MB 上限 磁盘缓存 OkHttp Cache,100MB 预加载 提前下载 2-3 个切片 目标:低延迟 + 高流畅度 + 省流量

19.5 避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路:

  • HLS 的 EXT-X-DISCONTINUITY:当视频流发生编码格式变化时(比如从 H.264 切到 H.265),m3u8 里会插入这个标签。如果播放器没处理好,画面会花屏。我建议在解析 m3u8 时,遇到这个标签就重置解码器。
  • DASH 的 SegmentTimeline:有些 MPD 文件使用 SegmentTimeline 来描述切片时长,而不是固定的 duration。如果解析逻辑写死了,可能会漏掉切片。我习惯用 ExoPlayer 的 DashChunkSource,它已经处理好了这些细节。
  • 缓存过期:磁盘缓存如果设置不当,会导致用户看到旧内容。我一般给缓存设置一个最大存活时间,比如 24 小时。超过时间的切片,强制重新下载。
  • 内存泄漏:缓存管理器的生命周期一定要跟 Activity 或 Fragment 绑定。我曾经在 Application 里持有一个全局缓存,结果导致内存泄漏。后来改用 WeakReference 来持有缓存数据,问题才解决。

好了,关于 HLS、DASH 和缓存策略,今天就聊到这里。这些内容在实际项目中非常实用,尤其是缓存策略,直接决定了用户的播放体验。如果你在实现过程中遇到问题,欢迎随时交流。


公众号:蓝海资料掘金营,微信deep3321