第五章 实现一款视频播放器

2021-02-03  本文已影响0人  路飞_Luck
一 架构设计

待视频流和音频流都解码为裸数据之后,需要为音视频各自建立一个队列将裸数据存储起来,不过,如果是在需要播放一帧的时候再去做解码,那么这一帧的视频就有可能产生卡顿或者延迟,所以这里引出了第一个线程,即为播放器的后台解码分配一个线程,该线程用于解析协议,处理解封装以及解码,并最终将裸数据放到音频和视频的队列中,这个模块称为输入模块

输出部分其实是由两部分组成的,一部分是音频的输出,另一部分是视频的输出。

所以需要再建立一个模块来负责音视频同步的工作,这个模块称为音视频同步模块。

先把输入模块音频队列视频队列都封装到音视频同步模块中,然后为外界提供获取音频数据、视频数据的接口,这两个接口必须保证音视频的同步,内部将负责解码线程的运行与暂停的维护。

然后把音视频同步模块音频输出模块视频输出模块都封装到调度器中,调度器模块会分别向音频输出模块和视频输出模块注册回调函数,回调函数允许两个输出模块获取音频数据和视频数据。

1.1 详细介绍
image.png image.png
1.2 具体实现
  1. 选择FFmpeg开源库的libavformat模块来处理各种不同的协议以及不同的封装格式。
  2. 使用FFmpeglibavcodec模块作为解码器模块的技术选型。
  1. 对于iOS平台,其实也有很多种方式,比较常见的就是AudioQueueAudioUnit

技术选型肯定是选择OpenGL ES,在iOS平台上使用EAGL来为OpenGL ES提供上下文环境,自己定义一个View继承自UIView,使用EAGLLayer作为渲染对象,并最终渲染到这个自定义的View上。

  1. 使用pthread维护解码线程
  2. 对于音视频队列,我们可以自行编写一个保证线程安全的链表来实现。
  3. 采用视频向音频对齐的策略,即只需要把同步这块逻辑放到获取视频帧的方法里面就好了。

需要将上述的三个模块合理地组装起来。

二 解码模块的实现

直接使用FFmpeg开源库来负责输入模块的协议解析、封装格式拆分、 解码操作等行为,整体流程如图所示。

image.png

整个运行流程分为以下几个阶段:

  1. 建立连接、准备资源阶段。
  2. 不断读取数据进行解封装、解码、处理数据阶段。
  3. 释放资源阶段。

注意点:

  1. 对于每个流都要分配一个AVFrame作为解码之后数据存放的结构体。
  2. 对于音频流,需要额外分配一个重采样的上下文,对解码之后的音频格式进行重采样, 使其成为我们需要的PCM格式。
  3. decodeFrames接口的实现,该接口主要负责解码音视频压缩数据成为原始格式,并且封装成为自定义的结构体,最终全部放到一个数组中,然后返回给调用端。
  4. 对应于FFmpeg里面的AVPacket结构体,对于视频帧,一个AVPacket就是一帧视频帧;对于音频帧,一个AVPacket有可能包含多个音频帧
  5. 解码之后,需要封装成自定义的结构体的AudioFrameVideoFrame
  6. 对于音频的格式转换,FFmpeg提供了一个libswresample库。
  7. 对于视频帧的格式转换,FFmpeg提供了一个libswscale的库,用于 转换视频的裸数据的表示格式。
三 音频播放模块的实现

在iOS平台,可使用AudioUnit(AUGraph封装的实际上就是 AudioUnit)来渲染音频。

构造AUGraph,用来实现音频播放,应配置一个ConvertNode将客户端代码填充的SInt16格式的音频数据转换为RemoteIONode可以播放的Float32格式的音频数据(采样率、声道数以及表示格式应对应上)。

image.png
四 画面播放模块的实现

无论是在哪一个平台上使用OpenGL ES渲染视频的画面,都需要单独开辟一个线程,并且为该线程绑定一个OpenGL ES的上下文。

  1. 首先会书写一个VideoOutput类继承自UIView,然后重写父类的layerClass方法,并且返回CAEAGLLayer类型,重写该方法的目的是 该UIView可以被OpenGL ES进行渲染;然后在初始化方法中,将 OpenGL ES绑定到Layer上。

  2. iOS平台上的线程模型,采用NSOperationQueue来实现。

  3. iOS平台有一个比较特殊的地方就是如果App进入后台之后,就不能再进行OpenGL ES的渲染操作。

4.1 接下来看一下初始化方法的实现,首先为layer设置属性,然后初始化NSOperation-Queue,并且将OpenGL ES的上下文构建以及OpenGL ES的渲染Program的构建作为一个Block(可以理解为一个代码块)直接加入到该Queue中。

4.2 该Block中的具体行为如下:先分配一个EAGLContext,然后为该NSOperationQueue线程绑定OpenGL ES上下文,接着再创建FrameBufferRenderBuffer

