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

前3章讨论了许多音视频相关的知识,包括音视频的基本概念,如何搭建移动平台下的开发环境,并学习了ffmpeg以及使用ffmpeg解码的方法,第4章又学习了如何将音视频的裸数据渲染到硬件设备上。所以到现在其实我们完全可以做一个实际的大项目——视频播放器了,这个项目可以把之前学的知识串联起来,并且可以学习到多线程控制、音视频同步等一些知识,那么具体要实现一款视频播放器需要开发者做哪一些工作呢,笔者会利用本章的内容带领大家一步一步实现出来。

5.1架构设计

先来看一下播放器要提供给用户哪一些功能:要可以从零开始播放(当然要保证音画对齐);支持暂停和继续播放功能;支持seek功能(就是可以随意拖动到任意位置仍然可以继续播放),也有播放器支持快进、快退15s。那么下面先来实现最基本的功能,即播放器可以从头自己播放以及暂停和继续的功能。

首先来思考一下我们要实现的场景,播放器可以从零开始自己播放直到结束。如果直接抛出这样一个项目,我们很容易找不到任何头绪,但是作为一个开发人员,要做的事情就是把复杂的问题简单化,简单的问题条理化,最终按照拆分得非常细的模块来逐个实现。基于这个项目,我们需要问自己以下几个问题:

  • 输入是什么?
  • 输出是什么?
  • 可以划分为几个模块?
  • 每个模块的职责是什么?

下面来一个问题一个问题的梳理,首先要搞清楚输入是什么,可以是本地磁盘上的一个媒体文件(可能是flv、mp4、avi、mov等格式的文件),也可以是网络上的一个媒体文件(可能是http、rtmp、hls等协议的),这就是我们确定的输入;那输出是什么呢?输出就是可以把视频中的音频放到扬声器让用户的耳朵可以听到声音,把视频画面渲染到屏幕上让用户的眼睛可以看到画面,同时听到的声音和画面也必须是同步的(也就是说不能让用户听到了一个‘你好’的发音,却看到了‘吃了’的画面);然后我们根据输入和输出拆分模块,并给模块分配合理的职责。

对于输入部分分析如下,有可能是不同的协议,比如是file(本地磁盘的文件),或者是http、rtmp、hls协议等;也有可能是不同的封装格式,比如是mp4、flv、mov等封装格式;而对于这一些封装格式里面的内容,会有两路流,分别是音频流和视频流,我们需要将这两路流都解码为裸数据。待视频流和音频流都解码为裸数据之后,需要为音视频各自建立一个队列将裸数据存储起来,不过,如果在需要播放一帧的时候再去做解码,那这一帧视频就有可能产生卡顿或者延迟,所以这里引出了第一个线程,即为系统的后台解码做一个线程,这个线程用于解析协议,处理解封装以及解码,最终把裸数据放到我们音频和视频的队列中,这个模块称之为输入模块。

再看输出部分,输出部分其实由两部分组成,一部分是音频的输出,另一部分是视频的输出。不过可以确定的是,不论音频的输出还是视频的输出,它们都会用一个线程来管理,这两个模块应该先会去队列中拿出音视频的裸数据,然后分别进行音视频的渲染,最终发布到扬声器和屏幕上,让用户可以听得到、看得到,这两个模块称之为音频输出和视频输出模块。

再来思考一件事情,由于输出模块都在各自的一个线程中,音频和视频各自播放,这就导致了两个输出模块的播放频率以及线程控制没有任何关系,就不可以保证音画对齐的事情。我们规划的各个模块里,好像还没有一个模块的职责是负责音视频同步,所以需要再建立一个模块来负责相关工作,这个模块称之为音视频同步模块。

至此,已把模块都拆分完毕,具体的模块分布如图5-1所示。

图5-1

不过,我们应该还写一个调度器,将这几个模块组装起来。也就是说,先把输入模块、音频队列、视频队列都封装到音视频同步模块中,然后向外界暴露获取音频数据、视频数据的接口,这两个接口应该要保持同步,内部负责解码线程的运行与暂停的维护。然后把音视频同步模块、音频输出模块、视频输出模块封装到调度器中,调度器模块分别会向音频输出模块和视频输出模块注册回调函数,回调函数允许两个输出模块来获取音频数据和视频数据。这样就可以进一步整理一下类图设计了,如图5-2所示。

图5-2

对于图5-2,详细的解释如下。

  • VideoPlayerController:调度器,内部维护音视频同步模块、音频输出模块、视频输出模块,向客户端代码暴露接口:开始播放、暂停、继续播放、停止播放接口;向音频输出模块和视频输出模块暴露两个获取数据的接口;
  • AudioOutput:音频输出模块,由于在不同平台有不同的实现,所以这里真正的声音渲染API为Void类型,但是音频的渲染要放在单独的一个线程(不论是平台API自动提供的线程,还是我们主动建立的线程)中进行,所以这里有一个线程的变量,在运行过程中会调用注册过来的回调函数来获取音频数据;
  • VideoOutput:视频输出模块,虽然这里统一使用OpenGL
    ES来渲染视频,但是前面也讲过,OpenGL
    ES的具体实现在不同平台也会有自己的上下文环境,所以这里采用了Void类型的实现,当然,必须由我们主动开启一个线程来作为OpenGL
    ES的渲染线程,它会在运行过程中调用注册过来的回调函数获取视频数据;
  • AVSynchronizer:音视频同步模块,会组合后面将讲到的输入模块及音频队列和视频队列,这里面主要对它的客户端代码VideoPlayerController这个调度器提供接口,包括:开始、结束,以及最重要的获取音频数据和获取对应时间戳的视频帧。此外,它也会维护一个解码线程,并且根据音视频队列里面的元素数目来继续或者暂停这个解码线程的运行;
  • AudioFrame:音频帧,这里面记录了音频的数据格式以及这一帧的具体数据、时间戳等信息;
  • AudioFrameQueue:音频队列,主要用于存储音频帧,为它的客户端代码音视频同步模块提供压入和弹出操作,由于解码线程和声音播放线程会作为生产者和消费者同时访问这个队列中的元素,所以这个队列要保证线程安全性;
  • VideoFrame:视频帧,记录了视频的格式以及这一帧的具体的数据、宽、高以及时间戳等信息;
  • VideoFrameQueue:视频队列,主要用于存储视频帧,为它的客户端代码音视频同步模块提供压入和弹出操作,由于解码线程和视频播放线程会作为生产者和消费者同时访问这个队列中的元素,所以这个队列保证线程安全性;
  • VideoDecoder:输入模块,职责在前面已经分析了,由于还没有确定具体的技术实现,所以这里根据前面的分析写了三个实例变量,一个是协议层解析器,一个是格式接封装器,一个是解码器,并且它主要向AVSynchronizer暴露接口:打开文件资源(网络或者本地)、关闭文件资源、解码出一定时间长度的音视频帧。

