实时渲染(一)绘制一个三角形

实时渲染

本文旨在通过精简实用的方式,结合移动端开发的特点,提供一个易于上手的 OpenGL 学习路径。
我们将重点关注与移动端应用相关的核心功能实现,帮助开发者在短时间内掌握基础知识,并快速应用到实际项目中。

我们抛开自定义渲染管线,哪些让人云里雾里的 OpenGL 状态机设置,让人头疼的 C++ 工程编译配置,
在学习之初就要直击核心效果的绘制。

一个常见的误区是学习渲染需要先掌握 C++ 语言,对于非 C++ 出身的 Android 同学来说,这是个可怕的错误,因为它会让初学的成本直接翻倍,在应用渲染的过程中顺便解决 C++ 更合适。等我们学会了 Shader 之后,再去想能不能有优化工程中的其他问题,包括语言。

市面上现有的渲染教程,常常有着大段大段的代码,和复杂的渲染管线图,这些对于初学者来说都是舍本逐末,徒增学习负担。
本教程将尽力避免这些对于初学者不友好的点,并尽可能简单的提供给大家直接能编译运行调试程序的环境。

定义第一个三角形

在 OpenGL 中,Shader 是运行在 GPU 上的小程序,用于控制图形渲染的各个阶段。常见的 Shader 类型包括:

  1. Vertex Shader:处理顶点数据,决定顶点位置和属性。
  2. Fragment Shader:处理像素数据,决定每个像素的颜色。

我们专注于决定 Shader 渲染需要的参数和函数

着色器

理论

着色器:运行在 GPU 上的小程序,用于控制图形渲染过程中的特定阶段,如顶点变换和像素着色。

着色器的作用:控制 GPU 渲染的顶点处理和像素着色。

着色器程序:将编译后的顶点着色器和片段着色器链接在一起,用于 GPU 渲染的一段可执行代码。

着色器就是渲染的核心,从这个概念也可以看出,我们要学习渲染编程,最主要就是要学习如何编写着色器程序。
着色器程序的英文就是 Shader。

工程

GLuint:无符号整数类型,用于表示 OpenGL 对象的唯一标识符。

1
typedef unsigned int GLuint;

着色器程序对象指针:一个 OpenGL 的 GLuint 标识符,用于引用和操作链接后的着色器程序。

定义着色器程序对象指针
1
GLuint m_ProgramObj;  ///< 着色器程序对象 ID
定义顶点着色器对象与片段着色器对象指针
1
2
GLuint m_VertexShader;  ///< 顶点着色器对象 ID
GLuint m_FragmentShader; ///< 片段着色器对象 ID
编写三角形着色器程序代码
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
void TriangleSample::Init() {
if (m_ProgramObj != 0)
return;
// 顶点着色器代码
char vShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"layout(location = 0) in vec4 vPosition; \n" // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor; \n" // 输入顶点颜色,绑定到位置 1
"out vec3 fColor; \n" // 输出到片段着色器的颜色变量
"void main() \n" // 主函数
"{ \n"
" gl_Position = vPosition; \n" // 将顶点位置传递给 gl_Position(OpenGL 内置变量)
" fColor = vColor; \n" // 将顶点颜色赋值给输出变量 fColor
"} \n";

// 片段着色器代码
char fShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"precision mediump float; \n" // 指定默认精度为中等精度
"in vec3 fColor; \n" // 从顶点着色器接收的颜色变量
"out vec4 fragColor; \n" // 输出的片段颜色
"void main() \n" // 主函数
"{ \n"
" fragColor = vec4(fColor, 1.0); \n" // 将 fColor 赋值给 fragColor,同时指定 alpha 为 1.0
"} \n";

m_ProgramObj = GLUtils::CreateProgram(vShaderStr, fShaderStr, m_VertexShader, m_FragmentShader);
}

逐部分解析:

我们有以下的问题要回答:

  1. 什么是 vec3、vec4

vec3: 三维向量组

vec4: 四维向量组

  1. 怎么理解 in 和 out 和 main 函数

以 Kotlin 函数为例子

1
fun main(vPosition: vec4, vColor: vec3): vec3

vPosition 和 vColor 是函数入参 —— 对应了 in
vec3 是函数返回值 —— 对应了 out

在着色器程序中:
in 用于接收来自上一个阶段的数据
out 用于将数据传递给下一个阶段
main 函数是程序的入口

  1. layout(location = X) 是什么意思,怎么理解这个 layout