4.3 将RenderBufferstorage设置为UIViewlayer(就是前面提到的CAEAGLLayer),然后再将FrameBufferRenderBuffer绑定起来,这样绘制在FrameBuffer上的内容就相当于绘制到了RenderBuffer上,最后使用前面提到的VertexShaderFragmentShader构造出实际的渲染Program,至此,初始化就完成了。

5.1 然后是关键的渲染方法,这里先判断当前OperationQueueoperationCount的值,如果其数目大于我们规定的阈值(一般设置为2或者3),则说明每一次绘制所花费的时间都比较多,这将导致很多绘制的延迟,所以可以删除掉最久的绘制操作,仅仅保留等于阈值个数的绘制操作。

5.2 首先判定布尔型变量enableOpenGLRendererFlag的值,如果是YES,就绑定FrameBuffer,然 后使用Program进行绘制,最后绑定RenderBuffer并且调用EAGLContextPresentRenderBuffer将刚刚绘制的内容显示到layer上去,因为layer就是UIViewlayer,所以能够在UIView中看到我们刚刚绘制的内容了。

  1. 至于销毁方法,也要保证这步操作是放在OperationQueue中执行 的,因为涉及OpenGL ES的所有操作都要放到绑定了上下文环境的线程中去操作。

  2. 对于UIViewdealloc方法,其功能主要是负责回收所有的资源,首先移除所有的监听事件,然后清空OperationQueue中未执行的操作,最后释放掉所有的资源。

五 AVSync模块的实现

AVSynchronizer类的实现,第一部分是维护解码线程,第二部分就是音视频同步

5.1 维护解码线程

AVSync模块开辟的解码线程扮演了生产者的角色,其生产出来的 数据所存放的位置就是音频队列视频队列,而AVSync模块对外提供的填充音频数据和获取视频的方法则扮演了消费者的角色,从音视频队列中获取数据,其实这就是标准的生产者消费者模型

在最后销毁该模块的时候,需要先将isOnDecoding变量设置为false,然后还需要额外发送一次signal指令,让解码线程有机会结束,如果不发送该signal指令,那么解码线程就有可能一直wait在这里,成为一个僵尸线程。

5.2 音视频同步

音频向视频同步,顾名思义,就是视频会维持一定的刷新频率,或者根据渲染视频帧的时长来决定当前视频帧的渲染时长,或者说视频的每一帧肯定可以全都渲染出来。

AudioOutput模块填充音频数据的时候,会与当前渲染的视频帧的时间戳进行比较。

  1. 在阈值范围内,直接填充数据播放
  2. 音频帧比视频帧小,跳帧(加快音频播放速度,或丢弃音频帧)
  3. 音频帧比视频帧大,等待(放慢音频播放速度或填充空数据静音帧)

优点 画面看上去是最流畅的
缺点 音频有可能会加速 (或者跳变)也有可能会有静音数据(或者慢速播放),发生丢帧或者插入空数据的时候,用户的耳朵 是可以明显感觉到的。

不论是哪一个平台播放音频的引擎,都可以保证播放音频的时间长度与实际这段音频所代表的时间长度是一致的。

  1. 视频帧比音频帧小,跳帧
  2. 视频帧比音频帧大,等待(重复渲染上一帧或者不进行渲染)

优点 音频可以连续播放
缺点 视频画面有可能会有跳帧的操作,但是对于视频画面的丢帧和跳帧,用户的眼睛是不太容易分辨得出来的。

在外部单独维护一轨外部时钟,当我们获取音频数据视频帧的时候,都需要与这个外部时钟进行对齐,如果没有超过阈值,那么就直接返回本帧音频帧或者视频帧,如果超过了阈值就要进行对齐操作。

得出了一个理论,那就是人的耳朵比人的眼睛要敏感得多,我们所实现的播放器将采用音视频对齐策略的第二种方式,即视频音频对齐的方式。

六 中控系统串联各个模块
6.1 初始化阶段

调用AVSync模块放在一个异步线程中来打开连接会更加合理,所以这里使用GCD线程模型,将初始化的操作放在一个DispatchQueue中。首先也是调用AVSync模块的openFile方法,如果可以打开媒体资源连接,则继续初始化VideoOutput对象。

6.2 运行阶段

就是为AudioOutput模块填充数据,并且通知VideoOutput模块来更新画面。

6.3 销毁阶段
  1. 由于音视频对齐策略的影响,整个播放过程其实是由音频来驱动的,所以在销毁阶段肯定需要首先停止音频
  2. 然后停止AVSync模块。
  3. 最后一步应该是停止VideoOutput模块。
  4. 最终再将VideoOutput这个自定义的viewViewController中移除,至此销毁阶段就实现完毕了。
七 总结

本文参考音视频开发进阶指南


上一篇下一篇

猜你喜欢

热点阅读