至此我们根据用户场景(Case)把视频播放器拆解成了各个模块,并且根据模块的调用关系画出了类图,那么接下来要做的事情就应该是,具体来拆分每个模块的具体实现。

首先是输入模块,如果自己来写代码处理这一些不同的协议、不同的封装格式,以及不同的编解码格式(更专业的来讲是各种解码器),肯定非常复杂也极其不合理,因为这一套东西已经非常成熟,自己实现要付出很大的开发与测试成本,并且最终效果也不会太理想。而基于我们现在所掌握的知识,选择FFmpeg这个开源库的libavformat模块来处理各种不同的协议以及不同的封装格式。在解封装成每一路流之后,需要的就是解码的操作,当然,最简单的也可以直接使用FFmpeg的libavcodec模块来进行,但是如果需要更高性能的解码,开发者可以使用Android和iOS平台各自的硬件解码器。本章暂时先不考虑优化,只是先快速实现出一套方案,使用软件解码会是一种好的选择,所以本章使用FFmpeg的libavcodec模块来作为解码器模块的技术选型。

其实对于架构来说,没有最好的设计,只有最适合的设计。在这里,硬件解码器对于对于系统平台是有限制的,同时也会有一些兼容性问题,并且两个平台还需要分别去写代码实现各自的硬件解码器与将硬件解码器解码出来的数据转换为可用于显示的视频帧数据结构。而本章需要快速实现出来,所以就选择使用软件解码器,因为它有更高的兼容性及更简单的API调用,同时从兼容性方面考虑。以后可能需要硬件解码来提升性能,所以在设计解码模块的时候可以更多地使用面向接口的设计,以便日后更加方便地替换实现。

其次是音频输出模块,音频的输出其实有很多种方式,下面分别来分析下,首先是Android平台,常用的就是Java层的AudioTrack和Native层的OpenSL
ES。我们的主要代码肯定是在Native层,在AudioTrack和OpenSL
ES之间,应该选择OpenSL ES,因为这样子省去了JNI的数据传递,并且OpenSL
ES在播放声音方面的延迟更低,缺点是提供的API比起AudioTrack来讲不够友好,调试也不太方便,但是总体来衡量,还是选择OpenSL
ES更合适;对于iOS平台,其实也有很多种方式,比较常见的就是AudioQueue和AudioUnit,AudioQueue是更高层次的音频API,是建立在AudioUnit的基础之上的,所提供的API更加简单,在这里其实选用AudioQueue可能更加合适,但是我们最终还是会选用AudioUnit,对此,有以下几个原因:首先有可能存在音频格式的转换,这时audioUnit会更加方便,并且我们也要为后续的录音、音效处理打下使用AudioUnit的基础,所以这里直接选择AudioUnit作为实现。

然后是视频输出模块,对此,技术选型肯定是OpenGL
ES,因为我们可以利用它非常高效率的渲染视频,不论在Android还是iOS平台,前面也已经学习了如何在Android平台和iOS平台搭建OpenGL
ES的环境。此外,在这里使用OpenGL ES还有一个好处,是我们可以利用OpenGL
ES处理图像的巨大优势,来对视频做一个后处理(去块滤波器、增加对比度等效果器的使用),让用户感觉上视频更加清晰,在Android平台使用EGL来为OpenGL
ES提供上下文环境,使用SurfaceView的Surface来构造显示对象,最终输出到SurfaceView上;在iOS平台使用EAGL来为OpenGL
ES提供上下文环境,自己定义一个View继承自UIView,使用EAGLLayer作为渲染对象,最终渲染到这个自定义的View上。

至于音视频同步模块,这里不会涉及任何有关平台相关的API,不过,考虑到它要维护解码线程,因此pthread其实是一个好的选择,因为两个平台都支持这种线程模型。此外,它还需要维护两个队列,由于STL中提供的队列不能保证线程安全性,所以对于音视频队列我们可以自己写一个保证线程安全的链表来实现。最后要负责音视频的同步,由于音视频同步的策略在前面章节已提到过,因此这里采用视频向音频对齐的策略,即只需要把同步这块逻辑放到获取视频帧的方法里面就好了。

最后是控制器,控制器需要把上述的三个模块合理地组装起来。在开始播放的时候,需要把资源的地址(有可能是本地的文件也有可能是网络的资源文件)传递给AVSynchronizer,如果能够成功地打开文件,那么就去实例化VideoOutput和AudioOutput,在实例化这两个类的同时,要传入回调函数,这两个回调函数又分别去调用AVSynchronizer里面的获取音频和视频帧的方法,这样就可以有序地组织多个模块,最终如果暂停、继续、停止的指令调用下来,自然也就去调用各个模块对应的生命周期方法。

以上笔者把每个模块的具体实现又给梳理了一遍,这样,其实架构已经基本成型了,但是作为一个架构师来讲,做到这一些其实是不够的,因为优秀的架构师必须在做完整个架构之后,再针对这个架构给出风险评估与部分测试用例,下面也逐一来分析一下。

首先是风险评估,由于我们最终做的项目是运行在移动平台上的,所以对于移动平台的碎片化设备(尤其是Android平台的碎片化更加严重)这一现象,必须要有足够的设备作为测试目标,以保证没有兼容性问题,设备所述的平台架构也应该覆盖到arm、armv7、arm64等平台。然后必须得测试性能问题,性能包括CPU消耗、内存占用、耗电量与发热量,而针对这些风险,在这一期项目中可能会有一些无法解决,那我们的长期计划就应该在这一方面进行改进。其实,目前来看最大的风险就是软件解码这部分,长期来看,需要有硬件解码的替代方案。

对于测试用例,我们应该要在以下几方面进行测试,首先是输入模块,包括协议层(网络资源、本地资源)、封装格式(flv、mp4、mov、AVI等等)、编码格式(H264、AAC、WAV)等;其次是音视频同步模块,应该在低网速的条件下观看网络资源的对齐程度;最后是两个输出模块,测试应该要覆盖iOS系统以及Android系统的大部分系统版本,和最终应用运行的Top10的所有设备的音频和视频播放的兼容性。

完成了风险评估和基本的测试用例,至此我们的架构算是比较完善了,接下来会把每个模块进行实现。

5.2 解码模块的实现

