一、视频缓存与预加载:让播放不再卡顿
做视频播放最怕什么?说白了就是卡顿。用户看着看着画面停了,转圈圈,然后骂一句「什么破 App」。我在早期做播放器的时候,就踩过这个坑——明明网络没问题,但播放就是一顿一顿的。后来才明白,问题出在缓存策略上。
今天我们就来聊聊视频缓存与预加载。这玩意儿说难不难,说简单也不简单。核心就四个字:快、稳、省。快是启动快,稳是播放稳,省是省流量省内存。
1.1 缓存策略:LRU vs FIFO
先问个问题:视频数据缓存到本地,满了怎么办?总得有个淘汰策略吧。常用的就两种:LRU 和 FIFO。
LRU(最近最少使用):谁最近没被访问,就淘汰谁。我个人的习惯是,视频播放场景首选 LRU。为什么呢?因为用户很可能反复看同一段内容,比如某个精彩镜头。LRU 能保证热数据留在缓存里。
FIFO(先进先出):谁先来谁先走。简单粗暴,但有个致命问题——如果用户正在播放一个长视频,FIFO 可能会把前面已经播过的数据清掉,导致往回拖动时又要重新下载。我曾经在一个项目里用过 FIFO,结果用户反馈「往回拖一下就卡住了」,后来果断换成了 LRU。
来看一个简单的 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-Control 和 ETag。
- 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 预加载机制
预加载,说白了就是「猜用户接下来要看什么」。猜对了,体验飞起;猜错了,白费流量。
常见的预加载策略有三种:
- 顺序预加载:当前视频快播完时,提前加载下一个视频的前几秒。适合列表页滑动场景。
- 滑动预加载:用户滑动列表时,预加载即将进入屏幕的视频。RecyclerView 的
onScroll回调里做。 - 智能预加载:根据用户历史行为,预测可能点击的视频。这个比较高级,需要机器学习模型。
我个人建议,大多数 App 做到前两种就够了。我曾经在一个短视频 App 里做过智能预加载,结果模型不准,预加载了 10 个视频用户一个都没点,白白浪费流量。后来改成了「滑动预加载 + 顺序预加载」的组合,效果反而更好。
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 在文件末尾,播放器必须等到整个文件下载完才能开始播放。这就是为什么有些视频「边下边播」无效的原因。
ffmpeg -i input.mp4 -movflags faststart output.mp4。
下面这张图展示了边下边播的整体流程:
最后说一句:缓存和预加载不是银弹。如果视频源本身质量差、服务器带宽不足,再怎么优化也没用。但做好缓存策略,至少能让 80% 的用户体验得到明显提升。嗯,这就是我们作为工程师的价值所在。
- 缓存策略选 LRU,别用 FIFO
- HTTP 缓存用 Cache-Control + ETag
- 预加载做到「滑动 + 顺序」就够了
- 边下边播要确保 moov atom 在文件开头
公众号:蓝海资料掘金营,微信deep3321