1
2
"layout(location = 0) in vec4 vPosition;  \n" // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor; \n" // 输入顶点颜色,绑定到位置 1

理解 layout

顶点着色器是渲染管线的第一道程序,其 in 修饰的参数来自于外部,需要使用 layout 给这样的参数一个序号,以便外部传入。

定义一个彩色三角形

首先要明确一个概念,任何物体,不管是二维还是三维的,三点确定一个平面,无数的平面组成了空间的中的物体。

这也是为什么我们要从渲染一个三角形开始说起。

VBO 顶点缓冲区

我们把空间中的点叫做顶点,把保存顶点属性数据的数据结构叫做 顶点缓冲区(VBO)

  • 顶点缓冲区存储的是顶点数据(如位置、颜色等)。你将顶点数据上传到 GPU 上的缓冲区对象(VBO)中。每个顶点数据通常包括多个属性,这些属性是按照一定顺序存储的。例如,一个顶点可能包含位置(vec4)和颜色(vec3)。

按照我们的定义,每个顶点包含一个 vPosition 和 vColor,layout 就是定义顶点属性数据在顶点缓冲区中的位置。

VBO 就应该是

position (vec4) color (vec3)
0.0f, 0.5f, 0.0f, 1.0f 1.0f, 0.0f, 0.0f

有了上面对多维向量,in、out、layout 和 VBO 的概念之后,我们继续说顶点着色器与片段着色器

完整的传参的过程

首先我们看一下完整参数的传递过程

顶点着色器是渲染流水管线的第一步,它的参数是从外面传入的,这个我们待会说

1
2
"layout(location = 0) in vec4 vPosition;  \n"      // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor;" // 输入顶点颜色,绑定到位置 1

同时它会将向下一个阶段也就是片段着色器中传递

1
"out vec3 fColor;                         \n"     // 输出到片段着色器的颜色变量

而片段着色器的参数是顶点着色器传入的

1
"in vec3 fColor;                              \n" // 从顶点着色器接收的颜色变量

同时它会向下一个阶段传递片段颜色

1
"out vec4 fragColor;                          \n" // 输出的片段颜色

现在我们定义一个 含有三个 VBO 的三角形

position (vec4) color (vec3) position (vec4) color (vec3) position (vec4) color (vec3)
0.0f, 0.5f, 0.0f, 1.0f 1.0f, 0.0f, 0.0f -0.5f, -0.5f, 0.0f, 1.0f 0.0f, 1.0f, 0.0f 0.5f, -0.5f, 0.0f.1.0f 0.0f, 0.0f, 1.0f

代码中应该这样写

1
2
3
4
5
6
7
// 定义三角形的顶点数据,包括位置和颜色
GLfloat vVertices[] = {
// 顶点位置 // 颜色 (RGB)
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 顶部顶点 (红色)
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下角顶点 (绿色)
0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 右下角顶点 (蓝色)
};

为什么顶点位置在着色器中被定义为 vec4 但是代码中只有三个浮点类型?

齐次坐标:

  • 在 3D 图形学中,通常使用 齐次坐标 来表示点的坐标。齐次坐标引入了第四个分量(w),使得点可以在经过透视投影等变换时更方便地处理。
  • 齐次坐标 是一种表示方法,通常在计算机图形学中用于:
    • 处理 投影变换(如透视投影)。
    • 简化 平移、缩放、旋转 等变换操作。
  • 在齐次坐标中,一个点 (x, y, z) 会被表示为 (x, y, z, w)。通常情况下,w 的值为 1.0,表示这是一个 位置 点,而不是一个 方向(如果 w0,则表示方向向量)。

即使顶点数据的 z 之后没有显式给出,OpenGL 默认也会使用 w = 1.0,这样顶点位置就是 (x, y, z, 1.0)

理解片段着色器的插值

顶点着色器非常容易理解,每个顶点都有一个对应的颜色,片段着色器是如何着色的呢,且看一个具体的问题

如果我定义了一个三角形三个顶点,每个顶点颜色都不一样,片段着色器是怎么实现整个三角形的颜色绘制的呢?

上述三角形顶点数据中,每个顶点都有一个对应的颜色信息,这些颜色信息分别是:

  • 顶部顶点:红色 (1.0f, 0.0f, 0.0f)
  • 左下角顶点:绿色 (0.0f, 1.0f, 0.0f)
  • 右下角顶点:蓝色 (0.0f, 0.0f, 1.0f)

这些颜色值在 OpenGL 渲染过程中会根据每个片段的位置通过插值计算得出。

