VOD 深度剖析 第 9 篇:视频播放器 —— 从清单到首帧

深入视频播放器内部:Web(MSE/EME)、iOS(AVPlayer)、Android(ExoPlayer/Media3),以及 TTFF 优化、缓冲策略、音画同步,还有自研与采购的取舍。

zhuermu··20 分钟
vodstreamingplayerhls-jsexoplayeravplayer

本文是 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.jsShaka 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.jsHLSDailymotion / video-dev
Shaka PlayerDASH + HLSGoogle
dash.jsDASHDASH-IF
Video.jsUI 框架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
  • 通过 MediaDrm API 原生支持 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)
场景MinTargetMax
长片电影3s30s120s
短视频1s10s20s
低延迟直播0.5s2s6s

当缓冲耗尽归零时 → 触发重缓冲(转圈加载)。应对手段:强制切到最低档位、切换 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_iduser_idcdnnetwork_typedevicebitratebuffer_level 等信息。


关键要点

  1. Web 端播放 HLS/DASH 需要 MSE;DRM 需要 EME
  2. iOS 上 HLS + FairPlay = 只能用 AVPlayer;ABR 是个黑盒。
  3. Android 的 ExoPlayer / Media3 开源且高度可定制。
  4. TTFF 优化:DNS 预解析 + HTTP/3 + 短 GOP + 预加载。
  5. 缓冲阈值(Min/Target/Max)应按内容类型分别调优。
  6. 音画同步以音频为主时钟。
  7. 优先选用开源播放器,只在不得已时才自研。

上一篇: 第 8 篇:DRM 内容保护

下一篇: 第 10 篇:QoE 指标与监控

参考资料

  1. hls.js — GitHub
  2. Shaka Player — Google / GitHub
  3. HTTP Live Streaming — Apple Developer