下面来介绍输入模块的具体实现,即类图中的VideoDecoder类的实现,前面在讨论技术定型的时候已经说过,我们会直接使用FFmpeg这个开源库来负责输入模块的协议解析、封装格式拆分、解码操作等行为,整体流程如图5-3所示。

图 5-3

首先,来看一下整体的运行流程,整个运行流程分为以下几个阶段:

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

以上就是我们输入端的整体流程,其中第二个阶段会是一个循环并且放在单独的线程中来运行,由于具体的API调用已经在前面章节做过详细的介绍,并且在本章的代码仓库中也可以找到对应的源码,因此这里就不再罗列源码,而是具体来看一下这个类中几个重要的接口是如何设计与实现的。

先来看openFile这个接口的实现,这个接口主要负责建立与媒体资源的连接通道,并且分配一些全局需要用到的资源,将建立连接通道与分配资源的结果返回给调用端。在此过程中,首先是与媒体资源建立连接通道,然后找出这个资源所包含的流的信息(其实是对应的各个Stream的MetaData,比如声音轨的声道数、采样率、表示格式或者视频轨的宽、高、fps等)。如果是网络资源,找出流信息失败的话可以进行重试(具体的重试逻辑可以根据不同的业务场景进行设置,在我们代码中会进行重试3次的策略)。在找出流信息的这一阶段需要使用到前面建立起来的连接通道,并且FFmpeg提供的找出流信息(av_find_stream_info)这个API其实是可以设置参数来控制方法执行时间的。这个过程对于本地资源的话,寻找出MetaData是很快的,不过,如果是网络资源,就需要一段时间了。在第三种中已经介绍过find_stream_info这个函数的内部实现,因为会发生实际的解码行为,所以解码的数据越多,花费的时间也会越长,对应得到的MetaData也会越准确,,对此,一般通过设置probesize和max_analyze_duration这两个参数来给出探测数据量的大小和最大的解析数据的长度,其值常设置为50
*
1024和75000,如果达到了设置的值还没有解析出对应的视频流和音频流的MetaData,那么就返回失败,紧接着进入重试策略,如果可以解析到就将对应的流信息填充到对应的结构体中。在找出对应的流信息之后,接着要打开每个流的解码器(如果声音有两路流,有的播放器中可以允许切换,像我们之前说的ffplay就支持带入参数进行选择,vlc播放器可以实时切换,本项目中只选择第一个音频流)。最后,对于每个流要分配一个AVFrame作为解码之后数据存放的结构体,对于音频流,则要额外分配一个重采样的上下文,将解码之后的音频格式进行重采样,使其成为我们需要的PCM格式,这里仅仅进行分配资源,具体的解码和转换行为后续再讲。

其次是decodeFrames这个接口的实现,这个接口主要是负责解码音视频压缩数据成为原始格式并且封装成为自定义的结构体,最终全部放到一个数组中,然后返回给调用端。首先是读出一个压缩数据帧来,对应于FFmpeg里面的AVPacket这个结构体,对此,前文中也有过详细介绍,对于视频帧,一个AVPacket就是一帧视频帧,对于音频帧,一个AVPacket有可能有多个音频帧,所以对于一个AVPacket判定了类型(音频类型还是视频类型)之后,所调用的解码方法是不一样的,视频部分仅需要解码一次,而音频部分是需要判定这个AVPacket里面的压缩数据是否被全部消耗干净了,并以此作为结束的条件。解码结束之后,需要提取出对应的裸数据填充到我们自己定义的结构体,全部都存入数组中,并返回给外界调用端,为什么这么做呢?因为我们不希望向外界暴露Input这个模块的内部所使用的技术细节,即不希望向客户端代码暴露这里面到底使用的是FFmpeg的解码器库还是硬件解码器或者其他的解码器等细节。所以解码之后,需要封装成自己定义的结构体的AudioFrame和VideoFrame,具体如何操作,前面章节也提到过,或者看源码也可以了解。

然后这里有一点特殊的是,如果音频或者视频解码出来的表示方式和我们预期的表示方式不一样,那么就需要做个转换。对于音频和视频的格式转换,FFmpeg分别提供了不同的API来完成,如下所示:

  • 对于音频的格式转换,FFmpeg提供了一个libswresample库,开发者只需要把原始音频的格式(包括声道、采样率、表示格式)和目标音频的格式(包括声道、采样率、表示格式)传递给这个库的初始化上下文方法(swr_alloc_set_opts),就可以构造出一个重采样上下文,然后调用swr_convert将解码器输出的AVFrame传递进来,那么重采样之后的数据就是开发者所期望的音频格式的数据了,最终使用完毕之后调用swr_free方法来释放掉重采样上下文。
  • 对于视频帧的格式转换,FFmpeg提供了一个libswscale的库,用于转换视频的裸数据的表示格式,如果原始视频的裸数据表示格式不是YUV420P,那么就需要使用这个库来将非YUV420P格式的视频数据转换为YUV420P。转换方式也很简单,就是把源的格式(包括视频宽、高、表示格式)和目标的格式(包括视频宽、高、表示格式)传递给这个库的获取上下文方法(sws_getCachedContext),构造出转换视频的上下文,然后在需要使用的时候调用sws_scale将解码器输出的AVFrame传递进来,那么转换之后的视频数据就存在于AVPicture结构体里面了,最终我们可以再从AVPicture里面取出对应的数据封装到自定义的结构体中,使用完毕之后调用sws_freeContext来销毁掉这个转换上下文。

至于销毁资源阶段的实现,它恰恰与打开流阶段相反,首先要销毁掉音频相关的资源,包括分配的AVFrame以及音频解码器(如果分配了重采样上下文与重采样的buffer,那么也销毁掉);然后销毁掉视频的相关资源,包括分配的AVFrame与视频解码器(如果分配了格式转换上下文与转换后的AVPicture,那么也销毁掉);最后断开连接通道,最终就销毁掉了所有的资源。

圆角矩形 2

5.3 音频播放模块的实现

本节来介绍音频播放模块的实现,即类图中的AudioOutput类的实现,这一部分的实现其实对于Android和iOS平台是不同的,在第4章已经详细地介绍了OpenSL
ES和AudioUnit的使用,这里面将结合现在这个项目再来具体地调整一下实现结构。

5.3.1 Android平台的音频渲染

在Android平台是使用OpenSL
ES进行音频的渲染的,首先建立AudioOutput这个类,按照之前的架构设计,需要在这个类里面定义一个回调函数,让外界来实现这个函数,以供这个类获取或者说填充所需要播放的音频PCM数据,所以定义这个回调函数如下:

typedef int(*audioPlayerCallback)(byte* , size_t, void* ctx);

