VOD 深度剖析 第 9 篇:视频播放器 —— 从清单到首帧
深入视频播放器内部:Web(MSE/EME)、iOS(AVPlayer)、Android(ExoPlayer/Media3),以及 TTFF 优化、缓冲策略、音画同步,还有自研与采购的取舍。
本文是 VOD 流媒体深度剖析 系列的第 9 篇。
播放器到底做了什么?
当你按下”播放”时,播放器至少要完成 10 个步骤:
① Parse manifest (m3u8/mpd)
② Decide which quality tier to start with
③ Download init segment + first media segment
④ Parse fMP4 box structure
⑤ Demux video ES + audio ES
⑥ Feed into decoder (hardware or software)
⑦ Decode to YUV image + PCM audio
⑧ Audio-video sync (lip sync)
⑨ YUV → RGB color conversion
⑩ Render to display
与此同时,它还要:下载后续分片、运行 ABR 算法、上报 QoE 数据、响应用户操作(暂停、拖动、切换清晰度),并处理 DRM 挑战。
一个现代播放器动辄就有 10 万行以上的代码,远不像一个 <video> 标签那么简单。
Web 播放器:video + MSE + EME
原生 video 标签够用吗?
<video src="video.mp4" controls></video>
它能播放单个 MP4,但无法播放 HLS/DASH/CMAF——这些格式是由一组分片构成的,需要 JavaScript 来编排调度。
Safari 是个例外:它原生支持 HLS,所以 <video src="index.m3u8"> 可以直接工作。
MSE(Media Source Extensions)
MSE 是一套 W3C API,它让 JavaScript 能够动态地把字节喂给 video 元素:
const video = document.querySelector('video');
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', () => {
const sb = mediaSource.addSourceBuffer(
'video/mp4; codecs="avc1.64001f,mp4a.40.2"'
);
fetch('seg_01.m4s')
.then(r => r.arrayBuffer())
.then(buf => sb.appendBuffer(buf));
});
有了 MSE,JavaScript 就能解析清单、按需下载分片并喂给浏览器。hls.js 和 Shaka Player 都构建在 MSE 之上。
EME(Encrypted Media Extensions)
EME 是 W3C 的 DRM API,让 JavaScript 与浏览器内置的 CDM 交互(Chrome 中是 Widevine、Edge 中是 PlayReady、Safari 中是 FairPlay):
video.addEventListener('encrypted', async (event) => {
const mediaKeys = await navigator
.requestMediaKeySystemAccess('com.widevine.alpha', config)
.then(a => a.createMediaKeys());
video.setMediaKeys(mediaKeys);
const session = mediaKeys.createSession();
session.addEventListener('message', async (event) => {
// event.message is the CDM challenge
const license = await fetch('/license', {
method: 'POST', body: event.message
}).then(r => r.arrayBuffer());
session.update(license);
});
session.generateRequest('cenc', event.initData);
});
开源 Web 播放器
| 库 | 侧重点 | 维护方 |
|---|---|---|
| hls.js | HLS | Dailymotion / video-dev |
| Shaka Player | DASH + HLS | |
| dash.js | DASH | DASH-IF |
| Video.js | UI 框架 | Brightcove |
仅需 HLS → 选 hls.js。DASH 或混合场景 → 选 Shaka Player。
iOS:AVPlayer
它是什么
- iOS 系统原生播放器
- 播放 FairPlay DRM 内容的唯一官方途径
- Apple 是 HLS 的发源方,其实现也最为完善
局限
协议:仅支持 HLS(无原生 DASH)。
ABR 是个黑盒。只有少数几个参数可配置:
// Limit max bitrate
playerItem.preferredPeakBitRate = 2_000_000 // 2 Mbps cap
// Forward buffer duration
playerItem.preferredForwardBufferDuration = 10 // 10 seconds
无法自定义”该挑哪个清晰度档位”的逻辑。
下载队列不透明。想做自定义缓存或预加载,只能靠一些变通手段。
变通方案:AVAssetResourceLoaderDelegate
用来拦截清单和分片请求:
class CustomLoader: NSObject, AVAssetResourceLoaderDelegate {
func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource
loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
let url = loadingRequest.request.url!
fetchFromCache(url) { data in
loadingRequest.dataRequest?.respond(with: data)
loadingRequest.finishLoading()
}
return true
}
}
实现起来很复杂,但头部应用(TikTok、短剧类 App)为了做到零 TTFF 都必须这么干。
用 AVQueuePlayer 做预加载
let queue = AVQueuePlayer()
queue.insert(AVPlayerItem(url: ep1URL), after: nil)
queue.insert(AVPlayerItem(url: ep2URL), after: nil) // pre-loads
queue.insert(AVPlayerItem(url: ep3URL), after: nil) // pre-loads
Android:ExoPlayer / Media3
Google 官方开源播放器,用于取代老旧的 MediaPlayer:
- 支持 HLS、DASH、SmoothStreaming、Progressive
- 通过
MediaDrmAPI 原生支持 Widevine DRM - 完全开源、可高度定制
val player = ExoPlayer.Builder(context)
.setTrackSelector(DefaultTrackSelector(context).apply {
setParameters(buildUponParameters().setMaxVideoBitrate(2_000_000))
})
.setLoadControl(DefaultLoadControl.Builder()
.setBufferDurationsMs(15_000, 30_000, 1_500, 2_500)
.build())
.build()
player.setMediaItem(MediaItem.fromUri("https://.../master.m3u8"))
player.prepare()
player.play()
它比 AVPlayer 可定制得多:TrackSelector(码率/分辨率控制)、LoadControl(缓冲参数)、MediaSourceFactory(自定义下载/CDN 路由)、RenderersFactory(后处理滤镜)。
TTFF 优化:抵达首帧的最快路径
TTFF(Time to First Frame,首帧时间) 是 VOD 和短视频最敏感的指标。
① DNS resolution ~20-100ms
② TCP handshake ~30-100ms
③ TLS handshake ~50-200ms
④ Fetch manifest ~30-200ms
⑤ Fetch init segment ~50-100ms
⑥ Fetch first segment ~100-500ms
⑦ Decode + render ~50-200ms
累计下来轻松就是 1–2 秒。下面是把它压到 300ms 以下的做法:
| 技巧 | 节省的时间 |
|---|---|
| DNS 预解析(在 App 启动时解析) | 20–100ms |
| HTTP/3 + 0-RTT(瞬时重连) | 50–200ms |
| Preconnect(提前建立 TLS) | 50–200ms |
| 清单预取(提前拉取下一集的清单) | 200ms |
| 短 GOP(1–2s)+ 短分片(2s) | 1–2 秒 |
| 从最低档位起播(首个分片体积小) | 视情况而定 |
| 本地缓存 init 分片 | 50–100ms |
| 预加载下一集的前 3 个分片 | 集间切换几乎零延迟 |
| 硬件解码 | 解码开销约 0ms |
短视频的零 TTFF
短视频类 App 的核心模式:
Currently playing episode N:
┌────────────────────────────────────────────────┐
│ N-1 (keep 5s buffer) [in case user swipes back]│
│ N (fully loaded) │
│ N+1 (pre-load init + first 3 segments ≈ 6-12s) │
│ N+2 (pre-fetch init + first 1 segment) │
└────────────────────────────────────────────────┘
缓冲策略
缓冲(Buffer) = 已下载但尚未播放的内容时长(秒)。
Playhead ─► [played] [play point] [buffered] [not downloaded]
◄── Buffer Level ──►
三个阈值:
- Min Buffer(起播前的最小缓冲量,例如 3s)
- Target Buffer(理想缓冲量,例如 30s)
- Max Buffer(上限,防止过度下载,例如 60s)
| 场景 | Min | Target | Max |
|---|---|---|---|
| 长片电影 | 3s | 30s | 120s |
| 短视频 | 1s | 10s | 20s |
| 低延迟直播 | 0.5s | 2s | 6s |
当缓冲耗尽归零时 → 触发重缓冲(转圈加载)。应对手段:强制切到最低档位、切换 CDN 节点、上报告警。
音画同步(唇音同步)
视频帧和音频帧是分开解码的。同步依赖于 PTS(Presentation Timestamp,显示时间戳)——每一帧都带有一个”何时显示”的时间戳。
大多数播放器以音频为主时钟(人对音频时序偏移更敏感),并对齐视频帧来匹配它。
硬件解码 vs 软件解码
| 硬件解码 | 软件解码 | |
|---|---|---|
| 性能 | 快,可处理 4K 60fps | 较慢,处理 4K 可能吃力 |
| 功耗 | 低 | 高 |
| 灵活性 | 仅限受支持的编解码器 | 任意格式 |
| 怪癖 | 部分设备存在边缘情况的 bug | 稳定 |
始终优先使用硬件解码。 只有当硬件不支持某编解码器时(老芯片上的 AV1、非常规编码参数)才回退到软件解码。
自研 vs 采购
| 方案 | 适用场景 | 成本 |
|---|---|---|
| 直接使用开源方案(hls.js / ExoPlayer / AVPlayer) | 99% 的 VOD 平台 | 低 |
| 在开源方案上做轻度定制 | 特殊的 UI / ABR / 分析需求 | 中 |
| 深度自研(替换 ExoPlayer 内核、绕过 AVPlayer) | 头部应用(TikTok、主流短剧平台) | 高(数十人月) |
不要自研播放器引擎,除非你遇到了开源方案解决不了的特定性能/体验问题,并且有足够的预算去持续维护它。
必备的播放器埋点分析(为第 10 篇做准备)
无论是开源还是自研,以下这些事件都必须埋点:
| 事件 | 含义 |
|---|---|
video_attempt | 用户触发了播放 |
video_start | 首帧已渲染 |
video_rebuffer_start | 开始缓冲 |
video_rebuffer_end | 缓冲结束 |
bitrate_change | 清晰度档位切换 |
video_complete | 播放完成 |
video_error | 发生错误 |
video_exit | 用户离开 |
每个事件都会携带:video_id、user_id、cdn、network_type、device、bitrate、buffer_level 等信息。
关键要点
- Web 端播放 HLS/DASH 需要 MSE;DRM 需要 EME。
- iOS 上 HLS + FairPlay = 只能用 AVPlayer;ABR 是个黑盒。
- Android 的 ExoPlayer / Media3 开源且高度可定制。
- TTFF 优化:DNS 预解析 + HTTP/3 + 短 GOP + 预加载。
- 缓冲阈值(Min/Target/Max)应按内容类型分别调优。
- 音画同步以音频为主时钟。
- 优先选用开源播放器,只在不得已时才自研。
上一篇: 第 8 篇:DRM 内容保护
下一篇: 第 10 篇:QoE 指标与监控
参考资料
- hls.js — GitHub
- Shaka Player — Google / GitHub
- HTTP Live Streaming — Apple Developer