09 播放器项目实践(三):让你的播放器跑起来
你好,我是展晓凯。今天我们进入播放器项目实战最后一部分的学习。
前面两节课,我们从播放器的场景入手设计出了播放器的架构,然后一起实现了播放器的底层核心模块,包括音频渲染模块、视频渲染模块与视频解码模块。这节课我们要把这些模块串联在一起,让我们的播放器运行起来。
用播放器播放视频最重要的一点就是要保证音画对齐,架构设计中的AVSync模块就承担这一职责。
AVSync模块的实现
AVSync模块除了负责音视频的同步之外,还要维护一个解码线程,主要工作就是线程的创建、暂停、运行、销毁,就是我们架构类图中AVSynchronizer这个类。
这个类的实现分为两部分,第一部分是维护解码线程,第二部分就是音视频同步。主要接口与实现有以下四个。
- 提供初始化接口,内部实现为:使用外界传递过来的URI去实例化解码器模块,实例化成功之后,创建音频队列与视频队列,并且创建解码线程,将音视频解码后的数据放入队列中;
- 提供获取音频数据接口,内部实现为:如果音频队列中有音频就直接去返回,同时要记录下这个音频帧的时间戳,如果音频队列中没有音频就返回静音数据;
- 提供获取视频帧接口,内部实现为:返回与当前播放的音频帧时间戳对齐的视频帧。
- 提供销毁接口,内部实现为:先停掉解码线程,然后销毁解码器,最后再销毁音视频队列。
维护解码线程
AVSync模块创建的解码线程扮演了生产者的角色,生产出来的数据根据类型分别存放到音频队列和视频队列中。而AVSync模块向外暴露的获取音频数据和视频帧的方法,扮演了消费者的角色,消费者会从音视频队列里面获取数据,这是一个标准的生产者-消费者模型。
由于是在Native层来维护线程,所以我们选用POSIX线程模型来创建一个解码线程。创建成功之后就让解码线程运行起来,解码音频帧和视频帧。解码出来的音视频帧封装为我们自定义的结构体AudioFrame和VideoFrame,然后把它们分别放入音视频队列中。解码线程内部代码如下:
while(isOnDecoding) {
isDecodingFrames = true;
decodeFrames();
isDecodingFrames = false;
pthread_mutex_lock(&videoDecoderLock);
pthread_cond_wait(&videoDecoderCondition, &videoDecoderLock);
pthread_mutex_unlock(&videoDecoderLock);
}
上述代码表示,只要我们不销毁这个模块(销毁的时候会把isOnDecoding设置为false),就会一直在这个循环中。这个循环内部有一个条件锁,每执行一次循环之后,就会停在Wait的地方,等待Signal指令过来,才可以进行下一次解码操作,为什么要这样安排呢?
原因有两点,一是如果全部解码出来,内存中是放不下的,尤其是视频,占用内存太大了;二是用户随时可能停止播放,我们解码出来的音视频帧就都没用了,白白浪费了CPU和带宽资源。所以后台解码线程没必要一股脑把视频全部解码并放入队列中。
那decodeFrames方法执行结束之后就要进行Wait,那执行这个方法的条件是什么呢?
我们设置一个变量名字叫做max_bufferDuration,值设置为0.2s。每一次调用decodeFrames这个方法,都会将两个队列填充到max_bufferDuration的刻度之上,这个方法执行结束之后,解码线程就在Wait处等待Signal指令。那什么时候会得到Signal指令呢?
我们设置一个变量名字叫做min_bufferDuration,值设置为0.1s。消费者每一次消费数据之后,我们要判断队列里面的音视频缓冲长度是否在min_bufferDuration刻度以下,如果在这个刻度以下,就发送Signal指令,让生产者线程继续生产数据。
bool isBufferedDurationDecreasedToMin = bufferedDuration <= minBufferedDuration;
if (isBufferedDurationDecreasedToMin && !isDecodingFrames) {
int getLockCode = pthread_mutex_lock(&videoDecoderLock);
pthread_cond_signal(&videoDecoderCondition);
pthread_mutex_unlock(&videoDecoderLock);
}
当生产线程收到Signal指令之后,就会进入下一轮的解码。伴随着生产者线程和消费者线程的协同工作,整个视频播放器也就运行起来了。
还需要注意的一点就是,在销毁这个模块的时候,需要先把isOnDecoding这个变量设置为false,然后再发送一次Signal指令,让解码线程有机会结束。否则,解码线程就有可能一直在这里等待,成为一个僵尸线程。
到这里,解码线程的工作模式和运行原理我们就学完了,下面我们来看AVSync模块的第二部分——音视频同步的设计逻辑。
音视频同步
音视频同步的策略一般分为三种:音频向视频同步、视频向音频同步,以及音频视频统一向外部时钟同步,在使用ffplay播放视频文件的时候,所指定的对齐方式就是上面所说的三种方式,下面我们来逐一分析一下,这三种对齐方式是如何实现的,以及各自的优缺点。
- 音频向视频同步
音频向视频同步,顾名思义,就是视频会维持一定的刷新频率,或者说根据渲染视频帧的时长来决定当前视频帧的渲染时长。而当我们向AudioOutput模块填充音频数据的时候,会和当前渲染的视频帧的时间戳进行比较。这个差值如果在阈值范围之内,就可以直接将这一帧音频帧填充给AudioOutput模块,让用户听到这个声音,如果不在阈值范围内,就需要做对齐操作。
如何做对齐操作呢?这就需要我们去调整音频帧了,也就是说,如果要填充的音频帧的时间戳比当前渲染的视频帧的时间戳小,那就需要跳帧操作,具体的跳帧操作可以是加快播放速度,也可以是丢弃掉一部分音频帧的实现。
如果音频帧的时间戳比当前渲染的视频帧的时间戳大,那么就需要等待,具体实现有两种,我们可以将音频的速度放慢,播放给用户听,也可以填充空数据给AudioOutput模块,进行播放。一旦视频的时间戳赶上了音频的时间戳,就可以将本帧音频帧的数据填充给AudioOutput模块了。
这种实现的优点就是视频可以每一帧都播放给用户看,画面可以说是最流畅的。但音频就会有丢帧或者插入静音帧的情况。所以这种对齐方式也会有一个比较大的缺点,就是音频有可能会加速或者跳变,也有可能会有静音数据或慢速播放。如果使用变速手段来实现,并且变速系数不太大的话,用户感知可能不太强,但是如果使用丢帧或者插入空数据来实现,用户的耳朵是可以明显感觉到卡顿的。
- 视频向音频同步
由于不论是哪个平台播放音频的引擎,都可以保证播放音频是线性的,即播放音频的时间长度与实际这段音频所代表的时间长度是一致的。由于音频线性渲染这一特性,当客户端代码跟我们要视频帧的时候,就会先计算出当前视频队列头部视频帧元素和当前音频播放帧时间戳的差值。
如果在阈值范围之内,就可以渲染这一帧视频帧;如果不在阈值范围之内的话就要进行对齐操作。如果当前队列头部视频帧的时间戳小于当前播放音频帧的时间戳的话,就进行跳帧操作;如果大于当前播放音频帧的时间戳,就等待一会儿(重复渲染上一帧或者不进行重复渲染)。这种对齐方式的优点是音频可以连续地播放,缺点就是视频有可能会跳帧,也有可能会重复播放,不会那么流畅。但是用户的眼睛是不太容易分辨轻微的丢帧和跳帧现象的。
- 统一向外部时钟同步
这种策略其实更像是上述两种对齐方式的合体,实现就是单独在外部维护一轨时钟,我们要保证这个外部时钟的更新是按照时间慢慢增加的,而我们获取音频数据和视频帧的时候,都要和这个外部时钟对齐。如果没有超过阈值,那么就直接渲染,如果超过阈值了我们就要进行对齐操作。
具体的对齐操作就是,使用上述两种方式里的对齐操作,用这些方式分别对齐音频和视频。优点是可以最大程度地保证音视频都不跳帧,缺点是如果控制不好外部时钟,极有可能出现音频和视频都跳帧的情况。
有研究表明,人的耳朵比眼睛要敏感得多,也就是说,一旦音频跳帧或者填充空数据,我们的耳朵是十分容易察觉到的,而视频有跳帧或者重复渲染的行为,我们的眼睛其实不太容易分辨出来,所以我们实现的播放器中就选用视频向音频对齐的方式。到代码实现阶段,音视频同步这部分的逻辑放到获取视频帧的方法里面就可以了。
到这里我们一起回忆一下,我们使用AVSync模块将解码模块包装了起来,而音频与视频渲染两个模块也已经准备好了,接下来我们需要书写一个中控模块,将这三个模块有机地串联起来,让我们的播放器可以跑起来。
中控模块
中控模块就是架构类图中VideoPlayerController这个类,这个类就是将上面提到的各个模块有序地组织起来,让单独运行的各个模块可以协同配合起来。由于每个模块都有各自的线程在运行,所以这个类需要维护好各个模块的生命周期,否则容易产生多线程的问题。我会将这个类拆分为初始化、运行、销毁三个阶段给你讲解。
初始化阶段
虽然我们的项目叫做“视频”播放器,但即使客户端代码没提供渲染的View,播放器也应该能够播放出声音来,可以单独播放音频。在某些场景下是一个比较有用的功能,比如可以加速秒开,可以做一些画中画的功能。要想达到这样的目标,我们需要把播放器的初始化和渲染界面的初始化分开。所以,初始化阶段可以分为两部分,一部分是播放器的初始化,另外一部分是渲染界面的初始化。
首先我们来看播放器的初始化,因为在初始化的过程中需要和资源建立连接,并执行I/O操作,所以这里必须要开辟一个线程来做初始化操作,即利用PThread创建一个InitThread来执行。在这个线程中,先实例化AVSynchronizer这个对象,然后调用这个对象的init方法,来建立和媒体资源的连接通道。
如果打开连接失败,那么回调客户端打开资源失败;如果打开连接成功,就拿出音频流信息(Channel、SampleRate、SampleFormat)来初始化AudioOutput,并把这个对象注册给AudioOutput,用来提供音频数据。AudioOutput初始化成功后,就直接调用AVSync模块的start方法,以及AudioOutput的播放方法,并且将初始化成功回调给客户端,至此我们的播放器就可以正常地播放音频了。
接下来是渲染界面初始化的阶段,如果业务层在某个时机可以显示视频的画面了,那么就让Surface(Texture)View显示,按照Surface(Texture)View的生命周期,调用端会拿着Surface调用到JNI层,JNI层会把Surface构建成ANativeWindow,然后调用中控系统的initVideoOutput方法。这个方法内部用传递进来的ANativeWindow对象和界面的宽、高以及获取视频帧的回调函数来初始化VideoOutput对象。
通过以上两个步骤,中控系统就将AVSync、AudioOutput、VideoOutput连接起来了,接下来是运行阶段。
运行阶段
运行阶段我们也分为两个步骤,一是音频的渲染,二是视频的渲染,先来看音频渲染。由于在初始化阶段,我们已经通过调用AudioOutput的start方法开启了音频输出模块,所以当AudioOutput模块将自己缓冲区里面的音频数据播放完毕之后,就会立马通过回调方法让我们的中控模块填充音频数据,那中控模块填充音频数据的方法是如何实现的呢?
这次我们从结果出发来看这个方法的实现。
- 填充静音数据,当以下情况出现的时候,要填充静音数据给AudioOutput进行播放。
- 播放器状态是暂停状态;
- AVSync中的音频队列已经空了;
- AVSync已经被销毁了或者解码完毕了。
- 填充真实数据,当以上情况都不满足的情况下,就去AVSync模块中获取音频数据,等填充了音频数据之后,要做两件事情。
- 更新当前播放的时间戳,用于做后续的音画同步;
- 给VideoOutput(视频输出模块)发送一个指令,让VideoOutput模块更新视频帧。
VideoOutput模块接收到这个指令之后,就可以调用回调方法来获取一帧视频帧,由于在初始化的时候,已经把中控系统注册给VideoOutput用来获取视频帧了,所以这里会调用中控模块获取视频帧的方法。这个方法会调用AVSync模块,来获取一个与音频匹配的视频帧,然后返回给视频播放模块,将最新的一帧视频帧更新到画面中。
运行阶段还有暂停和继续播放接口的实现,由于当前实现整个播放器是由音频播放模块来驱动的,所以只需要让音频播放模块暂停和继续就好了。
经过学习运行阶段的内容,我们知道整个视频播放的过程其实是由音频来驱动的,所以在销毁阶段要先停掉音频。首先调用AudioOutput对象的stop方法;然后要停掉AVSync模块,由于这个模块内部组合了输入模块,所以要把输入模块的连接给断开,输入模块中利用FFmpeg的超时设置可以快速断开连接,然后需要使用pthread_join这个排程的方法,等待解码线程运行结束,再把音频队列、视频队列以及解码器都给销毁掉。
最后一步是停止VideoOutput模块,通过调用VideoOutput的销毁资源方法(里面会销毁frameBuffer、renderbuffer、Program等)来实现,最后再调用音频输出模块的销毁方法。这样就可以销毁所有的模块了。
小结
视频播放器项目我们已经实现完了,因为这个实战项目比较大,所以我们一起来回顾一下整个设计与开发阶段。我们从第7节课开始,一步步设计并实现这个播放器。
- 首先实现了输入模块,也叫做解码模块,输出的音频帧是AudioFrame,里面的主要数据就是PCM裸数据,输出的视频帧是VideoFrame,里面的主要数据就是YUV420P的裸数据。
- 然后实现了音频播放模块,输入就是我们解码出来的AudioFrame,是SInt16表示一个sample格式的数据,输出就是输出到Speaker让用户直接听到声音。
- 接着实现了视频播放模块,输入就是解码出来的VideoFrame,里面存放的是YUV420P格式的数据,在渲染过程中使用OpenGL ES的Program将YUV格式的数据转换为RGBA格式的数据,并最终显示到物理屏幕上。
- 之后就是音视频同步模块了,它的工作主要由两部分组成,第一是负责维护解码线程,即负责输入模块的管理;另外一个就是音视频同步,向外部暴露填充音频数据的接口和获取视频帧的接口,保证提供出去的数据是同步的。
最后书写一个中控系统,负责将AVSync模块、AudioOutput模块、VideoOutput模块组织起来,最重要的就是维护这几个模块的生命周期,由于这里面存在多线程的问题,所以比较重要的就是在初始化、运行、销毁的各个阶段保证这几个模块可以协同有序地运行,同时中控系统向外暴露用户可以操作的接口,比如开始播放、暂停、继续、停止等接口。
整个播放器项目比较复杂,现在再回看第7节课的场景分析和架构设计,你是不是有了更清晰、更深刻的理解呢?
思考题
在这节课的实践中,VideoOutput渲染视频帧是用AudioOutput的运行来驱动的,但是每个平台渲染音频帧的间隔是不同的,如果遇到渲染音频帧间隔较大的设备,那么视频帧渲染就会出现掉帧的情况,那应该如何解决呢?
欢迎你把你的答案留在评论区,和我一起讨论,也欢迎你把这节课分享给需要的朋友,我们下节课再见!
- 小跑猫 👍(1) 💬(1)
老师,用于音视频同步的时间戳是怎么来的,比如视频和音频分别进行编码格式封装,那这俩的时间戳是在编码的时候写入的么,如果是话编码过程中如何保证时间戳的同步。
2022-08-28 - keepgoing 👍(0) 💬(1)
老师蓝牙耳机的问题我想可以通过操作更细节的音视频同步方案解决,但是还没有特别明确思路 老师能不能给一些解答的思路,感谢!
2022-12-12 - 一个正直的小龙猫 👍(0) 💬(1)
老师的播放器 支持dash协议么?
2022-08-17 - peter 👍(0) 💬(2)
请教老师一个问题: Q1:“混音”的技术方案。 老师,我目前在写技术文档,安卓APP需要实现“混音”、“变速”、“变调”功能,主要是“混音”功能。“混音”,即在一个音频中插入一段音频。 比如一个音频文件A的长度是5分钟,插入一个30秒的音频片段B。可以在A的任意位置插入,即插入起点是任意的。插入后在叠加部分两个声音会同时播放。 根据前面的学习和老师解答,我的理解是:1 不能用ffmpeg,ffmpeg能用在安卓平台但它不支持混音功能;2 基于安卓自身的音频组件来实现“混音”功能; 3 安卓自身有五种方法:SDK层有MediaPlayer、SoundPool 和 AudioTrack三种方法。Native层有OpenSL ES、AAudio两种方法。 4 SDK中的MediaPlayer、SoundPool 、AudioTrack无法完成该功能;用Native层的OpenSL ES、AAudio。 技术方案总结起来就是:基于安卓自身Native层的OpenSL ES或AAudio,进行一定的编程,即可实现。(OpenSL ES或AAudio怎么使用,需要查阅文档)。 我的理解是否对?
2022-08-12 - 我的無力雙臂 👍(0) 💬(2)
demo能否分享一下
2022-08-12