这个函数的第一个参数是需要外界填充PCM数据的缓冲区,第二个参数是这个缓冲区的大小,第三个参数是客户端代码自己填充的上下文对象(由于在C++中的回调函数是静态的函数,所以要传递对象自己作为这个上下文对象,以便被调用的时候可以将这个上下文对象强制转换成为目标对象,来访问对象中的属性以及方法)。

接下来,让我们具体实现AudioOutput这个类中的几个接口方法,其实面向对象的特征之一就是封装,即将类内部具体实现细节进行封装,向外暴露出接口,用来完成客户端代码想要这个类完成的行为。所以这几个接口肯定不需要暴露AudioOutput内部到底是使用OpenSL
ES来实现的音频播放还是AudioTrack来实现的播放,尽管我们现在就是使用OpenSL
ES来实现的音频播放,如果以后有一些特殊需求,可以换做AudioTrack或者其他的实现方式,但是对于外界的接口以及回调函数是不会变的。这对于整个系统的扩展性以及维护性是非常重要的,其实前面VideoDecoder不向客户端代码暴露AVFrame而是暴露自己封装的VideoFrame或者AudioFrame结构体也是一样的道理。那我们就来实现第一个接口。

(1)初始化方法

传入参数就是声道数、采样率、表示格式、回调函数以及回调函数的上下文对象,返回值就是OpenSL
ES是否可以正常完成初始化,具体的OpenSL
ES如何初始化不再赘述,因为在第4章中已经有很大篇幅在讲解。核心流程里面有一步是给audioPlayerBufferQueue设置回调函数,也就是当OpenSL
ES需要数据进行播放的时候会回调这个函数,由开发者来填充PCM数据,而此时我们在第一步中定义的回调函数就有用了,我们在此处就调用这个回调函数填充音频裸数据,然后调用audioPlayerBufferQueue的Enqueue方法,把客户端代码填充过来的PCM数据放到OpenSL
ES中的BufferQueue中去。

(2)暂停和继续播放方法

在上面一步初始化OpenSL
ES的时候,已经把audioPlayerObject中的play接口给拿出来了,我们只需要设置playState就可以了,如下:

int state = play ? SL_PLAYSTATE_PLAYING : SL_PLAYSTATE_PAUSED;

(*audioPlayerPlay)->SetPlayState(audioPlayerPlay, state);

(3)停止方法

首先先暂停掉现在的播放,然后最重要的一步是设置一个全局的状态,保证如果再有audioPlayerBufferQueue的回调函数要调用的时候,不需要再进行填充数据,最好再调用usleep方法来暂停一段时间(比如50ms),可以让buffer缓冲区里面的数据全部播放完毕,最终调用OpenSL
ES的API销毁掉所有的资源,包括audioPlayerObject与outputMixObject。

5.3.2 iOS平台的音频渲染

在iOS平台,使用AudioUnit(AUGraph实际上封装的就是AudioUnit)来渲染音频,类似于Android平台的实现,我们也要有一个类似于回调函数的形式来要求客户端代码填充音频的PCM数据。相比较于回调函数,在OC中的常用实现是定义一个协议(Protocol),由客户端代码实现这个协议,重写协议里面定义的方法,下面来看这个Protocol的定义:

@protocol FillDataDelegate \<NSObject>

  • (NSInteger) fillAudioData:(SInt16*) sampleBuffer

numFrames:(NSInteger)frameNum numChannels:(NSInteger)channels;

@end

其中,第一个参数就是要填充的缓冲区,第二个参数是这个缓冲区中有多少个音频帧,第三个参数是声道数。客户端代码在实现中要按照帧的个数和声道数来填充这个缓冲区。其实OC中的这种Protocol方式会更加面向对象,让开发者实现起代码来更加合理,客户端代码来实现这个协议就意味着要承担这个协议所要求的职责,比起C++的回调函数OC语言的这种写法会更加面向对象,读者可以自行体会一下。

然后是这个类的初始化方法,传入包括声道数(NSInteger
channels)、采样率(NSInteger sampleRate)、采样的表示格式(NSInteger
bytesPerSample),以及具体的所规定的协议实现的对象(id\<FilleDelegate>
fillAudioDelegate)。在这个方法的实现中首先构造一个AVAudioSession出来,然后给这个session设置用途类型以及采样率等;接下来设置音频被中断的监听器,方便应用程序在特殊情况下可以给出相应的处理;最后就是核心流程–构造AUGraph,用以实现我们的音频播放,这个具体的流程已经在前面的章节详细讲解过了,这里需要注意的是,需要配置一个ConvertNode将客户端代码填充的SInt16格式音频数据转换为RemoteIONode可以播放的Float32格式的音频数据(采样率、声道数以及表示格式要对应上),这一点是非常关键的,当然需要给ConvertNode配置上InputCallback,在这个InputCallback的实现中调用Delegate的fillAudioData方法,让客户端代码填充数据,配置好整个AudioGraph之后,调用AUGraphInitialize方法来初始化整个AUGraph。最终构造出来的AUGraph以及和客户端代码的调用关系如图5-5所示。

图 5-5

接下来是play方法,这个方法的实现就非常简单了,直接调用AUGraphStart方法,启动这个Graph就可以了,一旦启动了之后,就会从RemoteIO这个AudioUnit开始播放音频数据,如果需要音频数据,就向它的前一级AudioUnit即ConvertNode去拉取数据,而ConvertNode则会去找到自己的InputCallback,在InputCallback的实现中会去和delagate(即VideoPlayerController)去要数据,然后就在实现这个Protocol的客户端代码中填充数据,最终就可以播放出来了。

至于pause方法,其实现就更简单了,直接调用AUGraphStop方法,这样就可以停止掉AUGraph的运行,声音自然就不会播放出来了。

最后是销毁方法,在销毁方法中需要停止掉AUGraph,然后调用AUGraphClose方法把这个AUGraph给关闭掉,并移除掉AUGraph里面所有的Node,最终调用DisposeAUGraph,这样就可以彻底销毁掉整个AUGraph了。

5.4 画面播放模块的实现

本节来介绍视频(画面)播放模块的实现,即类图中的VideoOutput类的实现,这一部分的实现也是依赖于平台的,虽然原理上都是使用的OpenGL
ES,但是对于不同平台有不同的实现,在第4章中也已经详细地讲解了具体的使用实例,在这里就结合现在这个项目再来具体调整一下结构。

5.4.1 Android平台的视频渲染