插值过程

OpenGL 会基于片段(像素)所在的 重心坐标 来计算插值颜色。对于每个片段,OpenGL 会基于三角形的三个顶点计算出该片段到三个顶点的加权距离,然后根据这些距离来加权三个顶点的颜色,从而得到该片段的最终颜色。

具体例子

假设你有一个三角形,顶点 A、B 和 C 分别为:

  • 顶点 A(红色):位置 (0.0f, 0.5f, 0.0f),颜色 (1.0f, 0.0f, 0.0f)
  • 顶点 B(绿色):位置 (-0.5f, -0.5f, 0.0f),颜色 (0.0f, 1.0f, 0.0f)
  • 顶点 C(蓝色):位置 (0.5f, -0.5f, 0.0f),颜色 (0.0f, 0.0f, 1.0f)

在片段着色器中,我们从顶点着色器接收到每个片段的颜色信息(即 fColor)。然后,OpenGL 会根据该片段的位置和顶点 A、B、C 的颜色进行插值。

顶点颜色的加权插值

OpenGL 通过 重心坐标 插值颜色,重心坐标是一个描述三角形内部某个点相对三个顶点的相对位置的坐标。每个片段的位置可以通过三角形的重心坐标表示为:

1
P = u * A + v * B + w * C

其中:

  • P 是该片段的最终位置。
  • uvw 是重心坐标,它们的和总是 1 (u + v + w = 1)。
  • ABC 分别是三角形的三个顶点的位置。

对于颜色的插值,同样的方式适用:

1
Color(P) = u * Color(A) + v * Color(B) + w * Color(C)

即通过重心坐标 uvw 来加权三个顶点的颜色。

举例

假设一个片段位于三角形内部,并且它的重心坐标是 u = 0.3v = 0.3w = 0.4u + v + w = 1)。那么该片段的最终颜色会是:

1
Color(P) = 0.3 * (1.0f, 0.0f, 0.0f) + 0.3 * (0.0f, 1.0f, 0.0f) + 0.4 * (0.0f, 0.0f, 1.0f)

通过计算:

1
2
Color(P) = (0.3, 0.0, 0.0) + (0.0, 0.3, 0.0) + (0.0, 0.0, 0.4)
= (0.3, 0.3, 0.4)

因此,最终这个片段的颜色将是 (0.3, 0.3, 0.4),即一个介于红色、绿色和蓝色之间的渐变颜色。

片段着色器中的实现

在片段着色器中,我们会接收从顶点着色器传递过来的颜色数据,并由 OpenGL 自动进行插值。每个片段的颜色是由其位置在三角形内的重心坐标决定的。

以下是片段着色器的代码,假设我们已经在顶点着色器中传递了颜色:

1
2
3
4
5
6
7
8
9
#version 300 es
precision mediump float;

in vec3 fColor; // 从顶点着色器传递来的颜色
out vec4 fragColor; // 输出的片段颜色

void main() {
fragColor = vec4(fColor, 1.0); // 输出颜色,设置 alpha 为 1.0
}

总结

当我们在顶点着色器中为三角形的每个顶点指定不同的颜色时,OpenGL 会自动计算每个片段的颜色。这是通过 颜色插值 完成的,即对于三角形内的每个像素,OpenGL 会根据该像素到三个顶点的距离加权计算该像素的颜色,从而实现颜色渐变效果。

向顶点着色器中传参

回过头来说说向顶点着色器中传参

我们在上面解释 layout 的时候,说到顶点着色器的参数是外部传入的,所以需要借助 layout 来进行传参

现在我们来看看具体是如何传参数的

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
void TriangleSample::Draw(int screenW, int screenH) {
LOGCATE("TriangleSample::Draw");

// 定义三角形的顶点数据,包括位置和颜色
GLfloat vVertices[] = {
// 顶点位置 // 颜色 (RGB)
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 顶部顶点 (红色)
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下角顶点 (绿色)
0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 右下角顶点 (蓝色)
};

// 检查是否已经创建了着色器程序对象
if (m_ProgramObj == 0)
return;

// 清除颜色缓冲区、深度缓冲区和模板缓冲区
glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 设置清屏背景颜色为白色
glClearColor(1.0, 1.0, 1.0, 1.0);

// 使用着色器程序
glUseProgram(m_ProgramObj);

// 加载顶点数据
// 设置顶点位置数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices);
glEnableVertexAttribArray(0);

// 设置顶点颜色数据
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices + 3);
glEnableVertexAttribArray(1);

