音视频全链路技能分析之音视频消费侧技能树
各行各业都有鄙视链。娱乐圈的,拍电影的看不上拍电视的。IT圈的,C/C++工程师看不上Java、python、php这些搞高级API的小伙子。程序员之间,“文人相轻”的事情常有,但是平心而论,技术圈的事情确实有难易之分,工作有等级,那么技能就当然有高低。技术分高低,本地就是给我们一把尺,丈量一下自己水平,掂掂自己在领域中所处的位置。
一头扎进音视频的领域,才发现这行真是个无底洞,目前还还在半路行走,只能不断鞭策自己快点行动,快点学习。但是学习得有一个方向,定位要准,目标要明确。我还是根据自己的经验将音视频领域分为四个主要的工作方向。
- 音视频消费侧工程师
- 音视频生产侧工程师
- 音视频传输侧工程师
- 音视频算法工程师
四个不同的方向,所需要的技能侧重点是完全不同的。方向本身没有什么高级和低级的区分 ,但是技能是可以看出一个人研究的深度的。本人也处于音视频技能树的攀爬阶段,道阻且长,任重道远啊。虽然有些领域不是很通,但我可以以我现在的知识掌握程度,分享一下音视频领域的技能树。
音视频消费侧工程师
音视频消费工程师,其实就是播放端,做播放器。有人会说,只是将视频播放出来,这个容易,Android上不是有自带的MediaPlayer,iOS上也有VideoToolBox,就以Android端为例吧,MediaPlayer性能是差点,但是用还是没问题的。
稍等,你刚刚算是说到重点了,既然说到播放器,那就建立一些评价播放器的维度吧。

