播放器数据结构与功能模块(一)Chunk 分片

分片容器 ChunkHolder

参数

  • 持有一个分片 Chunk
  • 一个 boolean 代表该分片是否还有更多数据可以加载,代表流已到达末尾的标记。
  • clear 清空容器中的分片数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package androidx.media3.exoplayer.source.chunk;

import androidx.annotation.Nullable;
import androidx.media3.common.util.UnstableApi;

/**
* 持有一个分片(Chunk)或者代表流已到达末尾的标记。
*
* 它是播放器与分片供应器(ChunkSource)之间的通信媒介:供应器将计算出的结果填充到此对象中,
* 播放器随后根据其中的内容决定是发起网络请求还是停止加载。
*/
@UnstableApi
public final class ChunkHolder {

/**
* 待加载的分片。
* 如果决定接下来要下载某个媒体块、索引块或初始化块,则将其赋值给此成员。
*/
@Nullable public Chunk chunk;

/**
* 指示是否已到达流的末尾。
* 如果设为 true,表示已经没有更多分片可以加载了。
*/
public boolean endOfStream;

/**
* 清除持有者的状态。
* 将 {@link #chunk} 置空,并将 {@link #endOfStream} 重置为 false。
*/
public void clear() {
chunk = null;
endOfStream = false;
}
}

核心加载策略源 ChunkSource

在 ExoPlayer 的分片加载系统中,ChunkSource 是逻辑复杂度最高、最核心的组件。它不负责实际的数据传输,而是充当“大脑”,基于网络状况、缓冲区余量和播放位置,动态决定“下一步该做什么”。


核心职能概述

ChunkSource 的主要职责可以概括为以下四个方面:

  1. 分片决策(Decision Making):通过 getNextChunk 方法,决定下一个要加载的是初始化分片、媒体分片还是流结束信号。
  2. ABR 策略执行(Adaptive Bitrate Control):实时监测 queue(已缓冲队列)的长度。如果缓冲区过薄,切换至低码率保证流畅;如果缓冲区厚实,则请求高码率。
  3. 缓冲管理(Queue Management):评估已缓存的块是否过时。例如,当用户网络好转,getPreferredQueueSize 可能会建议删掉后端尚未播放的低画质块,替换为高清块。
  4. 跳转校准(Seek Calibration):处理 SeekParameters,确保用户点击进度条时,能够根据分片的关键帧分布,计算出最合理的加载起点。(这里联合 Dash 分片在播放器中的存储结构 TreeSet 去理解)。

重要数据结构与参数解析

ChunkSource 的方法调用中,以下数据结构决定了其决策的精确性:

List<? extends MediaChunk> queue(缓冲队列)

  • 职能:这是 ChunkSource 的“实时地图”。它包含了所有已下载完成、正在排队等待被渲染器消费的媒体分片。
  • 关键作用
    • 连续性校验:通过查看 queue 的最后一个分片序号,决定下一个请求的起始位置,防止数据断层。
    • 缓冲区评估:通过 queue.size() 计算当前“缓冲区厚度”,这是 ABR 算法切换画质的核心依据。

ChunkHolder out(指令输出容器)

  • 职能:如前所述,它是一个轻量级契约,负责将 getNextChunk 的决策结果回传给 ChunkSampleStream
  • 职能细分:携带具体的 Chunk 实例(下载任务)或 endOfStream 标识(终止信号)。

LoadingInfo(加载上下文)

  • 职能:包含当前加载请求发起时的全局信息,如当前的播放速度、预期的位置等。
  • 作用:帮助 ChunkSource 站在全局视角(而非单一分片视角)做出更符合用户当前观看行为的判断。

多轨道流管理器 ChunkSampleStream 和 EmbeddedSampleStream

流的类型