// 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3);

// 禁用着色器程序
glUseProgram(GL_NONE);
}

关键代码

1
2
3
4
5
6
7
8
9
10
/**
* @param index 目标属性位置,通常为顶点着色器中的 `layout(location = x)` 中的 x 值。
* @param size 每个属性的组件数量。例如,`3` 表示一个三维向量 (x, y, z)。
* @param type 属性的数据类型,常见的有 `GL_FLOAT`,表示浮点数。
* @param normalized 如果为 `GL_TRUE`,则会将属性值归一化到 [0,1] 或 [-1,1] 范围内;如果为 `GL_FALSE`,则属性值原样传递。
* @param stride 相邻两个属性之间的间隔,以字节为单位。通常是每个顶点的数据大小。
* @param pointer 指向顶点数据的指针,通常为一个数组或缓冲区对象的指针。此处的 `vVertices` 表示顶点数据数组的起始地址。
*/
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices);
glEnableVertexAttribArray(0);

附录

完整代码

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include "TriangleSample.h"
#include "../util/GLUtils.h"
#include "../util/LogUtil.h"

TriangleSample::TriangleSample() {

}

TriangleSample::~TriangleSample() {
}

void TriangleSample::LoadImage(NativeImage *pImage) {
//null implement
}

void TriangleSample::Init() {
if (m_ProgramObj != 0)
return;
// 顶点着色器代码
char vShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"layout(location = 0) in vec4 vPosition; \n" // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor; \n" // 输入顶点颜色,绑定到位置 1
"out vec3 fColor; \n" // 输出到片段着色器的颜色变量
"void main() \n" // 主函数
"{ \n"
" gl_Position = vPosition; \n" // 将顶点位置传递给 gl_Position(OpenGL 内置变量)
" fColor = vColor; \n" // 将顶点颜色赋值给输出变量 fColor
"} \n";

// 片段着色器代码
char fShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"precision mediump float; \n" // 指定默认精度为中等精度
"in vec3 fColor; \n" // 从顶点着色器接收的颜色变量
"out vec4 fragColor; \n" // 输出的片段颜色
"void main() \n" // 主函数
"{ \n"
" fragColor = vec4(fColor, 1.0); \n" // 将 fColor 赋值给 fragColor,同时指定 alpha 为 1.0
"} \n";

m_ProgramObj = GLUtils::CreateProgram(vShaderStr, fShaderStr, m_VertexShader, m_FragmentShader);
}

void TriangleSample::Draw(int screenW, int screenH) {
LOGCATE("TriangleSample::Draw");

// 定义三角形的顶点数据,包括位置和颜色
GLfloat vVertices[] = {
// 顶点位置 // 颜色 (RGB)
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 顶部顶点 (红色)
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下角顶点 (绿色)
0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 右下角顶点 (蓝色)
};

// 检查是否已经创建了着色器程序对象
if (m_ProgramObj == 0)
return;

// 清除颜色缓冲区、深度缓冲区和模板缓冲区
glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 设置清屏背景颜色为白色
glClearColor(1.0, 1.0, 1.0, 1.0);

// 使用着色器程序
glUseProgram(m_ProgramObj);

// 加载顶点数据
// 设置顶点位置数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices);
glEnableVertexAttribArray(0);

// 设置顶点颜色数据
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices + 3);
glEnableVertexAttribArray(1);

// 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3);

// 禁用着色器程序
glUseProgram(GL_NONE);
}

void TriangleSample::Destroy() {
if (m_ProgramObj) {
glDeleteProgram(m_ProgramObj);
m_ProgramObj = GL_NONE;
}
}

效果

image-20250215040336594

播放器数据结构与功能模块(一)Chunk (块) —— 加载层视角

Chunk 是 Media3 内部 ChunkSource 和 ChunkSampleStream 处理的对象实例。

定义: 在代码中,Chunk 是一个抽象类,代表了一次完整的 HTTP 加载任务。

特性:一个 Chunk 通常对应一个 Segment,但并不绝对。