我们说过,无论在哪一个平台使用OpenGL
ES渲染视频的画面,都需要单独开辟一个线程,并且为这个线程绑定一个OpenGL
ES的上下文,在Android平台肯定也不例外,由于是在Native层进行OpenGL
ES的开发,所以这里首先选用线程模型,在前面的章节笔者曾分析过各种线程模型的优缺点,所以这里就直接选用POSIX线程模型即PThread。在开始实现初始化函数之前,也要先定义一个回调函数,当VideoOutput这个模块需要渲染视频帧的时候,就调用这个回调函数拿到要渲染的视频帧,然后再进行真正的渲染,回调函数的代码原型如下:

typedef int (*getTextureCallback)(VideoFrame** texture, void* ctx);

这个函数的参数列表中,第一个就是要获取的视频帧,第二个是回调函数的上下文,返回值为int类型(当成功获取到一帧视频帧后返回大于零的值,否则返回负值)。接下来就看一下初始化函数的实现。

对于初始化函数,传入的第一个参数是ANativeWindow类型的指针,这个window实际上是从Java层传递过来的一个Surface构造出来的,而Java层的这个Surface就是从SurfaceView的onSurfaceCreated这个生命周期方法中的SurfaceHolder获取出来的。第二个和第三个参数则是绘制的View的宽和高,第四个参数是获取视频帧的回调函数以及回调函数的上下文对象。初始化函数的实现如下,首先创建一个线程作为OpenGL
ES的渲染线程,线程执行的第一个步骤就是初始化OpenGL
ES环境,它会利用EGL构建出OpenGL
ES的上下文,并且利用ANativeWindow构造出EGLDisplay作为显示目标,然后利用vertexShader和fragmentShader构造出一个Program。

VertexShader负责顶点的操作,所以我们的VertexShader的代码如下:

static char* OUTPUT_VIEW_VERTEX_SHADER =

“attribute vec4 vPosition; \n”

“attribute vec4 vTexCords; \n”

“varying vec2 yuvTexCoords; \n”

” \n”

“void main() { \n”

” yuvTexCoords = vTexCords.xy; \n”

” gl_Position = vPosition; \n”

“} \n”;

VertexShader中直接将顶点赋值给gl_Position,然后将纹理坐标传递给FragmentShader,具体的FragmentShader代码如下:

static char* YUV_FRAME_FRAGMENT_SHADER =

“varying highp vec2 yuvTexCoords; \n”

“uniform sampler2D s_texture_y; \n”

“uniform sampler2D s_texture_u; \n”

“uniform sampler2D s_texture_v; \n”

“void main(void) \n”

“{ \n”

” highp float y = texture2D(s_texture_y, yuvTexCoords).r; \n”

” highp float u = texture2D(s_texture_u, yuvTexCoords).r – 0.5; \n”

” highp float v = texture2D(s_texture_v, yuvTexCoords).r – 0.5; \n”

” \n”

” highp float r = y + 1.402 * v; \n”

” highp float g = y – 0.344 * u – 0.714 * v; \n”

” highp float b = y + 1.772 * u; \n”

” gl_FragColor = vec4(r,g,b,1.0); \n”

“} \n”;

由于视频帧YUV420P的数据格式表示的,所以在FragmentShader里面需要把YUV的数据转换为RGBA格式的数据。先取出对应的YUV的数据,因为UV的默认值是127,所以我们这里要减去0.5(在OpenGL
ES的Shader中会把内存中0-255的整数数值换算为0.0-1.0的浮点数值),然后按照YUV到RGBA的计算公式将YUV格式转换为RGBA格式,而这也就是FragmentShader要完成事情。

然后是渲染方法,当客户端代码需要VideoOutput渲染视频帧的时候,VideoOutput模块会先利用回调函数获得视频帧,然后利用第一步创建的线程中利用构造的Program执行渲染操作,最终调用eglSwapBuffers方法将渲染的内容绘制到EGLDisplay(EGLDisplay内部是ANativeWindow,ANativeWindow内部又组合了Surface,而Surface代表的就是其实就是SurfaceView,所以最终就是绘制到Java层的SurfaceView)上面去。

最后是销毁方法,也必须在第一步创建的线程中进行,因为这个线程中创建的OpenGL上下文、EGLDisplay、Program、渲染过程中使用到的纹理对象、frameBuffer对象等OpenGL
ES的对象。所以必须在这个线程中来销毁这一系列的对象。

5.4.2 iOS平台的视频渲染

前面章节介绍过如何在iOS平台上使用OpenGL
ES,在本节的实现中,首先书写一个VideoOutput类继承自UIView,然后重写父类的layerClass这个方法,并且返回CAEAGLLayer类型,重写这个方法的目的是声明这个UIView的Layer是用来被OpenGL
ES绘制的;然后在初始化方法中,将OpenGL
ES绑定到Layer上,在iOS平台上的线程模型,采用NSOperationQueue来实现,也就是把所有OpenGL
ES的操作都封装在NSOperationQueue中来完成,为什么要使用这种线程模型呢?其实笔者在很多设备中做过测试,由于某一些低端设备,比如iPod、iPhone4,在一次OpenGL的绘制中可能耗费的时间比较多,如果使用GCD的线程模型的话,会导致DispatchQueue里面的绘制操作越积累越多,并且不能清空,而使用NSOperationQueue,可以在检测到这个Queue里面的Operation超过定义的阈值(Threshold)时,清空老的Operation,只保留最新的绘制操作,这样才可以完成正常的播放。在前面的章节中也提到中,在iOS平台中有一个比较特殊的地方就是如果App进入后台之后,就不可以再进行OpenGL
ES的渲染操作,所以这里面还需要注册两个监听事件:一个是WillResignActiveNotification,即当App从活跃状态到非活跃状态的时候或者说即将进入后台的时候系统会调用这个监听事件;另外一个是DidBecomeActiveNotification,即当App从后台到前台时系统会调用这个监听事件。我们可以在这两个回调方法中控制一个布尔型变量:enableOpenGLRendererFlag,并在进入后台的监听事件中将它设置为NO,在回到前台的监听事件中将它设置为YES。在线程的绘制过程中应该先判定这个变量是否为YES,是YES则进行绘制,否则不进行绘制,整体实现结构如上所述。

接下来看一下初始化方法的实现,首先给layer设置属性,然后初始化NSOperationQueue,并且直接将OpenGL
ES的上下文构建以及OpenGL
ES的渲染Program作为一个Block(可以理解为一个代码块)扔到这个Queue中去。具体这个Block中的行为是:先分配一个EAGLContext,然后为这个NSOperationQueue线程绑定OpenGL
ES上下文,接着创建frameBuffer和renderBuffer,将renderBuffer的storage设置为UIView的layer(就是前面所提到的CAEAGLLayer),最后我们再将frameBuffer和renderBuffer绑定起来,这样绘制在frameBuffer上的内容就相当于绘制到了renderBuffer上,最后使用前面提到的VertexShader和FragmentShader构造出的实际的渲染Program,至此,初始化就完成了。