轨道类型常量 含义
TRACK_TYPE_UNKNOWN 未知类型
TRACK_TYPE_DEFAULT 默认类型
TRACK_TYPE_AUDIO 音频轨道
TRACK_TYPE_VIDEO 视频轨道
TRACK_TYPE_TEXT 文本轨道
TRACK_TYPE_IMAGE 图像轨道
TRACK_TYPE_METADATA 元数据轨道
TRACK_TYPE_CAMERA_MOTION 相机运动数据轨道
TRACK_TYPE_NONE 无轨道类型
TRACK_TYPE_CUSTOM_BASE 应用定义的自定义基值

关键参数

1
2
/** 策略源,负责决策下一个该下载哪个分片 */
private final T chunkSource;
1
2
/** 嵌入式(从属)轨道的类型数组 */
private final int[] embeddedTrackTypes;
1
2
/** 决策结果的临时持有者,用于与 chunkSource 交互获取下一任务 */
private final ChunkHolder nextChunkHolder;

主轨道和嵌入轨道

在流媒体开发(尤其是 ExoPlayer 的语境)中,主轨道(Primary Track)嵌入轨道(Embedded Track)的关系,本质上是“整体”与“部分”“容器”与“附件”的关系。

通过下面的对比,你可以清晰地理解它们的角色:

主轨道 (Primary Track)

主轨道是媒体流的核心负载。在绝大多数分片流(如 DASH 或 HLS)中,主轨道通常就是视频轨

  • 地位:它是“家长”。所有的网络请求(HTTP Request)和数据下载都是由主轨道发起的。
  • 职责:它不仅负责下载自己的视频数据,还顺带把同一个分片(Chunk)里的其他辅助数据一并下载下来。
  • 物理形式:在传输层,它是一个完整的数据块(Chunk)。

嵌入轨道 (Embedded Track)

嵌入轨道是寄生在主轨道分片里的附加信息。常见的包括:

  • **闭路字幕 (Closed Captions)**:隐藏在视频采样数据中的 CEA-608/708 字幕。

  • **元数据 (Metadata)**:如相机运动参数、实时统计数据、或是某些特殊的定时 ID3 标签。

  • 地位:它是“乘客”。它没有独立下载数据的能力,必须搭主轨道的“便车”。

  • 物理形式:它在物理上并不存在独立的文件,而是通过解析主轨道的数据块,被“剥离”出来的逻辑流。

核心区别对比
特性 主轨道 (Primary Track) 嵌入轨道 (Embedded Track)
数据来源 直接发起网络下载 从主轨道下载的数据中二次解析
生命周期 独立存在,控制下载进度 依赖主轨道,主轨道停则它停
代码实现 ChunkSampleStream EmbeddedSampleStream
典型内容 视频、主音频 字幕、定时元数据、辅助轨道
形象化的例子:快递包裹

想象你网购了一台电脑显示器,包装箱里顺便塞了一本说明书

  • 主轨道 = 显示器:它是你买这个包裹的核心目的。快递员(网络加载线程)运送的是这个大箱子。
  • 嵌入轨道 = 说明书:它被嵌入在显示器的包装箱里。它不需要单独的快递单号(独立 URL),只要显示器到了,说明书也就跟着到了。
  • **解封装 (Extractor)**:当你拆开箱子(解析 Data Chunk),你把显示器连上主机,同时把说明书拿给用户看。

主轨道流 ChunkSampleStream

它是数据的入口。它拥有 ChunkSource,决定何时下载下一个分片(Chunk)。当一个包含多个轨道的媒体块被下载并解封装(Extract)后,主轨道数据会进入其自身内部的 SampleQueue

嵌入轨道流 EmbeddedSampleStream

它不具备解封装能力。在父类解封装时,次要轨道的数据会被分流到父类维护的其他 SampleQueue 中。EmbeddedSampleStream 只是这些队列的一个访问窗口

ChunkSampleStream 与 EmbeddedSampleStream 的关系

ChunkSampleStream 参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** 主轨道类型(例如:C.TRACK_TYPE_VIDEO 或 C.TRACK_TYPE_AUDIO) */
public final @C.TrackType int primaryTrackType;