作用: 封装了数据加载的状态(从哪下载、下载了多少、数据读到了哪个 Buffer)。

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
/** 可使用 {@link Loader} 进行加载的对象接口。 */
public interface Loadable {

/**
* 取消加载任务。
*
* <p>Loadable 的实现类应确保在调用此方法后,当前正在执行的 {@link #load()} 调用能迅速退出。
* {@link #load()} 可以通过正常返回或抛出 {@link IOException} 来退出。
*
* <p>如果当前有正在执行的 {@link #load()} 调用,则在调用此方法后,执行该调用的线程将被立即中断。
* 因此,实现类不需要(也不应该尝试)自行中断加载线程。
*
* <p>尽管加载线程会被中断,但 Loadable 的实现类不应在 {@link #load()} 中使用线程的中断状态
* 来判断加载是否已被取消。这种方法并不健壮。相反,实现类应该使用自己的标志(例如使用 {@link AtomicBoolean})
* 来发出取消信号。
*/
void cancelLoad();

/**
* 执行加载操作,在完成或取消时返回。
*
* @throws IOException 如果输入内容无法加载。
*/
void load() throws IOException;
}
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
package androidx.media3.exoplayer.source.chunk;

import static com.google.common.base.Preconditions.checkNotNull;

import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.C.DataType;
import androidx.media3.common.Format;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSpec;
import androidx.media3.datasource.StatsDataSource;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.upstream.Loader.Loadable;
import java.util.List;
import java.util.Map;

/**
* 一个抽象基类,实现了 {@link Loadable} 接口,用于加载流播放所需的各种“数据块(Chunks)”。
*/
@UnstableApi
public abstract class Chunk implements Loadable {

/** 标识此加载任务的唯一 ID。 */
public final long loadTaskId;

/** 定义要加载的数据范围(如 URL、偏移量、长度等)的 {@link DataSpec}。 */
public final DataSpec dataSpec;

/** 此分片的数据类型(例如:媒体数据、清单文件、元数据等),仅用于统计和报告。 */
public final @DataType int type;

/** 此分片所属轨道的格式信息(如:分辨率、码率、编码格式等)。 */
public final Format trackFormat;

/**
* 轨道选择的原因(例如:自适应切换、手动选择、初次加载等)。
* 如果该分片不属于特定轨道或原因未知,则为 {@link C#SELECTION_REASON_UNKNOWN}。
*/
public final @C.SelectionReason int trackSelectionReason;

/**
* 轨道选择时的附加可选数据。如果没有相关数据则为 Null。
*/
@Nullable public final Object trackSelectionData;

/**
* 此分片包含的媒体内容的起始时间戳(微秒)。
* 如果加载的数据不包含媒体采样(如加载索引文件),则为 {@link C#TIME_UNSET}。
*/
public final long startTimeUs;

/**
* 此分片包含的媒体内容的结束时间戳(微秒)。
* 如果加载的数据不包含媒体采样,则为 {@link C#TIME_UNSET}。
*/
public final long endTimeUs;

/** 具有统计功能的数据源,用于追踪已读取的字节数、最后的响应头等。 */
protected final StatsDataSource dataSource;

/**
* @param dataSource 执行数据加载的数据源。
* @param dataSpec 定义要加载的数据范围。
* @param type 分片类型。
* @param trackFormat 轨道格式。
* @param trackSelectionReason 轨道选择原因。
* @param trackSelectionData 轨道选择关联数据。
* @param startTimeUs 媒体起始时间。
* @param endTimeUs 媒体结束时间。
*/
public Chunk(
DataSource dataSource,
DataSpec dataSpec,
@DataType int type,
Format trackFormat,
@C.SelectionReason int trackSelectionReason,
@Nullable Object trackSelectionData,
long startTimeUs,
long endTimeUs) {
// 将传入的 dataSource 包装成带统计功能的 StatsDataSource
this.dataSource = new StatsDataSource(dataSource);
this.dataSpec = checkNotNull(dataSpec);
this.type = type;
this.trackFormat = trackFormat;
this.trackSelectionReason = trackSelectionReason;
this.trackSelectionData = trackSelectionData;
this.startTimeUs = startTimeUs;
this.endTimeUs = endTimeUs;
// 为当前任务生成一个新的 ID,用于日志追踪和事件上报
loadTaskId = LoadEventInfo.getNewId();
}

/** 返回此分片的持续时间(微秒)。 */
public final long getDurationUs() {
return endTimeUs - startTimeUs;
}

/**
* 返回已加载的字节数。
* 注意:必须在加载完成、失败或被取消后调用此方法。
*/
public final long bytesLoaded() {
return dataSource.getBytesRead();
}

/**
* 返回最后一次调用 {@link DataSource#open} 关联的 {@link Uri}。
* 如果发生了重定向,则返回重定向后的 URI。
* 注意:必须在加载完成、失败或被取消后调用此方法。
*/
public final Uri getUri() {
return dataSource.getLastOpenedUri();
}

/**
* 返回最后一次 {@link DataSource#open} 调用产生的响应头。
* 注意:必须在加载完成、失败或被取消后调用此方法。
*/
public final Map<String, List<String>> getResponseHeaders() {
return dataSource.getLastResponseHeaders();
}
}

