第五章 实现一款视频播放器
一 架构设计
- 输入模块
待视频流和音频流都解码为裸数据
之后,需要为音视频各自建立一个队列
将裸数据存储起来,不过,如果是在需要播放一帧的时候再去做解码,那么这一帧的视频就有可能产生卡顿或者延迟,所以这里引出了第一个线程
,即为播放器的后台解码
分配一个线程,该线程用于解析协议,处理解封装以及解码,并最终将裸数据放到音频和视频的队列中,这个模块称为输入模块
。
- 输出模块
输出部分其实是由两部分组成的,一部分是音频
的输出,另一部分是视频
的输出。
- 音视频同步模块
所以需要再建立一个模块来负责音视频同步
的工作,这个模块称为音视频同步模块。
- 调度器
先把输入模块
、音频队列
、视频队列
都封装到音视频同步模块
中,然后为外界提供获取音频数据、视频数据的接口,这两个接口必须保证音视频的同步
,内部将负责解码线程的运行与暂停的维护。
然后把音视频同步模块
、音频输出模块
、视频输出模块
都封装到调度器中,调度器模块会分别向音频输出模块和视频输出模块注册回调函数,回调函数允许两个输出模块获取音频数据和视频数据。
1.1 详细介绍
image.png image.png-
VideoPlayerController
调度器,内部维护音视频同步模块、音频输出模块、视频输出模块,为客户端代码提供开始播放、暂停、继续播 放、停止播放接口;为音频输出模块和视频输出模块提供两个获取数据的接口。 -
AudioOutput
音频输出模块,由于在不同平台上有不同的实现, 所以这里真正的声音渲染API为Void
类型,但是音频的渲染要放在一个单独
的线程
中进行。 -
VideoOutput
视频输出模块,虽然这里统一使用OpenGL ES
来渲染视频,必须 由我们主动开启一个线程
来作为OpenGL ES
的渲染线程。 -
AVSynchronizer
音视频同步模块,会组合输入模 块、音频队列和视频队列,其主要为它的客户端代码VideoPlayerController
调度器提供接口,包括:开始
、结束
,以及最重要的获取音频数据和获取对应时间戳的视频帧
。此外,它还会维护一个解码线程
,并且根据音视频队列里面的元素数目来继续
或者暂停
该解码线程的运行。 -
AudioFrame
音频帧,其中记录了音频的数据格式以及这一帧的具体数据、时间戳等信息。 -
AudioFrameQueue
音频队列,主要用于存储音频帧,为它的客户 端代码音视频同步模块
提供压入和弹出操作。由于解码线程和声音播放线程会作为生产者
和消费者
同时访问该队列中的元素,所以该队列要保证线程安全性
。 -
VideoFrame
视频帧,记录了视频的格式以及这一帧的具体的数据、宽、高以及时间戳等信息。 -
VideoFrameQueue
视频队列,主要用于存储视频帧,为它的客户端代码音视频同步模块
提供压入和弹出操作,由于解码线程和视频播放线程会作为生产者
和消费者
同时访问该队列中的元素,所以该队列要保证线程安全性
。 -
VideoDecoder
输入模块,个是协议层解析器,一个是格式解封装器,一个是解码器,并且它主要向AVSynchronizer
提供接口,打开文件资源(网络或者本 地)、关闭文件资源、解码出一定时间长度的音视频帧。
1.2 具体实现
- 输入模块
- 选择
FFmpeg
开源库的libavformat
模块来处理各种不同的协议以及不同的封装格式。 - 使用
FFmpeg
的libavcodec
模块作为解码器模块的技术选型。
- 音频输出模块
- 对于iOS平台,其实也有很多种方式,比较常见的就是
AudioQueue
和AudioUnit
。
- 视频输出模块
技术选型肯定是选择OpenGL ES
,在iOS平台上使用EAGL
来为OpenGL ES
提供上下文环境,自己定义一个View继承自UIView
,使用EAGLLayer
作为渲染对象,并最终渲染到这个自定义的View
上。
- 音视频同步模块
- 使用
pthread
维护解码线程 - 对于音视频队列,我们可以自行编写一个保证线程安全的
链表
来实现。 - 采用
视频向音频对齐
的策略,即只需要把同步这块逻辑放到获取视频帧
的方法里面就好了。
- 控制器
需要将上述的三个模块合理地组装起来。
二 解码模块的实现
直接使用FFmpeg
开源库来负责输入模块的协议解析、封装格式拆分、 解码操作等行为,整体流程如图所示。
整个运行流程分为以下几个阶段:
- 建立连接、准备资源阶段。
- 不断读取数据进行解封装、解码、处理数据阶段。
- 释放资源阶段。
注意点:
- 对于每个流都要分配一个
AVFrame
作为解码之后数据存放的结构体。 - 对于音频流,需要额外分配一个重采样的上下文,对解码之后的音频格式进行
重采样
, 使其成为我们需要的PCM
格式。 -
decodeFrames
接口的实现,该接口主要负责解码音视频压缩数据
成为原始格式
,并且封装成为自定义的结构体
,最终全部放到一个数组中,然后返回给调用端。 - 对应于
FFmpeg
里面的AVPacket
结构体,对于视频帧,一个AVPacket
就是一帧视频帧
;对于音频帧
,一个AVPacket
有可能包含多个音频帧
。 -
解码
之后,需要封装成自定义的结构体的AudioFrame
和VideoFrame
。 - 对于音频的格式转换,
FFmpeg
提供了一个libswresample
库。 - 对于视频帧的格式转换,
FFmpeg
提供了一个libswscale
的库,用于 转换视频的裸数据的表示格式。
三 音频播放模块的实现
在iOS平台,可使用AudioUnit
(AUGraph封装的实际上就是 AudioUnit)来渲染音频。
构造AUGraph
,用来实现音频播放
,应配置一个ConvertNode
将客户端代码填充的SInt16
格式的音频
数据转换为RemoteIONode
可以播放的Float32
格式的音频数据(采样率、声道数以及表示格式应对应上)。
四 画面播放模块的实现
无论是在哪一个平台上使用OpenGL ES
渲染视频的画面,都需要单独开辟一个线程
,并且为该线程绑定一个OpenGL ES
的上下文。
-
首先会书写一个
VideoOutput
类继承自UIView
,然后重写父类的layerClass
方法,并且返回CAEAGLLayer
类型,重写该方法的目的是 该UIView
可以被OpenGL ES
进行渲染;然后在初始化方法中,将OpenGL ES
绑定到Layer
上。 -
iOS平台上的线程模型,采用
NSOperationQueue
来实现。 -
iOS平台有一个比较
特殊
的地方就是如果App进入后台之后,就不能
再进行OpenGL ES
的渲染操作。
4.1 接下来看一下初始化方法的实现,首先为layer
设置属性,然后初始化NSOperation-Queue
,并且将OpenGL ES
的上下文构建以及OpenGL ES
的渲染Program
的构建作为一个Block
(可以理解为一个代码块)直接加入到该Queue
中。
4.2 该Block
中的具体行为如下:先分配一个EAGLContext
,然后为该NSOperationQueue
线程绑定OpenGL ES上下文
,接着再创建FrameBuffer
和RenderBuffer
,
4.3 将RenderBuffer
的storage
设置为UIView
的layer
(就是前面提到的CAEAGLLayer
),然后再将FrameBuffer
和RenderBuffer
绑定起来,这样绘制在FrameBuffer
上的内容就相当于绘制到了RenderBuffer
上,最后使用前面提到的VertexShader
和FragmentShader
构造出实际的渲染Program
,至此,初始化就完成了。
5.1 然后是关键的渲染方法
,这里先判断当前OperationQueue
中 operationCount
的值,如果其数目大于我们规定的阈值
(一般设置为2或者3),则说明每一次绘制所花费的时间都比较多,这将导致很多绘制的延迟,所以可以删除掉最久的绘制操作,仅仅保留等于阈值个数的绘制操作。
5.2 首先判定布尔型变量enableOpenGLRendererFlag
的值,如果是YES
,就绑定FrameBuffer
,然 后使用Program
进行绘制,最后绑定RenderBuffer
并且调用EAGLContext
的PresentRenderBuffer
将刚刚绘制的内容显示到layer
上去,因为layer
就是UIView
的layer
,所以能够在UIView
中看到我们刚刚绘制的内容了。
-
至于
销毁
方法,也要保证这步操作是放在OperationQueue
中执行 的,因为涉及OpenGL ES
的所有操作都要放到绑定了上下文环境的线程中去操作。 -
对于
UIView
的dealloc
方法,其功能主要是负责回收所有的资源,首先移除所有的监听事件,然后清空OperationQueue
中未执行的操作,最后释放掉所有的资源。
五 AVSync模块的实现
AVSynchronizer
类的实现,第一部分是维护解码线程
,第二部分就是音视频同步
。
5.1 维护解码线程
AVSync
模块开辟的解码线程
扮演了生产者
的角色,其生产出来的 数据所存放的位置就是音频队列
和视频队列
,而AVSync
模块对外提供的填充音频数据和获取视频的方法则扮演了消费者
的角色,从音视频队列中获取数据,其实这就是标准的生产者消费者模型
。
在最后销毁该模块的时候,需要先将isOnDecoding
变量设置为false
,然后还需要额外发送一次signal
指令,让解码线程
有机会结束,如果不发送该signal指令,那么解码线程就有可能一直wait
在这里,成为一个僵尸线程。
5.2 音视频同步
音频向视频同步
音频向视频同步,顾名思义,就是视频会维持一定的刷新频率,或者根据渲染视频帧的时长来决定当前视频帧的渲染时长,或者说视频的每一帧肯定可以全都渲染出来。
向AudioOutput
模块填充音频数据
的时候,会与当前渲染的视频帧
的时间戳进行比较。
- 在阈值范围内,直接填充数据播放
- 音频帧比视频帧小,跳帧(加快音频播放速度,或丢弃音频帧)
- 音频帧比视频帧大,等待(放慢音频播放速度或填充空数据静音帧)
优点
画面看上去是最流畅的
缺点
音频有可能会加速 (或者跳变)也有可能会有静音数据(或者慢速播放),发生丢帧或者插入空数据的时候,用户的耳朵 是可以明显感觉到的。
视频向音频同步
不论是哪一个平台播放音频的引擎,都可以保证播放音频的时间长度与实际这段音频所代表的时间长度是一致的。
- 视频帧比音频帧小,跳帧
- 视频帧比音频帧大,等待(重复渲染上一帧或者不进行渲染)
优点
音频可以连续播放
缺点
视频画面有可能会有跳帧的操作,但是对于视频画面的丢帧和跳帧,用户的眼睛是不太容易分辨得出来的。
统一向外部时钟同步
在外部单独维护一轨外部时钟
,当我们获取音频数据
和视频帧
的时候,都需要与这个外部时钟
进行对齐,如果没有超过阈值
,那么就直接返回本帧音频帧或者视频帧,如果超过了阈值就要进行对齐操作。
得出了一个理论,那就是人的耳朵比人的眼睛要敏感得多,我们所实现的播放器将采用音视频
对齐策略的第二种方式,即视频
向音频
对齐的方式。
六 中控系统串联各个模块
6.1 初始化阶段
调用AVSync
模块放在一个异步线程中来打开连接会更加合理,所以这里使用GCD
线程模型,将初始化的操作放在一个DispatchQueue
中。首先也是调用AVSync
模块的openFile
方法,如果可以打开媒体资源连接,则继续初始化VideoOutput
对象。
6.2 运行阶段
就是为AudioOutput
模块填充数据,并且通知VideoOutput
模块来更新画面。
6.3 销毁阶段
- 由于音视频对齐策略的影响,整个播放过程其实是由
音频
来驱动的,所以在销毁阶段肯定需要首先停止音频
。 - 然后停止
AVSync
模块。 - 最后一步应该是停止
VideoOutput
模块。 - 最终再将
VideoOutput
这个自定义的view
从ViewController
中移除,至此销毁阶段就实现完毕了。
七 总结
-
输入模块
(或者称为解码模块),输出音频帧是AudioFrame
,其中的主要数据是PCM
裸数据;输出视频帧是VideoFrame
,其中的主要数据是YUV420P
的裸数据。 -
音频播放模块
,输入是解码出来的AudioFrame
,直接就是SInt16
表示的sample
格式的数据,输出则是输出到Speaker
用户能够直接听到声音。 -
视频播放模块
,输入是解码出来的VideoFrame
,其中存放的是YUV420P
格式的数据,在渲染过程中可使用OpenGL ES
的Program
将YUV
格式的数据转换为RGBA
格式的数据,并最终显示到物理屏幕上。 -
音视频同步模块
,它的工作主要由两部分组成;第一部分是负责维护解码线程
,即负责输入模块的管理;另外一部分是音视频同步
,可向外部提供填充音频数据的接口和获取视频帧的接口,以保证所提供的数据是同步的。 -
中控系统
,负责将AVSync模块
、AudioOutput模块
、VideoOutput模块
组织起来,最重要的就是维护这几个模块的生命周 期,由于其中存在多线程的问题,所以需要重点注意的是,应在初始 化、运行、销毁各个阶段保证这几个模块可以协同有序地运行,同时中 控系统应对外提供用户可以操作的接口,比如开始播放、暂停、继续、 停止等接口。
本文参考音视频开发进阶指南