然后是关键的渲染方法,这里先判断当前OperationQueue里面的operationCount的值,如果其数目大于我们规定的阈值(一般设置为2或者3),就说明每一次绘制花费的时间比较多,导致很多延迟的绘制,所以可以删除掉最久的绘制操作,仅仅保留等于阈值个数的绘制操作,然后将本次绘制操作加入到OperationQueue中,这个绘制操作的执行已经委托到OperationQueue的线程中了。由于在初始化的过程中我们已经给这个线程绑定了OpenGL
ES的上下文,所以可以在这个线程中直接进行OpenGL
ES的渲染操作。首先判定布尔型变量enableOpenGLRendererFlag的值,如果是YES,就绑定frameBuffer,然后使用Program进行绘制,最后绑定renderBuffer并且调用EAGLContext的PresentRenderBuffer将刚刚绘制的内容显示到layer上去,因为layer就是UIView的layer,所以就可以在UIView中看到我们刚刚绘制的内容了。

至于销毁方法,也要保证这一步操作放到OperationQueue中去执行,因为所有涉及到OpenGL
ES的操作都要放到绑定了上下文环境的线程中去操作。具体实现中,首先要释放掉Program,然后释放掉frameBuffer和renderBuffer,最后将本线程与OpenGL上下文解除绑定。

对于UIView的dealloc方法,主要负责回收掉所有的资源,首先移除所有的监听事件,然后清空掉OperationQueue里面未执行的操作,最后释放掉所有的资源。至此这个VideoOutput就实现完毕了。

5.5 AVSync模块的实现

本节介绍音视频同步模块的实现,即类图中AVSynchronizer类的实现,对于这个类的职责,从它的名字上就可以看出来,主要是用来做音视频同步的,但是我们在架构设计阶段就说过,不想把这个系统拆得太细,所以这个类的职责还包括维护解码线程,即创建、暂停、运行、销毁解码线程,基于以上分析,这个类分为两部分来实现,第一部分是维护解码线程,第二部分就是音视频同步。主要接口与实现如下。

  • 当外界调用这个模块的初始化方法的时候,拿着要打开的媒体资源的URI去实例化解码器出来,并且把解码器维护为一个全局变量以便后续的使用;
  • 当外界需要这个类填充音频数据的时候,如果音频队列中有音频就直接去填充,同时要记录下这个音频帧的时间戳,如果音频队列中没有音频就填充空数据;
  • 当外界需要这个类返回视频帧的时候,会拿着当前播放的音频帧时间戳找到合适的视频帧返回。
  • 当外界调用销毁方法的时候,首先停止掉解码线程,然后销毁掉解码器,最后再销毁掉音视频队列。

5.5.1 维护解码线程

AVSync模块开辟的解码线程其实扮演了生产者的角色,生产出来的数据存放的位置就是音频队列和视频队列,而AVSync模块向外暴露的填充音频数据和获取视频的方法其实扮演了消费者的角色,从音视频队列里面取数据,其实这就是标准的生产者消费者模型。当客户端代码调用start方法的时候,就应该利用POSIX线程模型来创建出一个解码线程,并且让解码线程开始运行,从而解码音频帧和视频帧,解码出来的音视频帧要转换为我们自己定义的结构体AudioFrame和VideoFrame,并且要把这两种类型的帧分别放入音频队列和视频队列。那么具体在解码线程中如何调用VideoDecoder(输入模块)来进行解码操作的呢?详细的实现代码如下:

while(isOnDecoding) {

pthread_mutex_lock(&videoDecoderLock);

pthread_cond_wait(&videoDecoderCondition, &videoDecoderLock);

pthread_mutex_unlock(&videoDecoderLock);

isDecodingFrames = true;

decodeFrames();

isDecodingFrames = false;

}

上述代码在这个线程中会是一个循环,只要我们这个模块不进行销毁(销毁的时候会把全局变量isOnDecoding设置为false),就会一直走这个循环。在这个循环内部会首先看到有一个条件锁,即每循环一次之后就会停在wait的地方,等待signal指令过来才可以进行下一次解码操作,为什么要这样安排呢?因为播放器播放的视频是随着时间逐一进行播放的,而后台解码线程没必要一股脑把视频全部解码完毕并放入队列中(一个原因是在内存中基本上存储不开,因为视频占的空间实在太大了;第二个原因就是用户可能看一会儿就不看了,我们解码出来音视频帧就都作废了,没必要白白浪费CPU,如果是网络资源还白白浪费了带宽)。所以需要把解码线程做成这种模式,我们规定两个值,一个min_bufferDuration和一个max_bufferDuration,比如分别是0.2s和0.4s,前面已提到过解码线程其实充当的是生产者的角色,每一次调用decodeFrames这个方法都会将两个队列填充至max_bufferDration的刻度之上,然后解码线程就会进入下一次循环,就在上面代码中的wait处等待signal指令。而当消费者线程每一次消费数据的时候,我们都会判断队列里面所有视频帧的长度是否在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指令,让解码线程有机会结束,如果不发送这个signal指令的话,解码线程就有可能一直wait在这里,成为一个僵尸线程。

5.5.2 音视频同步

音视频同步的策略在前文中也有提到过,这里再重点介绍一下,音视频同步一般分为三种:音频向视频同步;视频向音频同步;音频视频统一向外部时钟同步。第3章在学习FFmpeg框架时,也学习过ffplay,其中使用ffplay播放视频文件的时候,所指定的对齐方式就是上面所说的三种方式,下面来逐一分析这三种对齐方式分别是如何实现的,以及各自的优缺点。

(1)音频向视频同步

先来看一下这种同步方式是如何实现的,音频向视频同步,顾名思义,就是视频会维持一定的刷新频率或者说根据渲染视频帧的时长来决定当前视频帧的渲染时长,或者说视频肯定可以每一帧都渲染出来,而当我们向AudioOutput模块填充音频数据的时候,会和当前渲染的视频帧的时间戳进行比较,这个差值如果不在阈值范围内,就需要做对齐操作;如果在阈值范围之内,那么就可以直接将本帧音频帧填充给AudioOutput模块,进而让用户听到这个声音。那如果不在阈值范围之内,要如何做对齐操作呢?这就需要我们去调整音频帧了,也就是说如果要填充的音频帧的时间戳比当前渲染的视频帧的时间戳小,那就需要跳帧操作(具体的跳帧操作可以是加快速度播放的实现,也可以是丢弃掉一部分音频帧的实现);如果音频帧的时间戳比当前渲染的视频帧的时间戳大,那么就需要等待,具体实现可以是填充空数据给AudioOutput模块进行播放,也可以是将音频的速度放慢播放给用户听,而此时视频帧是继续一帧一帧进行渲染的,一旦视频的时间戳赶上了音频的时间戳,就可以将本帧音频帧的数据填充给AudioOutput模块了。这就是音频向视频同步的实现,其优点就是视频可以每一帧都播放给用户看,画面可以说是最流畅的,但是音频就会有所丢帧或者插入静音帧,所以这种对齐方式也会有一个巨大的缺点就是音频有可能会加速(或者跳变)也有可能会有静音数据(或者慢速播放),如果是变速系数不太大的话,用户感知可能不太强(但是系数变化比较大的话就会感知非常强烈了),但是如果丢帧或者插入空数据的时候,用户的耳朵是可以明显感觉到的。

