一、视频缓存与预加载:让播放不再卡顿

做视频播放最怕什么?说白了就是卡顿。用户看着看着画面停了,转圈圈,然后骂一句「什么破 App」。我在早期做播放器的时候,就踩过这个坑——明明网络没问题,但播放就是一顿一顿的。后来才明白,问题出在缓存策略上。

今天我们就来聊聊视频缓存与预加载。这玩意儿说难不难,说简单也不简单。核心就四个字:快、稳、省。快是启动快,稳是播放稳,省是省流量省内存。

1.1 缓存策略:LRU vs FIFO

先问个问题:视频数据缓存到本地,满了怎么办?总得有个淘汰策略吧。常用的就两种:LRU 和 FIFO。

LRU(最近最少使用):谁最近没被访问,就淘汰谁。我个人的习惯是,视频播放场景首选 LRU。为什么呢?因为用户很可能反复看同一段内容,比如某个精彩镜头。LRU 能保证热数据留在缓存里。

FIFO(先进先出):谁先来谁先走。简单粗暴,但有个致命问题——如果用户正在播放一个长视频,FIFO 可能会把前面已经播过的数据清掉,导致往回拖动时又要重新下载。我曾经在一个项目里用过 FIFO,结果用户反馈「往回拖一下就卡住了」,后来果断换成了 LRU。

核心结论:视频播放场景,LRU 优于 FIFO。除非你的场景极其简单,否则别用 FIFO。

来看一个简单的 LRU 缓存实现(基于 LinkedHashMap):

public class VideoCache {
    private final LinkedHashMap<String, byte[]> cache;
    private final int maxSize;

    public VideoCache(int maxSize) {
        this.maxSize = maxSize;
        this.cache = new LinkedHashMap<String, byte[]>(maxSize, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
                return size() > maxSize;
            }
        };
    }

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

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

注意那个 true 参数,它表示按访问顺序排序。这就是 LRU 的精髓——每次访问都会把数据移到链表尾部,淘汰时删掉头部的。

1.2 HTTP 缓存实现

除了内存缓存,我们还需要 HTTP 层面的缓存。说白了就是利用 HTTP 协议自带的缓存机制,减少重复请求。

HTTP 缓存主要靠两个东西:Cache-ControlETag

  • Cache-Control:告诉客户端能不能缓存,缓存多久。比如 max-age=3600 表示 1 小时内不用重新请求。
  • ETag:资源的唯一标识。客户端请求时带上 If-None-Match,服务器返回 304 表示没变化,直接用缓存。

我在项目中遇到过一个问题:视频分片(HLS 的 .ts 文件)的 ETag 经常变化,导致缓存失效。后来发现是 CDN 配置问题,每个分片都生成了新的 ETag。解决办法是让 CDN 对视频分片使用固定的 ETag,或者干脆用 Last-Modified 替代。

小技巧:对于视频分片,建议使用 Cache-Control: public, max-age=86400,缓存一天基本够用。如果视频内容会更新,再配合 ETag 做验证。

Android 端实现 HTTP 缓存,可以用 OkHttp 的 Cache 拦截器:

OkHttpClient client = new OkHttpClient.Builder()
    .cache(new Cache(cacheDir, 50 * 1024 * 1024)) // 50MB 缓存
    .addInterceptor(new CacheInterceptor())
    .build();

嗯,这里要注意:视频文件通常很大,HTTP 缓存只适合缓存小文件或分片。对于整个视频文件,还是用本地文件缓存更靠谱。

1.3 预加载机制

预加载,说白了就是「猜用户接下来要看什么」。猜对了,体验飞起;猜错了,白费流量。

常见的预加载策略有三种:

  1. 顺序预加载:当前视频快播完时,提前加载下一个视频的前几秒。适合列表页滑动场景。
  2. 滑动预加载:用户滑动列表时,预加载即将进入屏幕的视频。RecyclerView 的 onScroll 回调里做。
  3. 智能预加载:根据用户历史行为,预测可能点击的视频。这个比较高级,需要机器学习模型。

我个人建议,大多数 App 做到前两种就够了。我曾经在一个短视频 App 里做过智能预加载,结果模型不准,预加载了 10 个视频用户一个都没点,白白浪费流量。后来改成了「滑动预加载 + 顺序预加载」的组合,效果反而更好。

注意:预加载一定要考虑用户流量。在 Wi-Fi 下可以大胆预加载,在移动网络下要谨慎。建议在设置里加一个「仅在 Wi-Fi 下预加载」的开关。

1.4 边下边播:核心实现

边下边播,也叫 Progressive Download。说白了就是一边下载视频文件,一边播放已经下载的部分。这是视频播放的基础能力。

实现边下边播,核心在于两点:

  • 数据分片:把视频文件分成多个小块,下载一块就通知播放器可以播了。
  • Range 请求:支持 HTTP 的 Range 头,可以只请求文件的某一部分。

来看一个简单的边下边播架构:

// 伪代码:边下边播的核心逻辑
public class ProgressiveDownloader {
    private final String url;
    private final File cacheFile;
    private final MediaPlayer player;

    public void start() {
        // 1. 发起 Range 请求,下载文件头
        // 2. 获取文件总大小
        // 3. 启动下载线程,分片下载
        // 4. 每下载完一个分片,通知播放器
        // 5. 播放器从本地文件读取已下载的数据
    }
}

这里有个坑:视频文件的 moov atom(元数据)必须在文件开头。如果 moov 在文件末尾,播放器必须等到整个文件下载完才能开始播放。这就是为什么有些视频「边下边播」无效的原因。

避坑指南:我曾经遇到一个视频,怎么都实现不了边下边播。后来用工具一看,moov atom 在文件末尾。解决办法是用 FFmpeg 把 moov 移到文件开头:ffmpeg -i input.mp4 -movflags faststart output.mp4

下面这张图展示了边下边播的整体流程:

边下边播核心流程 视频源 HTTP Range 请求 下载器 分片下载 本地缓存 文件存储 播放 反馈已播放位置,继续下载后续分片 关键点: 1. 下载器通过 HTTP Range 请求获取视频文件的指定字节范围 2. 每下载完一个分片,写入本地缓存文件 3. 播放器从本地缓存文件读取数据,无需等待完整下载

最后说一句:缓存和预加载不是银弹。如果视频源本身质量差、服务器带宽不足,再怎么优化也没用。但做好缓存策略,至少能让 80% 的用户体验得到明显提升。嗯,这就是我们作为工程师的价值所在。

总结一下:
  • 缓存策略选 LRU,别用 FIFO
  • HTTP 缓存用 Cache-Control + ETag
  • 预加载做到「滑动 + 顺序」就够了
  • 边下边播要确保 moov atom 在文件开头

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