/** 主轨道的样本队列,解析出的音视频数据存储于此 */
private final SampleQueue primarySampleQueue;

/** 嵌入式(从属)轨道的类型数组 */
private final int[] embeddedTrackTypes;

/** 嵌入式轨道的格式(Format)数组 */
private final Format[] embeddedTrackFormats;

/** 嵌入式轨道的样本队列数组(如视频流中携带的隐藏字幕) */
private final SampleQueue[] embeddedSampleQueues;

EmbeddedSampleStream 参数

1
2
3
4
5
6
7
8
/** 关联的父级媒体块样本流,负责实际的数据加载逻辑 */
public final ChunkSampleStream<T> parent;

/** 指向父级流中存储该轨道数据的样本队列 */
private final SampleQueue sampleQueue;

/** 该嵌入式轨道在父级流中的索引位置 */
private final int index;

整体与部分

从我列出的这些参数中,我们能清晰看出 ChunkSampleStream 和 EmbeddedSampleStream 之间整体与部分的关系

ChunkSampleStream 中持有主轨道类型和数据,以及多个嵌入轨道的类型和数据

EmbeddedSampleStream 中持有父级 ChunkSampleStream 类型的指针,以及父级嵌入轨道数据的指针,还有一个轨道在父级流中的索引位置

indexparent

  1. **主轨道 (parent)**:负责把大箱子搬进屋。它内部有多个抽屉(SampleQueue),它把显示器零件放 0 号抽屉,说明书放 1 号抽屉。
  2. **嵌入轨道 (index)**:它只负责盯着 1 号抽屉。每当主轨道往里放一张纸,嵌入轨道就立刻取出来读。

这种设计的目的是为了减少网络连接数——不需要为每一行字幕都去发一次 HTTP 请求,直接塞在视频流里最高效。

Chunk 的整体性

物理上:它们是一个整体(效率优先)

在传输层和文件存储层,视频、音频和元数据是“捆绑”在一起的。

单一容器(Container): 媒体分片(如 .m4s.ts 文件)通常是一个二进制文件。为了节省网络连接开销(HTTP 请求次数),编码器会将视频帧、音频帧和字幕数据按时间戳交错排列,打包成一个 Chunk。

单一请求: 播放器只需要发起一次网络请求(例如 GET chunk_1.m4s),就能把这个时间段内所有的媒体信息全部拿回来。

共享生命周期: 这个 Chunk 要么下载成功,所有轨道都有数据;要么下载失败,所有轨道都断流。这种“同生共死”的关系由 parentChunkSampleStream)统一管理。

Chunk 的独立性

逻辑上:它们是相互独立的(功能优先)

在播放器内部处理和解码层,每个轨道必须表现得像是单独存在一样。

  • 独立的消费速度: 视频解码器、音频解码器和字幕渲染引擎的工作节奏是不一样的。视频可能在等待硬件解码,而字幕可能瞬间就处理完了。因此,每个轨道需要有自己的 SampleQueue(缓冲区)和读取指针。
  • 独立的选择性: 用户可以关闭字幕,或者切换不同的音轨。在逻辑上,播放器需要能够“按需取货”,只读取某个特定的轨道,而忽略其他轨道。
  • 独立的格式声明: 视频的格式信息(分辨率、编码)和嵌入轨道的格式信息(如元数据类型)完全不同。EmbeddedSampleStream 通过 maybeNotifyDownstreamFormat 确保每个轨道都能正确地向后级报告自己的身份。

EmbeddedSampleStream 只是容器

要理解为什么说 EmbeddedSampleStream “不具备解封装能力”且只是个“访问窗口”,我们需要从 ExoPlayer 的数据流水线(Pipeline) 架构来看。

为什么它不具备解封装能力?

