你好,我是展晓凯。今天我们来一起学习视频录制器的场景设计与架构分析。

前面我们用了9节课学习了音视频采集和编码方面的知识。现在是时候用一个视频录制器项目把这些知识点串联起来了。这个项目运行起来后,我们就可以采集音频和视频,最终保存成一个视频文件了。这个视频录制器是录播场景下非常重要的一个模块,之后你也可以以这个项目为基础,做一些扩展和改动(码率自适应、网络抖动处理、关键帧间隔设置等),改造成一个直播场景的视频推流器。
整个视频录制器项目,我会分为三部分来讲解,每一部分会解决一个核心问题。
接下来我们就一起来看一下视频录制器的场景分析和架构设计部分吧。
我们在工作中完成一个项目或者产品的迭代时,首先要做的就是场景分析,场景分析不是要写一堆别人看不懂甚至看都不看的文档,而是站在技术角度思考这个场景的输入、输出和存在的技术风险点,目的是辅助我们设计出一个好的架构来实现这个项目。
视频录制器运行起来之后,需要展示给用户一个视频预览页面,让用户可以看到摄像头里采集到的画面,用户将预览画面调整满意之后,就可以点击录制按钮。接下来录制器需要把用户的声音和画面全都录制下来,并编码生成一个MP4文件。在录制过程中,也可以让用户选择是否需要开启背景音乐,方便用户唱歌或展示舞蹈等。
场景看上去还是挺简单的,但是针对这个场景如何设计出一个合理的架构却是一个比较复杂的工作。从录制视频的角度来讲,每个平台都有自己独特的API可供开发者调用,但是要想合理地使用这些API,还需要通过架构的手段拆分出具体的模块,定义清楚每个模块的边界或者职责,再根据平台特性为某个技术模块确定实现细节。基于业务场景分析,录制器项目可拆分成两部分,一部分是音频,另一部分是画面(视频)。
我们先来看音频模块的架构设计示意图。

乍一看,你可能会觉得这个架构比较复杂,因为图里包含了两个平台的音频架构,同时链路里还包括了BGM的播放。不过,你别着急,我会带你来逐一分析。
我们先从最顶层来解读这张架构图,图最上边的一部分从左到右依次为Input、Output、PCM队列和Consumer,下面来逐一看一下这4个模块。
拆分完模块之后,接下来就是在Andorid和iOS平台确定技术选型来完成模块的职责。
架构图的第二行是Android平台的实现。注意,核心系统还是在Native层构建的。

输入模块主要分为两部分,一部分是采集人声,一部分是解码BGM。
输出模块也分为两部分,一部分是人声的耳返,另一部分是伴奏BGM的播放。
因为核心系统都是构建在Native层的,所以我们使用C++自己书写一个线程安全的链表,来实现队列功能,提供对应的Put、Get、Abort、Size等接口供生产者和消费者使用,为了方便消费者获取数据,可以把这个队列改造成一个Blocking Queue的形式。
消费者模块需要单独开启一个线程,来从数据存储模块里获取伴奏的PCM数据和人声的PCM数据。这里可以对PCM数据做一些处理,比如给人声增加音效、AEC、人声伴奏对齐调整。然后把两个PCM的Buffer合并成一轨音频数据,接着用MediaCodec或者libfdk_aac把PCM数据编码成AAC的码流,最终利用FFmpeg的Muxer模块把编码后的AAC数据封装到MP4文件的声音轨道里。
这样Andorid平台的技术选型就说完了,你可以对照架构图里Android部分再梳理一遍。接下来,我们看iOS平台的实现。
架构图的第三行是iOS平台的实现,相比Android平台,iOS会简单一些。

输入模块主要分为两部分,一部分是采集人声,另一部分是解码BGM。
然后使用一个Mixer的AudioUnit把人声和伴奏两轨声音合并起来,输出给下面的Output模块。
如果想要完成伴奏和耳返播放功能,这里使用RemoteIO这个AudioUnit的OutputElement,把MixerUnit输出的音频播放出来就可以了。接下来还需要实现把PCM数据放入PCM队列的功能,这就需要给AudioUnit注册一个回调函数,利用Converter的AudioUnit把转换成SInt16采样格式表示的PCM数据放到音频队列里。
我们可以使用C++自己写一个线程安全的链表,提供先入先出的接口来完成队列的功能,这个队列的代码可以和Andorid平台共享一份代码。
最后是Consumer模块的实现,开启一个线程在后台将PCM队列中的数据取出来之后,使用AudioToolbox或者libfdk_aac编码成AAC码流。最后利用FFmpeg把编码后的AAC数据封装到MP4文件的声音轨道里。这个模块主要是使用C++语言调用FFmpeg的API来实现的,所以可以和Android平台共享一份代码,你可以对照架构图的iOS部分再梳理一下。
音频架构分析得差不多了,下面我们看一下视频部分的架构。
相比于音频的架构设计,视频的架构相对简单一些,整体架构图如下:

可以看到架构图的第一行,和音频架构图类似,分为输入、输出、队列和消费者四个模块,每个模块完成的功能这里就不再重复了,接下来我们直接确定一下各个模块在两个平台的技术选型。
Input模块的实现,直接使用Android系统为我们提供的Camera来采集,需要注意的是,要以纹理回调的方式使用它。Output模块分为两部分,一部分是预览,通过使用OpenGL ES结合Java层的Surface(Texture)View来实现;另外一部分是编码,建议你优先使用硬件****编码器给视频编码。如果有兼容问题,使用libx264软件编码作为保底的方案来实现,最终编码成H264的数据。(可参考第16课和第17课的内容)
这里和第17课的不同之处是编码之后的H264数据不要直接写入文件,而是要放到第三个模块——H264的队列里。对于H264的队列,我们同样可以使用一个线程安全的链表来实现,链表里每一个节点元素都是一帧H264的数据,也就是一个Annex-B格式的NALU。
最后一个模块是消费者模块,我们在Consumer这个模块取出队列里的H264数据包,然后利用FFmpeg的Mux模块封装到MP4的视频轨道里,这就和之前封装到这个文件里的音频轨道组成了一个完整的MP4文件,也就是我们最终输出的MP4文件。
在iOS平台上,Input模块的实现自然会使用系统提供给开发者的Camera这个API来实现。Output模块,分为预览和编码,预览的实现直接使用EAGL和OpenGL再结合自定义的一个UIView来完成;编码的实现是使用VideoToolbox进行硬件编码,直接使用我们的ELImage框架来完成即可。我们第14节课和第18节课重点讲解过,这里的不同之处在于编码之后的H264数据不要写入文件里,而是应该放到H264的队列里。
第三个模块是H264队列模块,可以使用一个线程安全的链表来实现,链表里每一个节点元素都是H264的数据包,这个模块的实现可以和Android平台共享一份代码。
最后一个模块就是消费者模块,在Consumer模块取出队列里的H264数据包,然后利用FFmpeg的Mux模块把H264的包封装到MP4的视频轨道部分,和之前封装到这个文件中的音频轨道共同组成一个完整的MP4文件。这个模块主要使用C++语言调用FFmpeg的API来实现,也可以和Android平台共享一份代码。
以上就是我们视频录制器整体架构的设计和实现,但好的架构设计还需要给出具体的风险评估,接下来我们一起看一下。
整个架构的风险点有两个:
最后再补充一点测试用例方面的注意事项,在测试完App的Top机型和主流系统之后,要重点测试Android平台的硬件编码覆盖面,还要测试音视频对齐的问题,这里我们可以使用自动化检测音画对齐的工具来处理。
最后,我们可以一起来回顾一下。这节课我们从视频录制器的场景分析入手,设计出了一个视频录制器的架构。

音频的输入包括采集音频和解码伴奏,输出包含播放音频、把PCM数据存入队列里,消费者模块会从队列里取出PCM数据进行编码,最后合并到文件里。

视频架构会更简单一些,输入模块就是摄像头采集图像,输出模块包含两部分,一部分是让用户预览到图像,另外一部分是编码成H264的码流放到队列里,消费者模块从队列里获取数据并合并到最终的MP4文件里。
无论是音频部分还是视频部分,我们底层的设计都是Input、Output、队列和消费者模块,在音视频的架构设计中基本都包含这几个模块。其中Input、Output模块和平台的相关性是比较大的,而队列和消费者模块在两个平台的实现其实是一模一样的,所以我们可以把这部分代码抽象成统一的接口,使用C++来书写,跨平台同时运行在Android与iOS上,这样可以提升开发效率,降低维护成本。
我来考考你,如果让你基于这个架构设计一个推流器的架构,你思考一下要在此基础上加入哪些模块,这些模块的职责又是什么?欢迎把你的答案留在评论区,也欢迎你把这节课分享给更多对音视频感兴趣的朋友,我们一起交流、共同进步。下节课再见!