播放器内核专项(一)线程模型的分离

ExoPlayer 线程模型架构文档

概述

ExoPlayer 的线程模型是其实现高性能、低延迟媒体播放的核心。为了保证 UI 的流畅性并确保播放状态的稳定,ExoPlayer 采用了多线程异步解耦架构。在这种架构下,播放器的 API 调用(通常在主线程/应用线程)与实际的媒体处理(解码、渲染、缓冲)被严格分离。


架构概览

ExoPlayer 的设计理念是“控制权在主线程,执行权在播放线程”。所有对播放器的操作均通过 Handler 发送到内部的播放线程中执行,从而避免阻塞主线程。


核心线程分解

ExoPlayer 的运行依赖于以下三个主要的线程角色,它们各司其职,通过消息机制协同工作。

线程角色 名称 主要职责
应用/主线程 Main / UI Thread UI 交互、用户指令输入、监听播放状态回调
播放线程 Playback Thread 状态机管理、媒体时钟同步、解码器调度、渲染控制
媒体加载线程 Media Loading Threads 网络 IO、数据下载、Buffer 填充、数据解析

1. 主线程 (Application/UI Thread)

  • 用途: 这是宿主应用程序运行的线程。
  • 职责:
    • 初始化 ExoPlayer 实例。
    • 接收来自用户的操作(如播放、暂停、进度跳转)。
    • 接收播放器抛出的回调事件(如 onPlayerStateChangedonPlayerError),以便更新 UI。
  • 限制: 绝对不能在主线程执行耗时操作(如网络请求或复杂的解码逻辑)。

2. 播放线程 (Playback Thread)

  • 用途: ExoPlayer 在内部创建一个专门的后台线程(通常使用 HandlerThreadExoPlayer 内部管理的线程)来处理播放逻辑。
  • 职责:
    • 核心逻辑控制: 维护播放器的内部状态机(Idle, Buffering, Ready, Ended)。
    • 同步: 协调音频和视频流的同步(Media Clock Synchronization)。
    • 组件通信:MediaSourceRendererTrackSelector 进行交互。
  • 架构优势: 通过将所有状态变更逻辑封装在单一的播放线程中,避免了复杂的多线程竞争条件(Race Conditions),无需对内部状态变量使用大量的锁(Lock/Mutex),从而提升了性能。

3. 媒体加载/IO 线程 (Media Loading/Parsing Threads)

  • 用途:MediaSource 相关的组件创建的后台线程池。
  • 职责:
    • 执行具体的网络请求(下载数据块)。
    • 文件解析(如读取 MP4 的 moov 原子,解析 DASH/HLS 的 Manifest)。
    • 将解析后的数据放入缓冲区(Buffer)。
  • 架构优势: 实现了 IO 操作与渲染操作的彻底分离,确保网络波动(如 Wi-Fi 抖动)不会直接卡死播放器或 UI。

线程通信与解耦机制

ExoPlayer 的解耦架构依赖于 HandlerLooper 机制:

  1. 指令下发: 当你在主线程调用 player.play() 时,ExoPlayer 内部会将该调用封装为一个 MessageRunnable,通过 Handler 发送到播放线程的 MessageQueue 中。
  2. 顺序执行: 播放线程按顺序从 MessageQueue 中取出并执行任务。这种方式保证了播放指令的串行化,避免了并发冲突。
  3. 结果反馈: 当播放线程完成任务或状态发生改变时,它会通过 Handler 将事件通过主线程的 Looper 发回,从而在应用层触发监听器(Listeners)。

最佳实践与开发者注意事项

为了维持这种线程架构的稳定性,开发者应遵循以下准则:

  • 线程归属感 (Thread Confinement): ExoPlayer 实例的操作必须在创建它的那个线程上进行(通常是主线程)。不要在不同的线程中随意操作同一个 ExoPlayer 实例。
  • 轻量化回调: 虽然 onPlayerStateChanged 等回调是在主线程执行的,但如果你在回调中执行了极其耗时的操作(如数据库写入或复杂的 UI 渲染逻辑),依然会掉帧。请确保回调逻辑保持轻量。
  • 避免阻塞播放线程: 如果自定义了 MediaSourceRenderer,请确保其中的 readrender 方法不会被同步阻塞过长时间,否则会直接导致播放线程卡顿,进而引发音频断续或视频冻结。
  • 生命周期管理: 务必在 ActivityFragment 的生命周期销毁回调(如 onStoponDestroy)中调用 player.release(),以释放播放线程资源,防止内存泄漏。