在 ExoPlayer 中,“解封装能力”指的是 Extractor(解封装器) 的职责。

  • 真正的执行者是父类:ChunkSampleStream 加载一个媒体块(MediaChunk)时,父类内部持有一个解封装器(如 MatroskaExtractorFragmentedMp4Extractor)。
  • 一次拆解,多路输出: 当解封装器处理一个文件时,它会同时识别出视频轨、音频轨和元数据轨。解封装器会将这些样本(Samples)直接推送到各自对应的 SampleQueue 中。
  • 嵌入流只是“持有者”: EmbeddedSampleStream 只是在构造时,由父类分配给它一个已经创建好的 SampleQueue 的引用(指针)。它本身不参与将二进制数据转换为样本的过程,它只是静静地守着那个队列,等待父类把“洗好的菜”放进去。
特性 父类 ChunkSampleStream 嵌入类 EmbeddedSampleStream
数据源 网络/磁盘(通过 Extractor 解封装) 父类分配的 SampleQueue
队列管理 创建并管理多个 SampleQueue 仅持有其中一个队列的引用
解封装逻辑 运行 Extractor,分发样本
角色比喻 厨师(负责切菜、炒菜、摆盘) 传菜员(负责把对应的菜端给对应的客人)

这种设计的妙处在于,无论一个分片里有多少个轨道,底层的 IO 加载和解封装逻辑只运行一次,极大地节省了 CPU 和内存开销。

深入理解嵌入轨道流持有主轨道流指针·
节省带宽与 IO (合并下载)

在 DASH 或 HLS 这种分片流中,视频轨道和嵌入轨道(如元数据、相机运动轨迹)通常被物理封装在同一个文件(Chunk)里。

  • 如果没有 Parent:每个轨道都要独立发请求下载同一个文件,导致带宽翻倍,流量被极度浪费。
  • 有了 Parent:由 parent 发起一次 HTTP 请求,下载整个分片,解析后分发给不同的轨道。
状态的“一键同步” (重置与跳转)

当用户拖动进度条(Seek)时,所有轨道必须同时停下来并跳转。

  • 如果没有 Parent:播放器必须逐个通知每一个嵌入流去重置,极易出现视频跳了但音频或字幕还在播旧数据的情况。
  • 有了 Parent:只需重置 parent,所有 EmbeddedSampleStream 通过 isPendingReset() 瞬间感知并同步停止。
精准的生命周期控制

嵌入流往往是“顺带”存在的。

  • 安全性:当 parent 取消了某个正在下载的分片(比如因为网速慢切换到了低清晰度),parent 会通过 canceledMediaChunk 标记一个边界。
  • 作用:它告诉嵌入流:“虽然你现在还能读到缓存里的数据,但由于我已经切走轨道了,后面的数据不要再读了。”这种边界约束必须由掌控全局下载的 parent 来下达。
readData 和 skipData 函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Override
public int skipData(long positionUs) {
if (isPendingReset()) {
return 0;
}
// 获取队列中可以跳过的样本数量
int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished);

// 安全边界检查:如果存在已被取消的 MediaChunk
if (canceledMediaChunk != null) {
// 确保不会跳过进入那些即将被丢弃的无效 Chunk 区域
// 1 + index 是因为 0 通常是主轨道,从 1 开始是嵌入轨道
int maxSkipCount =
canceledMediaChunk.getFirstSampleIndex(/* trackIndex= */ 1 + index)
- sampleQueue.getReadIndex();
skipCount = min(skipCount, maxSkipCount);
}

sampleQueue.skip(skipCount);
if (skipCount > 0) {
// 如果发生了跳转,可能需要更新下游的格式信息
maybeNotifyDownstreamFormat();
}
return skipCount;
}