造轮子,还是复用轮子,这是一个问题,起步阶段,还是建议复用轮子,用什么轮子?建议看下我之前的一篇分析文章:Ijkplayer、ExoPlayer、VLC播放器综合比较,视频播放器很多种,但是从根上面来讲的话,原理都是一直的。目前的播放器有两个流派:
- ffmpeg的ffplay流派
- VLC的pipeline流派
其他的像KmPlayer和PotPlayer,都是从这两种衍生出来的。
ijkplayer就是基于ffplay,VLC自成一体,VLC原始方案要很早了,可以追溯到1994年,那时候互联网还在襁褓中。一切都在草创阶段,VLC走得相当坚实,目前VLC也是最好的跨平台播放器,但是缺点也还是存在的,就Android平台,VLC的包实在太大了,16M,任何一个公司都不会允许一个播放器就占用16M,这个是一个缺点。但是就播放器体验和性能而言,我觉得VLC还是要强一些。具体大家看我上面的分析文章吧。
说了这么多铺垫,那么播放器的核心流程是什么?
1.网络请求
网络请求有啥难的?直接用HttpURLConnection请求数据,对于大文件分range请求就行了啊。如果你是这么想的,你把播放器的网络请求模块想简单了。
协议支持、缓存策略、网络全链路控制、加载控制、关键帧加载优化。任何一项拿出来都是需要很大的工作量的。你用开源库的可以,当时你要看懂开源库的一些东西,发生问题的时候能做到全链路排查。
协议支持:
除了我们熟知的http和https协议,一些流媒体协议rtmp、rtsp等协议也是需要支持的,就以常用的rtmp协议为例,可以是http包裹着rtmp流,也可以rtmp协议直接封装流。两种有一点区别,http包括这rtmp流,可以看成是http-flv,当作http请求。按照标准的http协议对接即可。如果是rtmp协议封装流,那就要对rtmp 流中的chunk数据解包,然后按照正常的视频解析流程进行。一篇文章搞清楚直播协议RTMP 这篇文章能帮助你理解rtmp协议内容。
缓存策略:
你是希望播放时无限缓存还是播放多少缓存多少,还是压根就不缓存。那是先缓存到本地播放读本地数据,还是网络加载时存储,播放还是读网络数据。这是两种思路,看你的需求。如果你只是想做简单一点的边下边播,那就播放多少下载多少。如果复杂一点的,建议本地代理方式,这种方式就是直接下载视频到本地,播放器通过本地搭建的Socket服务读取本地已经下载好的文件部分。
网络全链路控制:
播放器核心的延时就是网络,对网络的了解,绝对有助于我们优化播放器的播放速度、性能,网络整条链路有很多段。DNS解析、建连、请求头部、返回response 头部、请求body、返回response数据等等。我们单纯使用HttpURLConnection不能满足这些要求。java层的OkHttp可以满足我们的要求,但是需要稍微定制一下,这需要你掌握OkHttp的代码,OkHttp是一个非常复杂的网络库,我建议任何程序员都有必要看看,Android 8.0以上底层的网络请求也是直接将OkHttp的代码拿过来用了。native层的网络库选择可以考虑一下cronet,但是cronet是单线程的,效率肯定要逊色一些。也可以直接考虑ffmpeg的network模块,ffmpeg的network直接复用了底层的tcp udp模块的源码,控制的粒度更细,对我们网络知识的要求更高,后续我会专门发文阐释的。了解网络全链路是有很大的好处的,更好的优化播放过程,或者说帮我们量化视频url的请求时间,建立一个更加全面的播放请求全过程。
加载控制:
播放器播放视频会加载数据,有一定的加载策略的,以ExoPlayer而言,LoadControl就是ExoPlayer的加载控制策略接口,原理就是已加载的buffer size设置在 Min Buffer到Max Buffer之间,小于Min Buffer就继续loading,大于Max Buffer就停止loading,在Min Buffer和Max Buffer之间保持现状。这是一种比较好的加载控制策略。既可以满足保持足够的播放预存量,也能控制loading的频次。
关键帧加载优化:
我们只是视频是I帧、P帧、B帧组成的一组帧序列.I帧是帧内编码帧,又称intra picture,就是关键帧,关键帧的意思就是不借助其他帧数据,只通过I帧自身就可以解码成一张完整的图片。P帧是前向预测编码帧,又称predictive-frame,通过充分将低于图像序列中前面已编码帧的时间冗余信息来压缩传输数据量的编码图像,也叫预测帧。P帧需要参考其前面的一个I frame 或者B frame来生成一张完整的图片。B帧是双向预测内插编码帧 又称bi-directional interpolated prediction frame,既考虑与源图像序列前面已编码帧,也顾及源图像序列后面已编码帧之间的时间冗余信息来压缩传输数据量的编码图像,也叫双向预测帧;B帧则要参考其前一个I或者P帧及其后面的一个P帧来生成一张完整的图片。上面我们了解了视频帧的基本分类,在极端情况下,我们只解析关键帧,达到基本的播放体验。当然这种情况下不是很好,不过也是权衡网络差等各方条件的妥协。视频直播的时候这样的优化很有帮助。网络优化是一个很大的命题,基本上什么应用都会用到,掌握网络优化的要义,不是会用API就可以的。
2.封装格式
视频的封装格式,就像是人身上穿着的衣服。因为视频本身是一个个流数据的综合体,视频本身包含音频流、视频流、字幕流,甚至还不止一个音频流。这么多流数据总要整合起来,对外合成一个文件整体。合成一个文件的过程就是封装过程,反过来我要解析其中的流数据,就要解封装。封装格式有哪些呢?MP4、FLV、TS等等,之前我专门分享了这些封装格式:《多媒体文件格式剖析:MP4篇》 《 多媒体文件格式剖析:FLV篇》 《多媒体文件格式剖析:TS篇》,大家可以简单回顾一下。解封装的过程实际上就是就是按照此类封装格式的要求,逐次脱掉视频衣服的过程。
还是要讲一些ExoPlayer,大家看到ExoPlayer的时候,看到代码量还是挺多的,那是因为每一种封装格式都需要逐行解析。封装解析工厂类是DefaultExtractorsFactory,ExoPlayer支持的封装格式都在这里了。什么MP4、AC3、FLAC、MKV等等。要做到对这些封装格式的支持,你必须要了解这些视频封装格式的具体内容。这儿不会展开代码,后面会专门出专题将播放器性能优化。有人会说,解析这些封装格式是播放器做的工作,我一定要了解吗?凡是优化一些东西到极致,必须要深入了解原理,例如我在熟悉了MP4封装格式之后,我知道有些字段是不必要的,但是很多,读起来有点耗时,在熟知MP4格式的前提下,我们就可以不读这些字段。
3.音视频编码
脱完了视频的衣服,发现里面包裹着的是一个个流数据,音频流,视频流,有的还有可能有字幕流。这些音频流和视频流是编码过的,编码就是压缩,只不过说法不同罢了。以常用的视频编码H264而言,视频压缩非常有必要。每一张图片,都是由像素组成的,假设为 1024*768(这个像素数不算多)。每个像素由 RGB 组成,每个 8 位,共 24 位。我们来算一下,每秒钟的视频有多大?30 帧 × 1024 × 768 × 24 = 566,231,040Bits = 70,778,880Bytes,如果一分钟呢?4,246,732,800Bytes,已经是 4 个 G 了。从这么看来非常有压缩的必要。
那么压缩或者说编码的基于的原则是什么?
(1)空间冗余:图像的相邻像素之间有较强的相关性,一张图片相邻像素往往是渐变的,不是突变的,没必要每个像素都完整地保存,可以隔几个保存一个,中间的用算法计算出来。
(2)时间冗余:视频序列的相邻图像之间内容相似。一个视频中连续出现的图片也不是突变的,可以根据已有的图片进行预测和推断。
(3)视觉冗余:人的视觉系统对某些细节不敏感,因此不会每一个细节都注意到,可以允许丢失一些数据。
(4)编码冗余:不同像素值出现的概率不同,概率高的用的字节少,概率低的用的字节多,类似霍夫曼编码(Huffman Coding)的思路。

