你好,我是展晓凯。今天我们来学习如何写一个播放器。
前面我们分别学习了移动端音频的渲染和视频的渲染,现在是时候用一个完整的项目来将我们学习的知识串联起来了,所以从这节课开始,我们写一个视频播放器来实际操练一下。
播放器项目属于系统性比较强的项目,我会带着你从场景分析入手,然后进行架构设计与模块拆分,再到核心模块实现以及数据指标监控,最后还会向你介绍从这个基础的播放器如何扩展到其他业务场景,所以整体内容还是比较多的,我会分成三讲带着你学习。首先,我们一起进入播放器的场景分析与架构设计的部分吧。
场景分析
我们先来思考一下,播放器要提供哪些功能给用户?最基本的功能自然是从零开始播放视频,能听到声音、看到画面,并且声音和画面是要对齐的,然后还需要支持暂停和继续播放功能;另外,需要支持 seek 功能,即可以随意拖动到任意位置,并立即从这个位置继续播放;高级一点的也会支持切换音轨(如果视频中有多个音轨的话)、添加字幕等功能。
下面我们就先来实现最基本的功能,也就是播放器可以从头播放、暂停和继续的功能。如果直接让你实现这样一个项目,你可能会找不到任何头绪。但作为一个开发人员,我们需要具备把复杂的问题简单化,简单的问题条理化的能力,最终按照拆分得非常细的模块来逐个实现。那基于这个播放器项目,我们需要问自己几个问题:
输入是什么?
输出是什么?
要将输入转换为输出需要几个模块以及每个模块的职责是什么?
那接下来我们会逐一回答一下这几个问题,我们先了解一下播放器的输入是什么,它可以是本地硬盘上的一个媒体文件,格式有可能是 FLV 、MP4、AVI、MOV 等;也可以是网络上的一个媒体文件,网络传输协议有可能是 HTTP、RTMP、HLS 等协议,这样我们就确定了播放器的输入。
那接下来再看输出是什么,输出就是让用户可以听到、看到这个视频,也就是可以把视频中的音频播放出来,同时把视频画面渲染到屏幕上,并且让声音和画面同步播放出来,这样我们进一步确定了输出;最后一步我们根据输入和输出来拆分模块,并给模块分配合理的职责,其实就是需要将输入资源和掌握的音视频能力进行合理的规划和使用,来实现最终的输出结果,但这个问题相比前两个问题要复杂得多,我会带着你来慢慢分析。
输入分析
输入资源有可能是不同的协议,比如本地磁盘的文件(file)或者是 HTTP、RTMP、HLS 等协议,也有可能是不同的封装格式,比如 MP4、FLV、MOV。这些封装格式里通常会有两个 Stream(轨道 / 流),分别是音频流(轨道)和视频流(轨道)。每个轨道里面存储的都是压缩后的编码格式,音频一般为 AAC、视频一般为 H264。
对于这样的输入我们要将这两路流都解码为裸数据,等视频流和音频流都解码为裸数据之后,就可以用我们前面学习的音视频渲染方法去渲染了。但是如果在需要渲染一帧的时候再去做解码,那这一帧视频就有可能出现卡顿或者延迟,所以这里就需要用到视频播放器中的第一个线程——解码线程了,这个线程用来解析协议、处理解封装以及解码,最终把裸数据放到我们音频和视频的队列中,这个模块被称为输入模块。
输出分析
接下来我们看输出部分,输出部分由音频的输出和视频的输出两部分组成。不过可以确定的是,不论音频的输出还是视频的输出,都需要用一个独立的线程来管理,这两个线程会先去输入模块管理的队列中拿出音视频的裸数据,然后分别进行音视频的渲染,最终让用户听得到声音、看得到画面,这两个模块被称为音频输出模块和视频输出模块。
再来思考一件事情,输出模块都在各自的线程中,由于两个输出模块的播放频率以及线程控制没有任何关系,这就导致了另外一个问题:音画没有对齐。在上面我们规划的各个模块里,还没有一个模块的职责是负责音视频同步,所以需要再建立一个模块来负责相关的工作,这个模块就是音视频同步模块。
到这里,我们把模块都拆分完了,具体的模块分布如图所示。
架构设计图
左侧是输入模块,负责将多媒体文件处理成音视频裸数据;中间是音视频的队列负责存储音视频的裸数据;右侧中间是音视频同步模块,负责音视频的同步;音频输出与视频输出模块负责音视频的渲染。基于以上的模块拆分,我们就可以设计整体架构,然后为每个模块来做技术选型了。
架构设计
了解了具体的模块后,我们来整体看一下不同模块之间如何组装到一起。
音视频同步模块向外界暴露获取音频数据、视频数据的接口,这两个接口提供数据的同时要保持同步。音视频同步模块在内部组装输入模块,负责解码线程的调度。然后我们把音视频同步模块、音频输出模块、视频输出模块封装到调度器模块中,调度器模块会分别向音频输出模块和视频输出模块注册回调函数,调度器模块的回调函数中就调用音视频同步模块来获取音频数据和视频数据。
基于以上架构设计,我们可以进一步整理类图设计,如图所示。
类图设计
我们可以详细地看一下类图设计中的各个模块。
VideoPlayerController:调度器模块的类,内部维护音视频同步模块、音频输出模块、视频输出模块,向上层业务暴露开始播放、暂停、继续播放、停止播放等接口;向音频输出模块和视频输出模块暴露两个获取裸数据的接口。
AudioOutput:音频输出模块,在不同平台会有不同的实现,但是一般音频的渲染要放在单独的一个线程中进行,在运行过程中会调用注册过来的回调函数来获取音频数据。
VideoOutput:视频输出模块,虽然我们统一使用 OpenGL ES 来渲染视频,但是前面也讲过,OpenGL ES 在不同平台也会有自己的上下文环境,所以这里采用了 Void 类型的实现,当然,必须由我们主动开启一个线程来作为 OpenGL ES 的渲染线程,它会在运行过程中调用注册过来的回调函数,来获取视频的裸数据进行渲染。
AVSynchronizer:音视频同步模块,用来组合输入模块及音频队列和视频队列,主要给它的客户端代码 VideoPlayerController 这个调度器提供接口,接口包括开始、结束,还有最重要的获取音频数据和对应时间戳的视频帧等。此外,它也会维护一个解码线程,并且根据音视频队列的状态来暂停或者继续运行这个解码线程。
AudioFrame:音频帧,这个结构体中记录了一段 PCM Buffer 以及这一帧的时间戳等信息。
AudioFrameQueue:音频队列,主要用于存储音频帧,为它的客户端代码音视频同步模块提供压入和弹出操作,由于解码线程和声音播放线程会作为生产者和消费者同时访问这个队列,所以这个队列要确保具有线程安全性。
VideoFrame:视频帧,这个结构体中记录了 YUV 数据以及这一帧数据的宽、高以及时间戳等信息。
VideoFrameQueue:视频队列,主要用于存储视频帧,为它的客户端代码音视频同步模块提供压入和弹出操作,由于解码线程和视频播放线程会作为生产者和消费者同时访问这个队列中的元素,所以这个队列也要确保线程的安全性。
VideoDecoder:输入模块,职责在前面已经分析了,由于还没有确定具体的技术实现,所以这里我们根据前面的分析写了三个实例变量,协议层解析器、格式解封装器还有解码器,并且它主要向 AVSynchronizer 暴露一些接口,如打开文件资源(网络或者本地)、关闭文件资源、解码出一定时间长度的音视频帧等。
到这儿,我们根据用户场景把视频播放器拆解成了各个模块,并且根据模块的调用关系画出了类图,那么接下来要做的事情就是来拆分每个模块的具体实现。
每个模块的具体实现
输入模块
从输入文件到最终得到裸数据,会经历解析协议、解封装、解码三个步骤。如果我们自己来写代码,处理不同的协议、不同的编解码格式(更专业地讲是各种解码器),会非常复杂也很不合理,要付出很大的开发与测试成本,并且最终效果也可能不会太理想。现在已经有一些成熟的技术可以供我们使用了,选择 FFmpeg 这个开源库来作为输入模块的技术选型是最合适不过的了。
FFmpeg 中的 libavformat 模块可以处理各种不同的协议以及不同的封装格式,先用 libavformat 模块把文件解封装成每一路流,之后再进行解码。最简单的方式是直接使用 FFmpeg 的 libavcodec 模块来实现,但是如果需要更高性能的解码手段,我们可以使用 Android 和 iOS 平台各自的硬件解码器。
这节课暂时不考虑优化,只是先快速地实现一套方案,使用软件解码是一种好的选择,所以这节课我们使用 FFmpeg 的 libavcodec 模块来作为解码器的技术选型。
其实对于架构设计来说,没有最好的设计,只有最适合当前业务阶段的设计。放在这里来讲,就是硬件解码器对系统平台是有限制的,同时也会有一些兼容性问题,两个平台还需要分别去写代码做各自硬件解码器的实现,并且还要将硬件解码器的输出转换为可用于显示的视频帧数据结构。
因此我们这里选择使用软件解码器,它有更高的兼容性及更简单的 API 调用接口。另外考虑兼容性,以后可能需要硬件解码来提升性能,所以在设计解码模块的时候,我们可以更多地使用面向接口的设计,方便之后更加高效地替换实现。
输出模块
下面我们来看音频输出模块,我们知道音频渲染的技术选型有多种,让我们简单回顾一下。
首先是 Android 平台,常用的就是 Java 层的 AudioTrack 和 Native 层的 OpenSL ES。由于播放器的核心逻辑是在 Native 层,在 AudioTrack 和 OpenSL ES 之间,我们还是选择 OpenSL ES,因为这样省去了 JNI 的数据传递,并且 OpenSL ES 在播放声音方面的延迟更低,缺点就是 OpenSL ES 提供的 API 比起 AudioTrack 不够友好,调试也不太方便,但是总体来衡量,还是 OpenSL ES 更合适些。
而 iOS 平台,比较常见的就是 AudioQueue 和 AudioUnit,AudioQueue 是更高层次的音频 API,是建立在 AudioUnit 的基础之上的,提供的 API 更加简单,在这里选用 AudioQueue 其实也是可以的,但是我们最终选择了 AudioUnit,首先是因为音频渲染过程中有可能存在音频格式的转换,这时使用 AudioUnit 会更加方便;其次我们也要为后续的录音、音效处理等打下使用 AudioUnit 的基础。所以这里我们最终选择 AudioUnit 作为实现方案。
然后是视频输出模块,技术选型肯定要选择 OpenGL ES,因为不论在 Android 还是 iOS 平台我们都可以利用它高效地渲染视频。此外,在这里使用 OpenGL ES 还有一个好处,那就是扩展性。我们可以利用 OpenGL ES 处理图像的巨大优势,来对视频做一个后处理,比如增加去块滤波器、对比度等效果器,让用户感觉视频更加清晰。
前面我们已经学习了如何在 Android 平台和 iOS 平台搭建 OpenGL ES 的环境,在 Android 平台使用 EGL 来为 OpenGL ES 提供上下文环境,使用 SurfaceView(TextureView)的 Surface 来构造显示对象,最终输出到 SurfaceView(TextureView)上;在 iOS 平台使用 EAGL 来为 OpenGL ES 提供上下文环境,自己定义一个继承自 UIView 的 View,使用 EAGLLayer 作为渲染对象,最终渲染到这个自定义的 View 上。
音视频同步模块
音视频同步模块中其实不会涉及任何平台相关的 API,不过考虑到它要维护解码线程,因此使用 PThread 来创建线程会是一个好的选择,原因是两个平台都支持这种线程模型。此外,这个模块还需要维护两个队列,由于 STL 中提供的标准队列不能保证线程安全性,所以对于音视频队列,我们自己写一个保证线程安全的链表来实现。
音视频同步的策略一般分为三种:音频向视频同步;视频向音频同步;音频视频统一向外部时钟同步。具体操作我会在第 9 讲中进行详细地介绍,我们实现的播放器中的音视频对齐策略就选用业内常用的第二种方式,即视频向音频对齐的方式,而到代码实现阶段,音视频同步这块逻辑放到获取视频帧的方法里面就可以了。
控制器模块
最后是控制器,控制器需要把上述的三个模块合理地组装起来。在开始播放的时候,需要把资源的地址(有可能是本地的文件,也有可能是网络的资源文件)传递给 AVSynchronizer。如果能够成功地打开文件,那么就去实例化 VideoOutput 和 AudioOutput。
在实例化这两个类的同时,要传入回调函数,这两个回调函数又分别去调用 AVSynchronizer 里获取音频和视频帧的方法,这样就可以有序地组织多个模块,最终如果暂停、继续的指令调用下来,也相应地去调用各个模块对应的生命周期方法。
小结
这节课我带你完成了视频播放器的场景分析和架构设计,学到这里我相信在你心里视频播放器的核心架构已经基本成型了。但是如果想要成为一个优秀的架构师,仅仅做到这些其实是不够的,我们必须在做完整个架构之后,再针对这个架构给出风险评估与部分测试用例,下面我们也逐一来分析一下。
首先是风险评估,由于我们最终做的项目是运行在移动平台上的,所以对于移动平台的设备碎片化(尤其是 Android 平台,碎片化更加严重)这一现象,必须要有足够的设备作为测试目标,以保证没有兼容性问题,设备所属的平台架构也应该覆盖到 arm、armv7、arm64 等平台。
然后是性能的评估,性能包括 CPU 消耗、内存占用、耗电量与发热量,其中一些风险点在这一期项目中可能无法完全解决,那我们就需要在架构设计中留出足够的扩展来应对这些风险。其实,目前来看最大的风险就是软件解码这部分,长期来看,需要有硬件解码的替代方案。
对于测试用例,我们要在以下几方面进行重点测试,首先是输入模块,包括协议层(网络资源、本地资源)、封装格式(FLV、MP4、MOV、AVI 等等)、编码格式(H264、AAC、WAV)等;其次是音视频同步模块,应该在低网速的条件下观看网络资源的对齐程度,同时也要考虑蓝牙耳机的对齐程度,一些蓝牙耳机输出的 Buffer 很大;最后是两个输出模块,测试要覆盖 iOS 系统以及 Android 系统的大部分版本,保证应用运行的兼容性,在 Top50 的设备中音频与视频能够成功播放出来。
完成了风险评估和基本的测试用例,我们的架构就比较完善了,下节课我们会去具体实现各个模块。
思考题
这节课我们一起分析了播放器的基本场景,然后进行了架构设计与模块拆分,但是基于这个播放器架构可以扩展成为更多场景,比如:直播播放器、视频编辑器、离线保存器等,所以这节课留给你的思考题就是:如果要让你实现一个视频编辑器,你会如何基于播放器的基础架构进行扩展呢?视频编辑器核心需求如下:
可以对视频画面进行处理,比如:增加字幕、添加贴纸、增加一些主题蒙版效果等;
可以给视频增加 BGM 音轨,并且可以调整音量等效果。
无需描述具体实现,把基于播放器架构的改动描述清楚即可。欢迎你把自己的思考过程写在评论区,我们一起讨论,也欢迎你把这节课分享给需要的朋友,我们下节课再见!