@Override
public int readData(
FormatHolder formatHolder, DecoderInputBuffer buffer, @ReadFlags int readFlags) {
if (isPendingReset()) {
return C.RESULT_NOTHING_READ;
}

// 安全边界检查:如果当前读取位置已经达到或超过了被取消块的起始位置
if (canceledMediaChunk != null
&& canceledMediaChunk.getFirstSampleIndex(/* trackIndex= */ 1 + index)
<= sampleQueue.getReadIndex()) {
// 拦截读取,不读取即将被丢弃的无效数据
return C.RESULT_NOTHING_READ;
}

// 在读取数据前,确保已通知下游最新的格式信息
maybeNotifyDownstreamFormat();
return sampleQueue.read(formatHolder, buffer, readFlags, loadingFinished);
}
skipData (跳过数据)
  • 核心逻辑:根据目标时间戳 positionUs 计算出可以跳过的样本数量,并移动读取指针。
  • 特殊处理:它引入了对 canceledMediaChunk 的判断。如果某个数据块(Chunk)因为加载策略(如下载太慢、切换画质)被取消了,这个函数会强制限制跳过的范围,严禁跳入无效的数据区
readData (读取数据)
  • 核心逻辑:从缓冲区(SampleQueue)中提取真实的媒体数据或新的格式信息(Format)。
  • 特殊处理:在真正读取数据前,它会先通过 maybeNotifyDownstreamFormat() 检查并上报格式变更。同时,它也具备“拦截”功能:如果读取位置已经触及被取消的块,直接返回“无数据可读”,防止解码器读到垃圾数据。
与父类的区别
维度 父类 (ChunkSampleStream / Primary) 本类 (EmbeddedSampleStream)
数据来源 负责发起 HTTP 请求,加载整个 MediaChunk 不加载数据,只从父类加载好的缓冲区中分一杯羹。
轨道地位 主轨道(通常是视频或音频)。 次要轨道(通常是嵌入在视频流里的字幕或元数据)。
错误处理 maybeThrowError 会真实抛出网络异常。 maybeThrowError 是空的,它相信父类会处理好所有加载异常。
索引管理 管理 0 号轨道。 管理 1 + index 号轨道。

待博主下一次更新


核心控制层 (Orchestration)

这一层负责整个加载流程的调度、决策与执行。

  • ChunkSampleStream: 系统的指挥中心。它负责与播放器(Player)对接,管理样本缓冲区(SampleQueue),并协调 ChunkSource 进行数据加载。
  • ChunkSource: 加载策略源(接口)。它不负责具体的下载,而是决定“接下来该加载哪段数据”。它会根据当前的带宽、缓冲区状态以及 ABR(自适应码率)算法,选择合适的分片并填充到 ChunkHolder 中。
  • ChunkHolder: 决策载体。这是一个简单的容器类,用于 ChunkSource 返回决策结果,可能包含一个待加载的 Chunk 对象或一个“已到达流末尾”的标记。

分片数据模型层 (Data Model)

这一层定义了分片物理实体的继承体系。

  • Chunk: 所有分片的基类。定义了基础属性,如加载的数据源(DataSource)、数据规格(Format)以及加载原因。
  • MediaChunk: 媒体分片抽象类。在 Chunk 基础上增加了时间戳信息(起止时间)和分片序号。
  • BaseMediaChunk: 媒体分片的基础实现。它引入了索引逻辑,能够将解析出的样本数据精准映射到底层的 SampleQueue
  • ContainerMediaChunk: 容器化分片实现。用于处理封装在 MP4、TS 或 WebM 等容器中的媒体数据。
  • SingleSampleMediaChunk: 单样本分片实现。常用于加载独立的样本文件(如外挂字幕或某些特定的图片流)。
  • InitializationChunk: 初始化分片。它不包含实际的音视频样本,而是包含解码器所需的元数据(如 SPS/PPS),用于初始化解复用器。
  • DataChunk: 通用数据分片。用于传输非媒体数据(如 DRM 许可证数据或清空缓存的指令)。

解析与提取层 (Extraction)

这一层负责将二进制的“块”解析为播放器可识别的音视频帧。

  • ChunkExtractor: 解析接口。定义了将分片数据流提取为样本(Samples)的标准行为。
  • BundledChunkExtractor: 内置解析实现。使用 ExoPlayer 内部集成的各类 Extractor(如 FragmentedMp4Extractor)来解析数据。
  • MediaParserChunkExtractor: 系统级解析实现。利用 Android 11 及以上系统提供的 MediaParser API 进行数据提取。
  • BaseMediaChunkOutput: 数据路由中间件。它作为 ChunkExtractor 的输出目标,负责将提取出来的样本数据转发给当前正在处理的 BaseMediaChunk