上面介绍关键帧的时候介绍过I、P、B帧之间的区别,I 帧最完整,B 帧压缩率最高,而压缩后帧的序列,应该是在 IBBP 的间隔出现的。这就是通过时序进行编码。这儿只是给出一个帧序列的例子,帧序列可以有很多种排列的。

在一帧中,分成多个片,每个片中分成多个宏块,每个宏块分成多个子块,这样将一张大的图分解成一个个小块,可以方便进行空间上的编码。尽管时空非常立体地组成了一个序列,但是总归还是要压缩成一个二进制流。这个流是有结构的,是一个个的网络提取层单元(NALU,Network Abstraction Layer Unit)。变成这种格式就是为了传输,因为网络上的传输,默认的是一个个的包,因而这里也就分成了一个个的单元。

每一个 NALU 首先是一个起始标识符,用于标识 NALU 之间的间隔;然后是 NALU 的头,里面主要配置了 NALU 的类型;最终 Payload 里面是 NALU 承载的数据。
在 NALU 头里面,主要的内容是类型 NAL Type。0x07 表示 SPS,是序列参数集, 包括一个图像序列的所有信息,如图像尺寸、视频格式等。0x08 表示 PPS,是图像参数集,包括一个图像的所有分片的所有相关信息,包括图像类型、序列号等。
在传输视频流之前,必须要传输这两类参数,不然无法解码。为了保证容错性,每一个 I 帧前面,都会传一遍这两个参数集合。如果 NALU Header 里面的表示类型是 SPS 或者 PPS,则 Payload 中就是真正的参数集的内容。如果类型是帧,则 Payload 中才是正的视频数据,当然也是一帧一帧存放的,前面说了,一帧的内容还是挺多的,因而每一个 NALU 里面保存的是一片。对于每一片,到底是 I 帧,还是 P 帧,还是 B 帧,在片结构里面也有个 Header,这里面有个类型,然后是片的内容。
这样,整个格式就出来了,一个视频,可以拆分成一系列的帧,每一帧拆分成一系列的片,每一片都放在一个 NALU 里面,NALU 之间都是通过特殊的起始标识符分隔,在每一个 I 帧的第一片前面,要插入单独保存 SPS 和 PPS 的 NALU,最终形成一个长长的 NALU 序列。
又有同学开始吐槽了,Android有MediaCodec,我要知道这么多干什么?是的,MediaCodec很牛皮,轮子已有的情况下你了解这些你走得更快,轮子没有的情况下你就可以造轮子。MediaCodec有一些问题,(1)超高清的视频MediaCodec hold不住;(2)复杂的播放场景,MediaCodec不行,例如直播,如果直播过程中频繁的码流切换,你会发现使用MediaCodec简直是天坑;(3)MediaCodec的实例是受硬件控制的,你的应用可以用,别的应用也能用,有时候你用着用着就崩溃了,还不一定是你的原因。当然我们要相信MediaCodec在进步,在发展。
4.音视频同步
从上面的流程分析来看,我们分离出音频流和视频流,就要分别起线程去解码音频和解码视频。如果简单的按照音频的采样率与视频的帧率去播放,由于机器运行速度,解码效率等种种造成时间差异的因素影响,很难同步,音视频时间差将会呈现线性增长。所以要做音视频的同步,有三种方式:
- 参考一个外部时钟,将音频与视频同步至此时间。我首先想到这种方式,但是并不好,由于某些生物学的原理,人对声音的变化比较敏感,但是对视觉变化不太敏感。所以频繁的去调整声音的播放会有些刺耳或者杂音吧影响用户体验。(ps:顺便科普生物学知识,自我感觉好高大上_)。
- 以视频为基准,音频去同步视频的时间。不采用,理由同上。
- 以音频为基准,视频去同步音频的时间。所以这个办法了。
比较推荐的做法还是以音频为准,因为人的感觉还是对声音比较敏感一些。视频画面会有视频暂留,但是声音一般不会。目前ffplay和ExoPlayer都是以音频为基准来完成音视频同步的。
介绍一下音视频同步的重要参数DTS和PTS,音视频中都有DTS与PTS。
DTS ,Decoding Time Stamp,解码时间戳,告诉解码器packet的解码顺序。
PTS ,Presentation Time Stamp,显示时间戳,指示从packet中解码出来的数据的显示顺序。
音频中二者是相同的,但是视频由于B帧(双向预测)的存在,会造成解码顺序与显示顺序并不相同,也就是视频中DTS与PTS不一定相同。
5.OpenSL 介绍
OpenSL ES (Open Sound Library for Embedded Systems)是无授权费、跨平台、针对嵌入式系统精心优化的硬件音频加速API。它为嵌入式移动多媒体设备上的本地应用程序开发者提供标准化, 高性能,低响应时间的音频功能实现方法,并实现软/硬件音频性能的直接跨平台部署,降低执行难度,促进高级音频市场的发展。简单来说OpenSL ES是一个嵌入式跨平台免费的音频处理库。ndk上是有这个动态库的。