播放器数据结构与功能模块(二)ChunkStream(块流)

分块容器 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 号轨道。
这些函数构成了 ChunkSampleStream 的核心逻辑。为了方便理解,可以将它们按照 生命周期管理、数据读取、加载控制、Seek/位置管理 以及 内部状态维护 这五个核心维度进行分类。

ChunkSampleStream 里面大概有什么

Chunk 纬度

ChunkSampleStream 的这些参数中,直接或间接与 Chunk(分块) 相关的成员可以归纳为以下几类,涵盖了从“决策”到“执行”,再到“存储”和“消费”的完整生命周期:

分块决策与获取 (Decision & Retrieval)

这组参数决定了“下一个分块是什么”以及“去哪里拿”。

  • chunkSource: 分块源。这是整个流程的“大脑”,负责根据当前的缓冲情况和网络带宽,决定下一个该下载哪个分块。
  • nextChunkHolder: 分块持有者。一个临时的容器,chunkSource 会将选中的分块信息填充到这个对象中,供加载器使用。
分块任务执行 (Execution)

这组参数关注“正在进行的”分块任务。

  • loadingChunk: 当前加载的分块。记录目前正在通过网络下载的那个 Chunk 对象。
  • canceledMediaChunk: 被取消的分块。如果当前的加载任务被中止(例如因为用户跳转或切换了更清晰的轨道),该变量会临时持有该分块,用于后续的清理或重试逻辑。
分块存储与视图 (Storage & View)

分块下载完成后的存放地。

  • mediaChunks: 分块列表。一个内部的 ArrayList,按顺序存储所有已下载完成、正等待被 SampleQueue 消费或正在输出的分块。
  • readOnlyMediaChunks: 只读分块列表。提供给外部(如 UI 或监控层)查看当前缓冲区内分块状态的视图,保证了线程安全和封装性。
分块解析与输出 (Parsing & Output)

负责将分块文件“拆解”并路由到正确的轨道。

  • chunkOutput: 分块输出桥接器。它扮演了中介角色,将解复用器(Extractor)从分块中解析出的音视频数据,精准地分发到对应的 SampleQueue 中。
分块状态同步 (Synchronization & Indexing)

维护分块与播放进度、格式之间的对应关系。

  • nextNotifyPrimaryFormatMediaChunkIndex: 格式通知索引。由于不同的分块可能具有不同的分辨率或码率,该索引记录了下一个需要触发“格式变化”通知的分块位置,确保渲染器能及时感知到数据流的变化。
分块生命周期示意
  • Step 1: chunkSource 选定分块并放入 nextChunkHolder
  • Step 2: loader 开始加载 loadingChunk
  • Step 3: chunkOutput 将数据从分块剥离,存入 primarySampleQueue
  • Step 4: 下载完的分块进入 mediaChunks 队列。
  • Step 5: 消费完成后,分块从 mediaChunks 移除。
Loading 纬度
加载执行与调度 (Execution & Scheduling)
  • loader: 核心执行器。内部封装了线程池,负责在后台线程执行 loadingChunk 的下载任务。
  • chunkSource: 加载决策者。虽然它不执行下载,但它决定了加载的内容,是加载行为的“指挥官”。
  • loadingChunk: 当前加载任务。指向当前正在被 loader 处理的 Chunk 实例。
状态监控与反馈 (State Monitoring & Feedback)

用于实时追踪加载进度,并通知外部组件(如 ExoPlayer 核心或 UI)。

  • loadingFinished: 加载完成标志。这是一个关键的状态位,当 chunkSource 告知没有更多分块可加载(流结束)且当前无待处理任务时,该值为 true
  • callback: 加载进度回调。用于将“数据已加载”、“加载完成”或“由于某种原因加载停滞”的消息通知给上层的 MediaPeriod
  • mediaSourceEventDispatcher: 事件广播器。负责发送更细粒度的事件,例如 onLoadStarted(加载开始)、onLoadCompleted(加载成功)和 onLoadCanceled(加载取消)。
加载异常与容错 (Error Handling & Resilience)