辅助与迭代层 (Auxiliary)

用于在不加载真实数据的情况下预览流信息。

  • MediaChunkIterator: 元数据迭代器(接口)。允许 ChunkSource 在未开始实际加载前,先查看后续分片的时间跨度和数据范围。
  • BaseMediaChunkIterator: 迭代器基础抽象。简化了具体协议(如 DASH 或 HLS)中分片迭代器的实现逻辑。

协作关系总结

  1. 请求决策ChunkSampleStream 调用 ChunkSource.getNextChunk()
  2. 生成分片ChunkSource 使用 MediaChunkIterator 预览流信息,决策后创建一个 ContainerMediaChunk 等对象,放入 ChunkHolder
  3. 执行加载ChunkSampleStream 将该分片交给加载器执行下载。
  4. 解析输出:在下载过程中,分片数据被推入 ChunkExtractor。解析出的样本通过 BaseMediaChunkOutput 写入 BaseMediaChunk 关联的缓存区,最终供播放器渲染。

ExoPlayer Chunk 加载系统:职责分类与交互指南

本指南旨在通过架构逻辑的可视化,帮助开发者理解 ExoPlayer 中分片(Chunk)加载系统的核心组件、职责边界及其交互流程。

二、 核心类分类说明表

所属分类 核心类名 职责说明
核心控制层 ChunkSampleStream 指挥中心。对接播放器,协调加载策略、缓存管理和最终的样本输出。
ChunkSource 策略源。接口类。决定当前应该加载哪个分片、何时进行码率切换。
ChunkHolder 决策载体。临时存放 ChunkSource 返回的分片对象或待处理的状态信息。
分片数据模型 Chunk / MediaChunk 基类。定义了分片的物理属性,如时间戳、数据规格和加载范围。
BaseMediaChunk 实现基础。媒体分片的核心抽象,主要负责处理样本索引和存储逻辑。
Container / SingleSample 具体实现。分别对应封装在容器(如 MP4/TS)中的分片或独立的单样本分片。
Initialization / Data 特殊分片。用于初始化解码器配置或传输非媒体数据(如 DRM 密钥)。
样本提取层 ChunkExtractor 解析接口。定义了将二进制数据流还原为音视频帧(Samples)的标准。
Bundled / MediaParser 解析实现。前者使用 ExoPlayer 内置解析库,后者调用 Android 系统原生 API。
BaseMediaChunkOutput 中间件。将解析出的样本数据精准路由到正确的 SampleQueue 中。
辅助与元数据 MediaChunkIterator 预览工具。在未加载真实数据前,允许系统查看后续分片的排列和时间信息。

三、 核心协作逻辑 (Workflow)

  1. 调度阶段ChunkSampleStream 定期轮询 ChunkSource 是否需要加载新数据。
  2. 决策阶段ChunkSource 根据当前带宽和缓冲区情况,填充 ChunkHolder(包含具体的 Chunk 子类)。
  3. 加载阶段:由 Loader 执行 Chunk 的加载任务。
  4. 提取阶段:如果是媒体分片,ChunkExtractor 开始解析,通过 BaseMediaChunkOutput 将解析出的数据推送到 SampleQueue 供渲染器消费。

[!TIP]
设计模式应用:该系统是典型的策略模式(ChunkSource)与工厂模式(ChunkExtractor)的结合,保证了对不同流媒体协议(DASH, HLS, SmoothStreaming)的扩展性。


💡 建议

如果你是在做代码重构或性能调优,重点关注 ChunkSource 的码率切换算法以及 SampleQueue 的内存占用情况。这两个点通常是 Chunk 系统中最容易产生瓶颈的地方。

,