随着移动终端的普及和网络的提速,以短视频为媒介的内容成了大家普遍接受和喜欢的内容消费形式。但是短视频是如何从一个视频地址到我们能看见的音视频内容呢?我们都知道播放器就是用来完成视频从地址解析到视频渲染这个流程的集合。那在我们Android平台上播放器的发展和演进过程中,有哪些实现方式?他们背后都有些什么优缺点呢?对于一个内容消费者来说,在浏览短视频的过程中,哪些性能指标是影响用户体验的呢?技术人员对于这些性能指标有哪些可做的优化?以及在快速的版本迭代中如何保证海量用户的播放体验呢?带着这些问题,本文尝试从播放器的原理开始着手,梳理一下在Android客户端上的播放架构的演进,以及在播放体验的核心指标的优化上,针对不同场景所作出的各种优化。
本文目录结构:
图1:(目录)
一、播放器基本原理
以FFplay播放一个本地HEVC编码的MP4视频为例, 简单分析下从拿到URL-->渲染首帧的链路过程。
图2:(FFplay播放链路)
解协议
在播放视频前,我们一般会拿到一个视频的播放地址,如果是本地视频,就是一个文件路径;如果是一个在线视频,那么可能有多种流媒体协议,常见的如HTTP、RTMP、HLS、DASH等。解协议的过程就是通过拿到的播放地址判断出当前视频的流媒体协议,然后用对应的协议去获取媒体文件数据。
FFmpeg中内置了常见的流媒体格式协议的解析,对于一个视频url http:www.qq.com/test.mp4, 常见的解析的过程如下:
- 取出url中的协议头如"http"
- 和初始化好的协议列表中的协议名进行对比,如果匹配上则使用该协议解析器;这里会匹配上http协议,http协议的name就是:http,实现在http.c
- 如果是一个本地文件的地址,会解析为file协议,file协议的name就是:file,实现在file.c
解析完成后就会使用对应协议实现的获取媒体数据的方式来读取媒体流。像File协议的实现就是读取本地文件;Http协议的实现就是通过http请求的方式向服务器请求数据。
File协议:
解封装
视频有多种格式,如常见的有MP4、3GP、AVI、FLV、RMVB。一般来说视频的格式名就对应着他的封装协议名称。封装协议的主要作用就是将已经编码好的视频数据和音频数据按照协议规则放在一个文件中。
一个完整的视频文件中,除了有已经编码后的音视频信息外,一般还会有描述媒体数据的组织结构的信息。如MP4封装格式如下所示,就是一个一个的box及其嵌套,不同的box里面存储了不同的信息,MP4的所有信息都以box的方式进行组织,box可以相互嵌套。其中最重要的就是moov box和mdat box,在moov box中存储了描述音视频格式如视频宽高、分辨率、码率等相关的格式信息,也有如moov box其中嵌套的stbl包含了所有音视频sample的时间戳pts和在文件中的偏移位置offset的信息;而mdat box则完全是存储的压缩后的音视频数据。
解封装的过程就是通过moov box中的媒体结构信息,从mdta box中分离出Audio Track和Video Track,再把一份一份的视频数据或音频数据取出来的过程。
遍历box列表,在这个过程中要去下载moov box;这里常用的一个优化点是将存放数据的mdat放在最后一个box,来减少在prepare阶段的网络请求,来节省耗时,这里的原理可以看这篇的“为什么要把mdat放在文件末尾?”。
解封装的过程是通过读取moov box来获取媒体信息和解析媒体文件的组织结构的过程。以一个MP4文件的解封装过程为例,大致流程如下:
- 通过av_probe_input_format2找到该文件的封装格式,寻找封装格式的过程如下:
- FFmpeg会通过,初始化后,demuxer_list中会有一个存储了demuxer的list;
- 寻找的过程,就是从这上百个demuxer中找到最合适的demuxer。在av_probe_input_format3通过遍历每一个demuxer,调用demuxer的read_probe方法,探测获得该demuxer的探测分数,最后获得分数最高的一个;
- 以mp4的read_probe为例,主要就是通过检查文件的box来进行打分,通常来说,依次匹配的box为ftyp、moov,匹配没问题的话就会返回score:AVPROBE_SCORE_MAX(100分);
- 找到的匹配的demuxer为mov,mp4格式的name就是:mov,mp4,m4a,3gp,3g2,mj2,,实现在mov.c。
- 确定了封装格式后,会首先调用demuxer的mov_read_header方法来解析文件,本质就是通过mov_read_default遍历box树,初始化每个box到内存中。
- 在header解析过程中,mov_read_trak尤其重要,因为在trak这个box及其嵌套的box中,包含了这个媒体文件的所有基本信息和组织结构,如trak类型(音频/视频);trak的基本信息,如视频的宽高、时长等;还有sample在mdat中的组织形式,如每个sample的大小、位置等;还有stss中存放的关键帧列表等;
- 在解析完header后,就是通过mov_read_packet来获取在mdat中的编码数据。主要原理为通过mov_find_next_sample找到下一个sample,然后初始化其pos、size、dts、pts,并封装为AVPacket,然后将初始化好的packet放到缓冲区;
解码
目前视频常用的压缩格式为H264和H265,音频常用的压缩格式为AAC。解码的过程就是将这些按照压缩算法解码为可直接送给Surface渲染和AudioTrack播放的原始数据类型。通常视频是解码YUV或RGB格式,音频是解码为PCM格式。
使用FFmpeg自带的软解解码器大致的解码流程如下:
- 通过find_probe_decoder找到合适的解码器,详细寻找过程和寻找demuxer类似: 解码器也会在初始化的时候初始化好放在codec_list中;寻找的过程就是找到解码器的AVCodecID相等的即可;AVCodecID存放在track box中,是在解析视频header的时候初始化的,如果该视频是HEVC编码格式的,找到的就是hevc的decoder;
- 找到匹配的decoder为hevc,hevc格式的name就是:hevc,,实现在hevcdec.c;
- 找到对应的decoder后,先通过hevc_decode_init初始化;
- 解码的时候会单独在一个解码线程,通过读取解封装数据缓存区的数据来进行解码,然后将解码后的数据放入缓存池中。
音视频同步
在视频数据解码完成后,不会立即渲染到View上,还需要通过音视频同步机制,等到合适的渲染时机。
音视频同步主要分为三种:
- 音频时钟为基准:以当前正在播放的音频时钟基准,比较视频和音频的pts差值,如果视频过慢,则通过丢帧的方式进行追赶;如果视频播放过快,则一直渲染当前帧,直到音频跟上;
- 视频时钟为基准:以当前正在播放的视频时钟为基准,比较视频和音频的pts差值,这里和音频时钟为基准不同的是,这里音频是通过重采样的方式适当缩减或添加audio sample来达到同步的目的。
- 以外部时钟为基准:音频和视频在输出时,都需要和外部时钟进行对比,然后音视频按照各自同步的方法进行同步(视频丢帧或等待、音频重采样),外部时钟的更新依赖于最近同步过的音频时钟或视频时钟。
各方案优缺点对比:
图片来源:geminili 18-TalkOnPlayer-038
ffplay中音视频同步的处理主要在video_refresh、synchronize_audio、sync_clock_to_slave等相关函数中处理,详细过程比较复杂,这里不做深入讨论。
感兴趣的可以参考系列文章:https://zhuanlan.zhihu.com/p/44684432
首帧渲染
对于Android端来说,通常是渲染在SurafaceView或TextureView上,在渲染前也可以对视频帧做后处理,如超分、添加黑白滤镜等操作。
对于FFPlay详细起播一个视频的代码跟读,也整理了一份文档,感兴趣的可以参考:FFplay播放原理浅析
二、Android平台播放器演进
想要实现视频文件的边下边播,本质上有两个问题:首先,我们要确定视频的格式,才能解出视频流进行渲染,而这通常都是通过获取到文件的头部等信息(比如moov等)来确定的。其次要在文件满足播放的情况下进行播放,而不是等到文件全部下载完成,因此需要有一套完善的控制机制,什么时候要进行缓冲,要缓冲多久的数据。
系统播放器MediaPlayer
在Android平台上播放视频,最简单的方式就是使用系统自带的播放器MediaPlayer。MediaPlayer即可以播放系统本地的文件, 也可以对网络上的音视频文件进行边下边播,其实现架构大致如下图:
MediaPlayer具备大部分我们需要的播放器功能接口(prepare/start/pause...)和状态(IDEL/PREARING...),但是通过这个架构图我们可以看到MediaPlayer大部分的实现都在Framework层的NuPlayer里面,NuPlayer通过DataSOurce Extrator来下载视频数据 解析封装格式,得到对应的VideoTrack和AudioTrack,再都通过Decoder来解码数据帧,然后通过Render进行渲染。虽然NuPlayer有很好的架构,他也可以通过扩展Datasource Extrator来支持更多下载协议和封装格式,通过Decoder来扩展支持的解码格式,但这些对我们都是黑盒,我们无法自定义下载协议,支持的协议(支持列表)也有限;由于Android手机的厂商和机型繁多,MediaPlayer的硬解也存在很多兼容性问题。MediaPlayer还有一个点就是如果播放网络源视频的话,会遇到将缓存文件删除的情况,每次播放都有可能需要下载一遍视频,无法做到播放完了之后,以后就播放缓存文件。
总结来说MediaPlayer的优点就是接入简单, 方便;但缺点也很明显,我们无法控制播放和下载流程, 比如边下边播的缓存策略;当我们想做一些自定义策略的时候往往会很困难。
各模块可拔插的简易播放器
根据NuPlayer的实现框架,我们完全可以仿照系统的方式去实现一个播放器,而类似Extrator, Decoder这些其实都有系统的api组件, 额外只要控制好数据请求以及下载;再将各个流程组合起来, 本质就是一个播放器。
可自己控制的播放器的方式如下:
通过自研下载器将数据缓存到本地,开始起播后通过Mp4Parser用轮训的方式一直检查下载文件是否满足起播条件(通常我们认为下载到3~5秒的有效播放数据),如果达到起播条件,则使用系统自带的MediaExtractor MediaCodec解码出对应的视频帧或音频帧,然后通过音频时钟同步的方式在合适的时机对解码后的帧进行渲染。如果在播放过程中要进行seek等操作,就通过Mp4Parser解析seek的时间点,然后通知下载器开始下载对应位置的数据。
相比系统的MediaPlayer,我们可以自定义不同策略的播放下载策略,同时每个模块对于我们都是透明的方式让我们可以有很多可以优化的空间,例如可以使用自研的下载器,下载器的实现可以使用QUIC协议、IP竞速、连接复用等优化。
但是我们发现这样子实现的架构较为复杂,主要是将下载器和Mp4Parser的耦合比较严重,维护成本较高。而且这里的Parser只能对于Mp4协议有效,如果要支持新的播放协议,又得增加新的Parser。对于多格式的支持,FFmpeg一直是做的最好的。所以将解封装的逻辑交给FFmpeg,中间用本地代理和FFmpeg的解协议模块与下载器进行隔离。既能通过FFmpeg有效的扩展各种各种协议和格式,也能利用FFmpeg中的各种缓冲区,进行高效的解封转、解码。
总结来说这个方案的优点就是可以方便的定义一些我们想要的策略; 但是模块之间的耦合会比较严重, 扩展能力有限。
各个模块可拔插的高性能播放器
我们可以引入FFmpeg的libavformat和libavcodec来支持更多的封装格式和编码格式,让播放器能力扩展更加方便。通过引入本地代理服务器的方式将下载器和解协议、解封装等模块解耦,让各个模块的维护成本也更低,这样我们就能得到一个类似于如下的播放架构。Downloader将数据下载缓存到LocalCache缓冲区。FFmpeg的解封装模块向本地服务器请求数据,先检查LocaCache,如果有就直接返回本地缓存,没有则通过Dowloader向服务器请求;解封装模块将取出的未解码的Video Package和Audio Package放到PackageQueue缓冲区进行缓存;解码模块轮询的向PackageQueue取数据,有数据则解码,无数据则进行等待,然后将解码后的可渲染或可播放的数据放到FrameQueue中。渲染模块一直检查Video和Audio的FrameQueue,然后在同步机制下,选择合适的时机将对应的视频帧送给Surface进行渲染,将对应的音频帧送给AudioTrack或OpenSL ES进行渲染,完成视频的播放;
三、播放链路分析
在播放视频的过程中,除了能成功播放视频,播放过程中不卡顿外,能不能在点击视频时瞬间起播,是一个在用户体验上非常重要的点。我们把从获得视频地址到首帧渲染这个链路的耗时称为首帧耗时,想要优化视频播放过程中的首帧耗时,我们需要知道在拿到一个视频播放地址后到首帧渲染之间,播放器都进行了哪些步骤,然后找到其耗时点,进行针对性优化。不同的业务场景下,播放行为和播控策略不尽相同,最常见的就是冷启动场景和滑动场景,我们也可以根据场景的特性,采取不同的播控策略。
对于业务方来说,启动一个播放器,主要是以下流程,其中在解码器性能一定的情况下,大部分的耗时都是在prepare-->prepared这个阶段, 这个过程主要的工作就是解协议、解封装。
通过上面的分析,我们将解协议和解封装过程中的耗时部分在图2:(FFplay播放链路)用粉红色和绿色标记出来了。
解协议
第一个点:在解析播放文件的过程中,我们需要下载足够的播放量才会开始对数据进行格式的探测,这里可以通过探测时所需buffer大小的调整节省一部分耗时;找到对应的协议格式(url_find_protocol)、封装格式(av_probe_input_format3)和编码格式(find_codec)是一个复杂的过程,但是对于业务场景比较单一的情况来说,如果我们的协议格式、封装格式和编码格式基本都是确定的,可以通过精简这部分逻辑,来减少这一部分的耗时;
第二个点:刚才在解封装流程中提到的,一定要把mdat box放在末尾,防止在preparing过程中多发网络请求;
解码
第三个点:解码阶段,使用MediaCodec硬解的解码效率高于FFmpeg自带的软解,尽量提高设备的硬解覆盖率;
第四个点:采用更先进的编码格式,在主观清晰度相当的情况下,HEVC(H265)的压缩效率高于AVC(H264),意味着同样的视频可以有更低的码率,减少首帧需要下载的数据量。
第五个点:短视频在手机上拍摄上传时,从拍摄编辑到消费播放的流程如下,从最开始的YUV/PCM进行前处理、在进行编码为H264/AAC,再通过编辑流程,最后生成一个完整的MP4文件。在生成MP4文件会将文件上传到服务器,并将这个文件作为原始档位,为了兼顾视频质量和带宽,一般我们会把原视频转码为多个档位,根据手机端的不同配置和网络环境播放合适的档位。
渲染
目前Android端进行视频帧渲染的主要有SurfaceView和TexterView。TexterView是从5.0才开始有的,所以对于低端设备的支持只能选择SurfaceView。在实现上,SurfaceView因为拥有自己的Surface以及双缓冲设计,这让他可以将Surface的渲染放在单独的线程进行,拥有更高的渲染效率,但是SurfaceView不支持移动、旋转、缩放等动画。TextureView支持相关动画,但相对来来说会需要更多的内存。所以根据机型设备及对应的应用场景进行选择即可。
详细二则的差别,可参考下列文章
Android 5.0(Lollipop)中的SurfaceTexture,TextureView, SurfaceView和GLSurfaceView
四、冷启动场景下的首帧优化
冷启动场景下能做的优化空间较小,主要两个思路,如下,除此之外我们也可以在APP冷启动的时候,在APP启动最早的时候(Application创建的时候)开始异步初始化播放器组件,让播放器尽早Ready。
五、连续滑动场景下的首帧优化
连续滑动场景下由于存在多次播放行为、滑动切换,可以做的策略相对较多,下面分开阐述下。
预加载
预加载的目的就是让播放器在起播时能尽量快的拿到数据初始化,从而解码出第一帧。但是考虑到带宽成本,预加载多少数据量的控制就是关键的。可以考虑从以下两个指标来判断预加载多少数据:
- 尽快首帧:MP4的MOOV头部的数据量。
- 播放过程不卡顿:通过后台的机器学习,判断当前视频的热点区域,然后算出对应数据量大小
在具体操作时优先保证moov头部数据量的预加载,带宽充足的话,再保重热点区域的数据量的下载。这样才能做到当用户看到视频的时候,数据已经满足启播的条件。
播放器池
在连续滑动场景中,我们会面临较频繁的播放器初始化和销毁。但是对于一次播放行为来说,除了关键的一些Demuxer、Decoder组件必须销毁重建。一些消息线程、各个缓冲区则是可以进行重置,然后在下次播放时使用。所以将划走的播放器进行复用也可以节省部分开销。
在具体实践时,由于32位和64位的机型内存大小不一,在不同的平台上可以采取不同的缓存策略。
预解码&&预渲染
目前滑动场景下的视频播放,为了更好的切换效果和用户体验,可以考虑通过封面图占位的方式来等待视频首帧渲染,然后隐藏封面。这里容易出现两个问题:
- 首帧耗时太久,一直停留在封面;
- 封面和首帧差异过大,切换的过程比较突兀。
对于这两个问题,我们可以在播放器性能和封面隐藏的策略上做出优化:
- 在未起播下个视频前,就启动下个视频的解码流程,然后提前渲染首帧,当用户画到下个视频时已经是首帧,就看不到封面了;
- 如果没有命中预缓存,那么在隐藏封面时,可以通过一个50~100ms的消失动画来平滑过渡。
如果要做预渲染的话,这里有个细节需要注意,那就是下一屏TextureView的Surface需要到onSurfaceTextureAvailable状态,才能设置给MediaCodec进行解码上屏。有两种方式做到:
- 通过将下一屏的TextureView在当前屏幕上显示一个像素点的方式,让其提前available;
- 通过LayoutManager的getExtraLayoutSpace方法,让RecyclerView能提前让下一屏的TextureView达到onSurfaceTextureAvailable状态。
解码器复用
解码器复用最早是Google在ExoPlayer中使用的方案。其原理如下:我们在使用MediaCodec进行硬解的时候,都需要经过create、configure、start等初始化操作(如下图)。但是初始化这个过程也是较为耗时的过程,如果在滑动场景下,我们下一个视频能够使用之前已经初始化过的MediaCodec进行解码,那么就可以节省这个初始化的耗时。这对于一些性能较差的手机来说,有200 ms的优化空间。但是在实践过程中,可能对于不同的厂商和机型,有一些兼容性问题需要处理。
MediaCodec使用流程:
六、其他优化点
由于目前的视频播放组件大多数是以下结构,即本地会创建一个local server,player通过链接本地Server来读取数据,这里对于冷启动场景的话,做的更极致一些可以创建一个ResourceLoader来代替Local Server,节省和Local Server建连的耗时。但是对于滑动场景来说这个优化微乎其微。
还有一些关于下载器的优化,如下:
- 使用Quic协议代替TCP协议;
- IP竞速;
- 连接复用;
- 多连接。
七、播放质量体系
为了应对快速迭代,我们需要一套完整的播放质量体系。该质量体系除了能帮助我们及时发现问题外,也能在出现问题外,通过各维度的数据聚合快速的分析并解决问题。我们常用的视频播放的关键指标主要有三个:首播耗时, 成功播放率, 播放卡顿率等,其他的如缓存命中率、下载速度、档位分布等更细维度的在分析问题时,也特别有用,具体就不一一展开了。
本文通过对播放流程的梳理,播放架构的演进过程,粗略分析了播放链路上的可优化点;在实际应用中针对冷启动场景和滑动场景上的策略优化也做了简单介绍。但是面对外网复杂的网络情况,Android端各种高、中、低端的海量机型,无论是预下载、预渲染策略、还是网络传输策略等各种策略,仍有非常多需要精细化的去优化的点,才能让尽量多的用户都能获得更好的播放体验。