当网络抖动或请求失败时,负责处理“接下来该怎么办”的逻辑。

  • loadErrorHandlingPolicy: 错误策略管理。定义了加载失败后的行为:是立即重试、延迟 5 秒重试,还是直接抛出异常并停止加载。
  • canceledMediaChunk: 取消状态记录。如果加载任务因为某种原因被手动取消(Cancel),此变量会记录该任务,以便后续判断是否需要重新触发加载。
加载控制 (Loading Control)

根据播放状态决定是否允许继续读取或加载数据。

  • suppressRead: 数据读取抑制。虽然它更偏向消费端,但在某些跳转或重新缓冲(Re-buffering)场景下,它会配合加载状态共同确保播放器不会在数据未就绪时尝试读取,从而影响整体加载策略。
加载状态机简述
  • 准备阶段: chunkSource 通过 nextChunkHolder 提交任务。
  • 启动阶段: loader 接管 loadingChunkmediaSourceEventDispatcher 发出开始信号。
  • 运行阶段: callback 不断汇报进度。
  • 异常阶段: 若失败,loadErrorHandlingPolicy 介入决定是否重试。
  • 结束阶段: 如果没有更多数据,loadingFinished 设为 true

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 预览工具。在未加载真实数据前,允许系统查看后续分块的排列和时间信息。

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

💡 建议

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

播放器内核专项(二)PlayerMessage

PlayerMessage 的设计核心是一个状态机(State Machine),用于确保消息在复杂的播放器线程模型中能够安全、准确地传递。我们可以将这些字段分为三大类来理解:


1. 基础配置(定义消息是什么)

这些字段在消息创建时设定,定义了“谁来处理”、“处理什么”以及“何时处理”。

  • target: 消息的接收者(实现了 Target 接口)。这是处理逻辑的核心,通常是 Renderer 或其他播放器组件。
  • sender: 消息的发送者,负责将消息放入播放器的消息队列。
  • type: 消息类型(整数常量),用于告知 target 该如何处理该消息。
  • payload: 携带的具体数据,可以是任何对象(如设置参数、配置信息等)。
  • looper: 指定消息处理的线程。这非常重要,因为 ExoPlayer 是多线程环境,该字段确保消息被发送到正确的线程处理,避免线程不安全问题。
  • mediaItemIndex & positionMs: 定义了消息的触发时机(在哪一段视频的哪个时间点触发)。如果不设置,通常意味着立即触发。

2. 状态标志(追踪生命周期)

这些字段用于记录消息在传递过程中的状态,防止非法操作(例如:消息发出了还能修改吗?)。

  • isSent: 消息是否已经调用了 send()。一旦变为 true,上述所有配置字段(Type, Payload, Position 等)都不可再更改,保证了线程安全。
  • isDelivered: 消息是否已经成功抵达 target 并被执行。
  • isProcessed: 消息是否处理完毕(无论是因为成功执行了,还是因为被取消了)。
  • isCanceled: 消息是否在发送后被手动取消。

3. 控制与同步(决定消息如何终结)

  • deleteAfterDelivery: 决定消息是一次性的还是重复触发的。如果为 false,则当播放器循环播放回到该位置时,消息会再次触发。
  • clock: 用于 blockUntilDelivered(timeoutMs) 方法中,计算超时时间。

PlayerMessage 生命周期流程图

理解这些状态的关键在于理解消息从创建到销毁的旅程:

  1. 创建 (Initialization): 设置 Target、Timeline、Looper。
  2. 配置 (Configuration): 调用 setPayload, setPosition 等。此时 isSentfalse,允许修改。
  3. 发送 (Send): 调用 send()。状态 isSent 置为 true。此时配置被锁定。
  4. 排队与执行 (Delivery): 播放器在 looper 指定的线程中处理消息,调用 target.handleMessage()
  5. 结束 (Completion):
    • 成功:执行完毕,调用 markAsProcessed(true)
    • 取消:调用 cancel(),状态 isCanceled 置为 true,调用 markAsProcessed(false)
    • 标记为已处理 (isProcessed = true) 后,blockUntilDelivered() 等待的方法会收到通知并返回。

核心设计哲学

您可以看到代码中大量使用了 checkState(!isSent)。这体现了不可变性(Immutability)的设计原则:一旦消息投入发送队列,其状态就不应该被外部随意更改,从而避免了极其难以调试的竞态条件(Race Conditions)。

这种机制确保了即便您在 UI 线程创建消息,也可以安全地将其发送到播放器的播放线程(Playback Thread)进行执行。