最近公司的的视频App上了H.265视频格式,安卓平台上可以正常播放,iOS接入以后出现了一些问题:seek之后出现卡顿。本文分为两部分,第一部分会说明FFmpeg播放流程、并分析这个问题的成因;第二部分是如何解决这个问题。
1. 首先对概念做一下说明
1.1 视频编码
众所周知互联网上最重要的多媒体传播手段就是视频,为了提高用户体验:提升画质、减少延迟,视频编解码技术一直在不断发展,H.265-HEVC(后简称H.265)是继H.264/AVC之后更加优秀的视频编解码标准。H.265使用先进的技术用以改善码流、编码质量、延时和算法复杂度之间的关系,达到最优化设置。具体的研究内容包括:提高压缩效率、提高鲁棒性和错误恢复能力、减少实时的时延、减少信道获取时间和随机接入时延、降低复杂度等。
压缩率:同等画质H.265视频编码仅为H.264的60%。
传输效率:H.264可以实现2Mbps下标清视频传输;H.265则可以在1.5Mbps下实现全高清视频传输。
1.2 流媒体封装:HLS-(HTTP Live Streaming)
常用的流媒体协议主要有 HTTP 渐进下载和基于 RTSP/RTP 的实时流媒体协议。与基于UDP的RTP协议不同,HLS仅使用HTTP传输,因此可以穿过任何允许HTTP数据通过的防火墙或代理服务器。这也便于使用传统的HTTP服务器作为源,并广泛使用基于HTTP的内容分发网络来传输媒体流。
H.265视频编码在HLS封装下有两种容器选择,MPEG-2 Transport Stream chunks和fMP4。fMP4和MP4编码的主要不同在于:前者把视频的metadata从MP4的集中存储在开头改为分散存储在每个分段当中,可以想象的出这样更适合网络传输、减少用户等待时间。这里提到编码方式主要是因为对于H.265的视频,Apple在iOS上仅支持fMP4格式播放。
1.3 FFmpeg
FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。它包括了领先的音/视频编码库libavcodec等。简而言之FFmpeg支持从音视频从生产到解码播放的整个过程。
前面提到过iOS内置的AVKit框架仅支持fMP4播放,项目中的视频都使用H.265格式MPEG-2编码,所以这里选择FFmpeg作为播放核心。
FFmpeg使用分层模型分别处理多种协议、容器以及编码类型,这有点类似于TCP/IP协议的分层:
协议层:该层处理流媒体协议的数据解析与封装,包括http,rtmp,rtsp,file等
容器层:该层处理多媒体容器的解析和封装,包括mp4,flv,mkv等
编解码层:该层负责音视频编解码,包括h264,h265,mp3,aac等
原始数据层:该层负责原始音视频数据的处理,如视频像素格式转换,缩放,裁剪,过滤,音频重采样,过滤等,处理对象是pcm,yuv,rgb等原始数据。
设备层:负责音视频播放及采集
对应到每一层,FFmpeg使用不同模块对层进行了组织和封装,具体来说:
livavformat 作用于协议层和容器层。
libavcodec 作用于编解码层。
libswscale,libswresample,libavfilter作用于原始数据层。
libavdevice 作用于设备层。
libavutil 是基础公共模块,上面各个类库都会依赖于它。
2.卡顿问题分析
2.1 IJKPlayer
IJKPlayer 是一个基于 FFmpeg/ffplay 的轻量级 Android/iOS 视频播放器。实现了跨平台功能,同时为了方便客户端集成还提供了Android/iOS的构建工程;编译配置可裁剪,方便控制安装包大小;支持硬件加速解码,更加省电;提供Android平台下应用弹幕集成的解决方案。使用的开发者很多,在Github上可以看到的讨论和PR数量众多,社区也很活跃。
IJKPlayer为iOS平台提供了两套播放器API,其中一套封装了Apple自家的AVPlayer的实现,另一套则使用ffplay。ffplay在iOS/Android平台提供了相同的解码播放能力,因为考虑到AVPlayer性能和功耗方面存在一定优势,在当前版本之前我们使用的都是AVPlayer作播放实现,这套实现播放H.264编码的HLS+TS流媒体功能很稳定。
2.2 使用FFmpeg/ffplay
前面提到过H.265编码的HLS每个分片存在两种选择分别是MPEG-2 Transport Stream chunks和fMP4,目前公司使用的是HLS+TS的封装,因为Apple对H.265仅支持HLS+fMP4封装,所以在我们开发中引入ffplay替换掉了AVPlayer来支持流媒体播放。
2.3 跳集和卡顿问题
使用ffplay版本的App上线之后,用户反馈播放剧集时出现跳集,在当前剧集播放未完时没有任何征兆,直接跳转播放了下一集。因为是在App灰度发布期间,该问题对线上用户影响较大,我们立即停止了灰度发布,在iOS端播放器实现恢复为AVPlayer,视频也恢复为使用H.264编码。
在解决了线上问题之后,我们又重启了H.265播放开发,经验证安卓客户端集成FFmpeg后播放正常,跳集仅发生在iOS端。在排查跳集问题时我们发现,在频繁seek的过程中会偶现加载时间过长的问题,考虑到跳集偶发,相比之下卡顿问题可能更加影响体验,我们需要优先处理。
从日志输出可以看到从开始seek到缓冲完成内容开始播放,甚至有时候会达到10秒钟以上。因为对FFmpeg设计了解有限,想要在短时间内解决问题,阅读代码显然不是最好的选择。这里选择自下而上的方式,通过在FFmpeg代码中增加日志输出。
FFmpeg虽然可以在初始化配置时设置日志回调,可以简单的输出一些日志。
可以看到回调函数只提供了日志等级用来简单的做一下级别划分,如果有分类信息可以方便我们把定位某一类问题的日志放在一起分析,另外还有如下问题:
日志不够详细,可以增加文件名、函数名、代码行号和时间戳。
由于使用IJKPlayer基于FFmpeg/ffplay的实现,而IJKPlayer也使用了FFmpeg的日志函数av_log,这里希望对两者的日志加以区分。
IJKPlayer没有向App提供类似设置日志回调的API。
2.4 增加外部日志接口
在IJK和FFmpeg中分别增加extern日志声明,在日志函数中增加了分类信息、tag、文件、行号、调用函数等信息。在FFmpeg中,使用改进的日志版本替换了原有的实现,不影响原有日志输出。
通过增加日志我们对HLS播放流程有了一个基本的了解。
另外定位到卡顿问题发生在tcp.c的tcp_read函数中,因为h->rw_timeout超时参数值为0,导致ff_network_wait_fd_timeout最长耗时会达到ffmpeg内部默认上限,至此问题基本上确定。解决方法就是需要为tcp协议设置合理的超时时间。
3.HLS播放流程
通过上述调试和日志信息分析,我们基本上摸清了HLS媒体流的播放流程。下面做一个简单的总结:
3.1 主要结构体
实际上任何协议、封装、编码在FFmpeg中播放流程基本都一致,统一按照分层解析(解码)然后进行播放。这里首先列出FFmpeg中主要结构体的关系图(截图来源于:https://blog.csdn.net/leixiaohua1020/article/details/11693997)
对于使用者来说编解码部分相对比较固定,通常更为关心的是数据采集、传输和数据的解析。具体到播放来说数据的传输获取直接影响用户的收看体验,下面简述一下数据读取流程:
AVFormatContext是顶层对象,贯穿始终的数据结构,很多函数都要用到它作为参数。它是FFMPEG解封装(flv,mp4,rmvb,avi)功能的结构体。其中pb是AVIOContext的指针,负责承担带缓冲的文件(网络)读写操作。
解协议:
URLContext 封装了协议对象及协议操作对象,其中prot指向具体的协议操作对象,priv_data指向具体的协议对象。
URLProtocol 协议操作对象,针对每种协议,会有一个这样的对象,每个协议操作对象和一个协议对象关联,比如,文件操作对象为ff_file_protocol,它关联的结构体是FileContext。
AVIOContext 协议(文件)操作的顶层结构,这个对象实现了带缓冲的读写操作。
解封装:
AVFormatContext 主要存储视音频封装格式中包含的信息,包含音/视频、字幕等解码器信息。
AVInputFormat 存储输入视音频使用的封装格式。由AVFormatContext持有。
解码:
AVStream 存储音/视频流相关的数据。
AVCodecContext 存储该视频/音频流使用解码方式的相关数据,被AVStream持有。
AVCodec 包含该视频/音频对应的解码器,由AVCodecContext持有。
数据:
AVPacket 存储压缩编码数据相关信息的结构体。
AVFrame 存储原始数据(即非压缩数据,例如对视频来说是YUV,RGB,对音频来说是PCM)。
3.2 播放流程
av_register_all函数:
注册所有支持编解码器,注册所有协议、容器的解析器。
avformat_open_input(AVFormatContext **ps, const char *filename, AVInputFormat *fmt, AVDictionary **options)
打开成功后会返回初始化好的AVFormatContext给调用者。
参数 ps 包含一切媒体相关的上下文结构
参数 filename 是媒体文件名或URL
参数 fmt 是要打开的媒体格式的操作结构,因为是读,所以是inputFormat.
参数 options 是对某种格式的一些操作,是为了在命令行中可以对不同的格式传入
avformat_find_stream_info(AVFormatContext*ic, AVDictionary **options)
主要是读一些包(packets ),然后从中提取出流的信息,作用是更新AVStream这个结构体中的字段。
avcodec_find_decoder(enum AVCodecID id)
通过解码器名字找到解码器。
avcodec_open2(AVCodecContext *avctx, const AVCodec *codec, AVDictionary **options)
用于初始化一个视音频编解码器的AVCodecContext。
接下来就是一个读取帧,解析/解码然后渲染的循环,在读取、渲染完最后一帧后播放流程结束。具体说来对应到不同的协议会有不同的操作协议结构体,例如tcp/ts
4. 总结
在本文中我们通过为FFmpeg/IJKPlayer引入增强的日志输出,自下而上的摸清了FFmpeg中播放部分的基本工作流程,从而定位出了导致播放卡顿问题的具体原因。实际上FFmpeg的功能还有很多需要深入的学习和研究,比如音视频采集、滤镜部分,还有流媒体转换等一些列功能等。