6.TextureView Or SurfaceView
渲染视频画面,我们可以用Android原生提供的TextureView或者SurfaceView,也可以使用OpenGL ES配合GLSurfaceView来完成。
OpenGL ES主题比较宏大,我后续会专门讲解一下,不在本文展开了。针对TextureView和SurfaceView两种渲染View做一些简单比较。
SurfaceView:
SurfaceView可以在一个独立的线程中进行绘制,不会影响主线程,使用双缓冲机制,播放视频时画面更流畅。当然缺点是SurfaceView中的Surface不在View hierachy中,它的显示也不受View的属性控制,所以不能进行平移,缩放等变换,也不能放在其它ViewGroup中。SurfaceView 不能嵌套使用。
双缓冲:在运用时可以理解为:SurfaceView在更新视图时用到了两张Canvas,一张frontCanvas和一张backCanvas,每次实际显示的是frontCanvas,backCanvas存储的是上一次更改前的视图,当使用lockCanvas()获取画布时,得到的实际上是backCanvas而不是正在显示的frontCanvas,之后你在获取到backCanvas上绘制新视图,再unlockCanvasAndPost(canvas)此视图,那么上传的这张canvas将替换原来的frontCanvas作为新的frontCanvas,原来的frontCanvas将切换到后台作为backCanvas。例如,如果你已经先后两次绘制了视图A和B,那么你再调用lockCanvas()获取视图,获得的将是A而不是正在显示的B,之后你将重绘的C视图上传,那么C将取代B作为新的frontCanvas显示在SurfaceView上,原来的B则转换为backCanvas。
TextureView:
TextureView支持移动、旋转、缩放等动画,支持截图,但是缺点是必须在硬件加速的窗口中使用,占用内存比SurfaceView高,在5.0以前在主线程渲染,5.0以后有单独的渲染线程。

播放器播放视频应该怎么选择?
从性能和安全性角度出发,使用播放器优先选SurfaceView。
(1)android 7.0以上系统SurfaceView的性能比TextureView更有优势,支持对象的内容位置和包含的应用内容同步更新,平移、缩放不会产生黑边。在android 7.0以下系统如果使用场景有动画效果,可以选择性使用TextureView。
(2)由于失效(invalidation)和缓冲的特性,TextureView增加了额外1~3帧的延迟显示画面更新。
(3)TextureView总是使用GL合成,而SurfaceView可以使用硬件overlay后端,可以占用更少的内存带宽,消耗更少的能量。
(4)TextureView的内部缓冲队列导致比SurfaceView使用更多的内存。
(5)SurfaceView内部自己持有surface,surface 创建、销毁、大小改变时系统来处理的,通过surfaceHolder 的callback回调通知。当画布创建好时,可以将surface绑定到MediaPlayer中。SurfaceView如果为用户可见的时候,创建SurfaceView的SurfaceHolder用于显示视频流解析的帧图片,如果发现SurfaceView变为用户不可见的时候,则立即销毁SurfaceView的SurfaceHolder,以达到节约系统资源的目的。
结束语
将一个播放器的原理搞得明明白白是一件不容易的事情,从0开始搭建一个播放器更是了不起的事情,但是非遥不可及的事情,世上无难事,只怕有心人。后续的分享的主题是:ExoPlayer原理剖析、自定义播放器实现、ijkplayer原理剖析、播放器性能优化专项。
感谢关注公众号JeffMony,持续给你带来音视频方面的知识。