(2)视频向音频同步

再来看一下视频向音频同步方式是如何实现的,这与上面提到的方式恰恰相反,由于不论是哪一个平台播放音频的引擎,都可以保证播放音频的时间长度与实际这段音频所代表的时间长度是一致的,所以我们可以依赖于音频的顺序播放给我们提供的时间戳,所以当客户端代码和我们要视频帧的时候,则先计算出当前视频队列头部的视频帧的元素的时间戳和当前音频播放帧的时间戳的差值。如果在阈值范围之内,就可以渲染这一帧视频帧;如果不在阈值范围之内的话就要进行对齐操作。具体的对齐操作就是:如果当前队列头部的视频帧的时间戳小于当前播放音频帧的时间戳的话,那么就进行跳帧操作,如果大于当前播放音频帧的时间戳,那么进行等待(重复渲染上一帧或者不进行渲染)的操作。优点就是音频可以连续的播放,缺点就是视频有可能会有跳帧的操作,但是丢帧和跳帧的表现用户的眼睛是不太容易分辨出来的。

(3)统一向外部时钟同步

这种策略其实更像是上述两种对齐方式的合体,其实现就是单独在外部维护一轨外部时钟,我们要保证这个外部时钟的更新是按照时间的增加慢慢增加的,而我们获取音频数据和视频帧的时候,都是要和这个外部时钟进行对齐,如果没有超过阈值,那么就直接进行将本帧音频帧或者视频帧返回,如果超过阈值了我们就进行对齐操作。具体的对齐操作就是:使用上述两种方式里面对齐操作,将其分别应用于音频的对齐和视频的对齐。优点是可以最大程度的保证音视频都可以不发声跳帧的行为,缺点是如果控制不好外部时钟,极易有可能引发音频和视频都跳帧的行为。

根据人眼睛和耳朵的生理因素,有一个理论,就是人的耳朵比人的眼睛要敏感得多,也就是说,一旦音频有跳帧的行为或者填空数据的行为,那么我们耳朵是十分容易察觉得到的,而视频有跳帧或者重复渲染的行为,我们的眼睛其实不容易分辨出来。根据这个理论,我们实现的播放器中的音视频对齐策略就选用第二种方式,即视频向音频对齐的方式。

5.6 中控系统, 串联起各个模块

下面介绍中控模块的实现,即类图中VideoPlayerController类的实现,这一部分其实就是将上面提到的各个模块有序地组织起来,让单独运行的各个模块可以协同配合起来工作。由于每个模块都有各自的线程在运行,所以这部分代码里面必须负责好各个模块的生命周期的维护,否则极易产生多线程的问题,下面就分为三个阶段来讲解这个模块,分别是初始化、运行、销毁这三个阶段。

5.6.1 初始化阶段

1. Android平台

虽然我们的项目成为视频播放器,但即使客户端代码没提供渲染的View,播放器也应该能够播放出声音来,而这是一个比较有用的功能(在一些产品中可以给用户非常好的体验,比如:在直播产品中的秒开首屏、在一些视频播放器中正在播放着这时候退出播放界面还是有声音播放在切换回来画面可以立即渲染类似于YouTube的播放界面的体验),所以我们在初始化阶段必须把播放器的初始化和渲染界面的初始化分离开。如上述分析,初始化阶段应该分为两部分,一部分是播放器的初始化,另外一部分是渲染界面的初始化。

首先来看播放器的初始化,因为在初始化的过程中需要I/O操作,因此需要调用AVSync模块来打开媒体资源,如果媒体资源是本地资源的话还好,一旦是网络资源,建立连接的时间就不确定了(因为建立连接操作是阻塞的,直到建立连接成功之后才会返回),所以这里必须要开辟一个线程来做初始化操作,即利用PThread开辟一个initThread来。在这个线程中,先实例化AVSynchronizer这个对象,然后调用这个对象的init方法来建立和媒体资源的连接通道。如果打开连接失败,那么回调客户端说打开资源失败;如果打开连接成功,就拿出媒体资源的Channel、SampleRate、SampleFormat以及我们的fillAudioDataCallback回调函数和对象本身来初始化AudioOutput。如果可以初始化成功,则代表初始化步骤可以完成了,然后直接调用AVSync模块的start方法以及audioOutput的播放方法。还有最后一步,由于上述操作都是在新开启的线程里面执行的,所以无法将初始化成功或者失败以及一些列参数返回给客户端,故而最后一步就是将初始化成功与否回调客户端对象,告诉客户端初始化播放器的状态,至此我们的播放器就可以正常的播放音频了,但是视频呢?

接下来是渲染界面初始化的阶段,如果客户端调用层觉得现在这个时机可以显示视频的画面部分了,那么就让SurfaceView显示,按照SurfaceView的生命周期,就应该会调用设置Callback的onSurfaceCreated方法,也就会调用中控系统的initVideoOutput方法,这就是用来初始化渲染界面的,这里会直接初始化VideoOutput对象,然后用传递进来的ANativeWindow对象和界面的宽和高以及获取视频帧的回调函数来初始化VideoOutput对象。以上就是初始化阶段所有的执行步骤了。

2. iOS平台

与Android平台不同,iOS的播放器在一个ViewController中,所以整个播放器的中控系统就是ViewController。所以在iOS平台上的实现要简单一些,毕竟不需要两种语言的交互(Java层到Native层的数据和指令传递),所以这里的初始化就是整个播放器的初始化。还记得在AVSync模块中定义的PlayerStateCallback的Protocol吗?里面有下面两个方法:

  • (void) openSucceed;
  • (void) connectFailed;

这两个方法分别是在初始化方法执行成功或失败时,回调客户端代码时用的。与Android平台类似,调用AVSync模块的来打开连接放在一个异步线程中会更加合理,所以这里使用GCD线程模型,将初始化的操作放入一个DispatchQueue中。首先也是调用AVSync模块的openFile方法,如果可以打开媒体资源连接的话,我们就继续初始化VideoOutput对象。还记得VideoOutput实际上是一个继承自UIView的自定义View吗?我们需要把这个View加入到ViewController中,但是当前是在一个子线程中初始化的ViewOutput的对象,所以我们必须dispatch到主线程中,然后调用代码:

[self.view insertSubview:_videoOutput atIndex 0];

接着还是在子线程中拿出媒体文件中的声道数、采样率、以及对象本身(作为实现AudioOutput类中声明的Protocol的实现者)来初始化AudioOutput对象,最终调用audioOutput的开始播放方法。由于上述操作一直是在子线程中执行的操作,所以当执行完毕时,可使用前面提到的playerStateCallback来回调客户端代码,告诉客户端播放器初始化的状态,是成功还是失败。

5.6.2 运行阶段

1. Android平台

由于在初始化阶段已经开启了音频输出模块(调用了AudioOutput这个对象的start方法),因此,在OpenSL
ES将自己的缓冲区里面的音频播放完毕之后,就会立马通过回调方法回调到我们的中控模块,由中控模块来填充数据,而填充音频的方法就是最核心的实现。具体实现如下,首先会判断一下当前播放器的状态,如果处于暂停状态就不会再去和AVSync模块要数据,而是填充静音数据(即全0的数据)给OpenSL
ES去播放,当然如果AVSync已经被销毁了或者解码完毕了,那么也要填充空数据给OpenSL
ES播放,如果上述情况都不满足的话,我们就调用AVSync模块填充音频数据的方法,待填充了这一帧音频数据的之后我们就给VideoOutput(视频输出模块)发送一个指令,让VideoOutput模块来更新视频画面的一帧,当VideoOutput模块收到这个指令时,就可以再调用自己的回调方法(由于在初始化的时候已经把中控系统的回调方法传递给了VideoOutput),从而调用到VideoPlayerController的获取视频帧方法,调用AVSync模块的获取视频帧方法后,返回给视频播放模块,将最新的一帧视频帧更新到画面中。

在运行阶段还有暂停和继续播放的接口实现,在Android平台上,由于整个播放器的驱动是由音频播放模块来驱动的,所以仅需要让音频播放模块暂停和继续就好了,所以这一块的实现是非常简单的。

2. iOS平台

由于iOS的中控系统实现了AudioOutput模块的FillDataDelegate这个Protocol,所以就需要实现这个协议里面填充音频数据的方法,而实现的这个方法实际上就是运行阶段的核心控制。这里先判断AVSync模块是否播放完成或者当前播放器状态是否处于暂停状态,如果已经播放完成或者是暂停状态了,那就填充为静音数据(即全0的数据),如果没有播放完成,就调用AVSync模块的获取音频帧的方法,并且我们发送一个指令,让VideoOutput模块来更新画面数据。这就是我们运行中的最核心的部分了,其实很简单,就是给AudioOutput模块填充数据,并且通知VideoOutput模块来更新画面。

再就是暂停和继续播放,跟Android平台很类似,当外界调用暂停和继续的时候,就调用AudioOutput模块的暂停和继续就可以了,其实就是让我们的播放器驱动端来暂停和继续。

5.6.3 销毁阶段

1. Android平台

销毁阶段其实就是初始化阶段的逆过程,首先应该中断媒体资源的连接通道,可调用AVSync模块的interruptRequest方法实现,然后应该看一下初始化阶段的线程有没有执行结束,如果没有执行结束就等待它的结束,所以这里需要使用pthread_join这个排程的方法等待初始化线程之行结束。然后优先停止掉VideoOutput,直接调用VideoOutput的stopOutput方法,紧接着再暂停掉音频输出模块,然后销毁掉AVSync模块,这个模块里面会进行等待解码线程的结束并且销毁掉解码器(输入模块),最后再调用音频输出模块的销毁方法。这样就可以销毁掉所有的模块了。

2. iOS平台

根据运行阶段的介绍已知道,由于音视频对齐策略的影响,整个播放过程其实是由音频来驱动的,所以在现在这个销毁阶段肯定要先停止掉音频,所以首先调用audioOutput对象的stop方法;然后应该停止掉AVSync模块,由于这个模块会有解码线程的存在,所以要把输入模块的连接等给断开,这里首先应该判断输入模块是否打开连接通道成功,如果没有打开成功,就应该中断掉连接;如果成功打开了,就应该调用AVSync模块的销毁方法(里面会把音频队列、视频队列、解码线程以及解码器都给销毁掉,具体参考前面的销毁方法);最后一步应该是停止VideoOutput模块,通过调用VideoOutput的销毁资源方法(里面会销毁掉frameBuffer、renderbuffer、Program等,具体参考前面小节的销毁方法)来实现,最终再将VideoOutput这个自定义的view从ViewController中移除掉来实现。至此销毁阶段就实现完毕了。

5.7 本章小结

视频播放器已经实现完毕,下面回顾一下整个设计与开发的阶段。在此之前,要说明的是,在书中大量罗列代码不是一件好的事情,因此本书会尽量少地粘贴代码上来,而是引导大家一起一步步设计并实现这款播放器。本章先从各个子模块一一实现:

  • 首先实现了输入模块或者称之为解码模块,输出音频帧是AudioFrame,里面的主要数据就是PCM裸数据,输出视频帧是VideoFrame,里面的主要数据就是YUV420P的裸数据;
  • 然后实现了音频播放模块,输入就是我们解码出来的AudioFrame,直接就是SInt16表示一个sample格式的数据,输出就是输出到Speaker让用户直接听到声音;
  • 接着实现了视频播放模块,输入就是解码出来的VideoFrame,里面存放的是YUV420P格式的数据,在渲染过程中使用OpenGL
    ES的Program将YUV格式的数据转换为RGBA格式的数据,并最终显示到物理屏幕上;
  • 之后就是音视频同步模块了,它的工作主要由两部分组成,第一是负责维护解码线程,即负责输入模块的管理;另外一个就是音视频同步,向外部暴露填充音频数据的接口和获取视频帧的接口,保证提供出去的数据是同步的;

最后书写一个中控系统,负责将AVSync模块、AudioOutput模块、VideoOutput模块组织起来,最重要的就是维护这几个模块的生命周期,由于这里面存在多线程的问题,所以比较重要的就是在初始化、运行、销毁各个阶段保证这几个模块可以协同有序的运行,同时中控系统向外暴露用户可以操作的接口,比如开始播放、暂停、继续、停止等接口。