介绍移动端Android/iOS硬解用法的文章有很多,本文将以笔者在实际开发工作中的经验为基础,抽出几个比较关键的部分来跟大家分享,旨在解决实际工作中可能遇到的花屏、(半边)绿屏、播放不完整等问题。
本文将以目前广泛应用的H.264编码的视频为例来说明,主要包含:H.264码流数据结构说明、解码器的初始化、seek、前后台切换、无缝分辨率切换、播放结束时的处理以及iOS如何避免下半部分绿屏的问题。
一、H.264码流数据结构说明
1. 理解码流数据结构的重要性
我们讲支持硬解,提高硬解兼容性,实际上就是对码流数据的结构进行处理以符合平台硬解要求,因此对码流数据结构的理解是必不可少的。
2. SPS/PPS与IDR帧
SPS(Sequence Parameter Set)序列参数集、PPS(Picture Parameter Set)图像参数集,包含了图像编码的各种参数信息,是作为解码器初始化所必须的参数信息。
IDR(Instantaneous Decoding Refresh)帧,也就是即时解码刷新帧,直观意思就是解码器在接收到IDR帧后会刷新参考帧缓存。IDR帧前后的视频帧不会有任何参考关系,解码器可以从任何一个IDR帧开始解码。
3. H.264的NAL单元
NALU结构图示:
H.264标准中,视频流是由NAL(Network Abstraction Layer)单元组成的(简称NALU),每个NALU中可能是IDR图像、SPS、PPS、non-IDR图像等。
上图中示意的NALU单元是以startcode方式分割的,关于NALU的分割方式将在后面说明。 另外,NALU内容中添加了防竞争字节,也就是说在一个NALU中,我们不可能再找到匹配的startcode.
H.264流的NALU组成图示
从上图可以看到,一个视频帧中可能可能包含多个NALU, 此时可以称该视频帧为多slice视频帧(一个NALU中包含该视频帧的一个slice)。
NAL Header的结构说明
其中nal_unit_type是我们关心的字段,该字段标识了当前NALU的类型,我们可以通过将NALU中第一个字节&0x1F的方式来得到NALU类型。NALU类型的具体定义如下图所示:
NALU类型定义
其中5代表上面提到的IDR帧数据,7、8分别代表SPS/PPS数据。
- AVCC与Annex-B
H.264码流分为AVCC与Annex-B两种组织格式。
• AVCC格式 也叫AVC1格式,MPEG-4格式,字节对齐,因此也叫Byte-Stream Format。用于mp4/flv/mkv等封装中。
• Annex-B格式 也叫MPEG-2 transport stream format格式(ts格式), ElementaryStream格式。用于TS流中(以及使用TS作为切片的hls格式中)。
这两种格式的区别有两点:
(1)NALU的分割方式不同;
(2)SPS/PPS的数据结构不同。
• AVCC格式使用NALU长度(固定字节,字节数由extradata中的信息给定)进行分割,在封装文件或者直播流的头部包含extradata信息(非NALU),extradata中包含NALU长度的字节数以及SPS/PPS信息。
• Annex-B格式使用start code进行分割,start code为0x000001或0x00000001,SPS/PPS作为一般NALU单元以start code作为分隔符的方式放在文件或者直播流的头部。
AVCC格式的extradata格式定义在“ISO_IEC_14496-15"文档中,Annex-B格式的SPS/PPS定义可以在"ISO_IEC_14496-10"文档中找到。
MediaCodec与VideoToolBox使用的数据格式
Android的硬解码接口MediaCodec只能接收Annex-B格式的H.264数据,而iOS平台的VideoToolBox则相反,只支持AVCC格式。
这就导致:
• 在Android平台硬解播放flv/mp4/mkv等封装的视频时,需要将AVCC格式的extradata以及NALU数据转为Annex-B格式;
• 在iOS平台播放ts或ts切片的hls视频时,需要将Annex-B格式的SPS/PPS NALU转为AVCC格式的extradata,以及将其他以size方式分割的NALU转为start code方式。
二、解码器的初始化及数据输入
初始化解码器,除了配置输入视频流的的编码格式、宽高以及输出格式之外,还需要配置一些额外的信息。 对于H.264视频,需要填充的就是我们前面提到的SPS/PPS信息。
1. Android平台MediaCodec的初始化
我们需要将Annex-B格式的两个SPS/PPS NALU单元通过setByteBuffer方法,以"csd-0"为名称(或SPS设为"csd-0", PPS设为"csd-1")设置到MediaFormat对象中,并调用configure接口配置到MediaCodec中去。
MediaCodec设置SPS/PPS信息的示例代码
MediaCodec mediaCodec = MediaCodec.createDecoderByType("video/avc");
MediaFormat mediaFormat = MediaFormat.createVideoFormat("video/avc", width, height);
// extradata中是Annex-B格式的SPS、PPS NALU数据
mediaFormat.setByteBuffer("csd-0", extradata);
// ...
mediaCodec.configure(mediaFormat, surface, 0, 0);
// ...
如上节所述,对于mp4/flv/mkv等封装,我们得到的是AVCC格式的extradata,需要先将该extradata转换为Annex-B格式的两个NALU, 然后用startcode进行分割。
Android平台在配置解码方式时,最好使用MediaCodec直接渲染到Surface的方式,一是可以避免不同硬件平台繁杂的YUV格式兼容,二是在解码渲染高分辨率的视频时可以有非常明显的效率提升。
2. iOS平台VideoToolBox接口的初始化
VideoToolBox针对AVCC格式和Annex-B格式的SPS/PPS信息设置,分别提供了两个方法:
• CMVideoFormatDescriptionCreate: 可以设置AVCC格式的extradata信
• CMVideoFormatDescriptionCreateFromH264ParameterSets: 用来设置Annex-B格式的SPS/PPS NALU信息(需要去掉startcode)
需要注意,iOS平台不支持隔行H.264视频的解码,需要在创建videoToolBox前从SPS中判断当前视频是否隔行编码。
3. 数据格式的转换
如前所述,Android平台只接受Annex-B格式以startcode分割的H.264 NALU;iOS平台则相反,只接受AVCC格式以size分割的NALU. 在原视频流格式不匹配时需要进行相应的转换。
iOS还有以下的一些限制需要留意:
(1)如果源视频流本身已经是AVCC格式,但NALU size的大小是3个字节,而非4字节时,需要转为4字节格式。具体的话,需要先更改extradata中标识NALU size的字段,然后每个视频帧中的NALU size都要改成4个字节。
(2)如果一个视频帧内有多个NALU(多slice),那必须将这些NALU打包到一个CMSampleBuffer中,一次性送给解码器。
三、seek时的处理
编码后的视频帧之间存在着参考关系,我们无法直接从任意一帧开始解码,只能从可随机访问帧开始,在H.264中就是IDR帧。
1. 从IDR帧开始解码
对于点播视频,mp4/flv/mkv的头信息中都会保存整个视频的IDR帧索引,seek时需要定位到原seek位置附近的IDR帧再送数据给解码器。 如果要实现短视频中的精确seek逻辑,可以先seek到离目标位置最近的上一个IDR帧开始解码,但不输出图像,直到目标位置的视频被解码出来。
2. 刷新解码器
进行seek操作时,除了要保证从IDR帧开始之外,还需要在送新的IDR帧数据前对解码器进行刷新操作。
• Android平台可以通过调用MediaCodec的flush()接口来实现。
• iOS平台则需要重新创建videoToolBox.
四、前后台切换
对Android、iOS平台,都存在App切后台,播放器渲染View被销毁而导致解码出错的情况。
1. 切回前台的处理
App切到后台时,iOS的videoToolBox session会失效,切回前台后原session也不能继续使用,需重新创建videoToolBox实例;Android平台在配置了Surface的情况下,如果Surface被销毁,则在切回前台时也需要配置新的Surface来重新创建并初始化MediaCodec.
如果我们要提高用户体验,实现前后台切换时的无缝播放,而不是重新拉流,那么可以在用户切后台的时候暂停播放,切回前台时重新创建解码器,继续从原位置开始播放。
不过参考前面seek章节的说明,我们恢复播放的位置很可能不是IDR帧,这种情况下就会出现切回前台后画面会先黑一段时间,直到下一个IDR帧被解码。黑屏的时间会跟视频流的IDR帧间隔有关,最差情况下黑屏时间接近IDR帧间隔。 为了尽量避免黑屏现象的出现,我们可以参考前面精确seek的处理,在解码过程中一直缓存当前GOP(Group Of Picture)的视频帧数据,在恢复时从当前GOP的IDR帧开始解码但不输出图像,直到恢复点。
不过上述方案也无法100%解决黑屏问题,解码恢复点前的视频数据本身会有时间消耗,GOP越大,解码恢复可能需要的时间也就越长,黑屏时间也就会越长。
2. Android平台使用TextureView避免Surface被销毁
对Android平台,我们也可以通过使用TextureView渲染来尽量避免Surface被销毁。
具体实现上,可以:
(1)在TextureView的onSurfaceTextureAvailable回调中保存当前创建的SurfaceTexture;
(2)App切后台时,TextureView的onSurfaceTextureDestroyed回调中返回false,不让系统销毁当前的SurfaceTexture;
(3)在下一次App切回前台,onSurfaceTextureAvailable回调中,将前面保存的SurfaceTexture通过setSurfaceTexture接口设置给TextureView,并销毁回调参数中传回的surfaceTexture;
(4)播放器销毁时,需要销毁保存的surfaceTexture.
五、无缝分辨率切换的处理
考虑到用户网络的差异性,以及不同时间段的拥堵状况不同,为了兼顾拉流清晰度与流畅度,我们可以通过实时检测用户的网络情况,并动态切换视频的分辨率、码率来提高播放体验。
rtmp直播,http/flv直播,hls直播以及hls点播可以支持动态分辨率切换。
分辨率切换时需要拿到新的SPS/PPS并重启解码器
• 对于rtmp, http/flv直播,以及mp4分片的hls视频,分辨率切换时我们能够拿到新的AVCC格式的extradata(使用ffmpeg解封装时这个信息是在AVPacket的sidedata中), 此时需要用新的extradata数据重新创建解码器,所需的分辨率信息可以从extradata中解析出来。
• 而对于ts切片的hls直播点播视频,SPS/PPS信息是以Annex-B格式保存在正常的NALU中,而且每个IDR帧前都会有SPS/PPS的NALU。对此,我们需要监控每个收到的视频包,获取其NALU类型,如果是SPS/PPS, 则从中解析出分辨率等信息,如果有变化,则用新的SPS/PPS重新创建解码器。
六、播放完成时避免遗漏最后几帧
前面我们提到过,编码后的视频帧之间存在着参考关系,而且存在双向参考帧(B帧)的视频流其解码输出顺序和输入的顺序是不同的,同时解码器在异步模式下也不会立即返回解码后的视频帧,这就导致我们在输入最后一帧数据给解码器后,可能还会有一些视频帧没有输出。
为了避免遗漏最后几帧的情况,我们需要做一些处理:
• Android平台需要给MediaCodec送入一个带有BUFFER_FLAG_END_OF_STREAM标记的buffer数据(可以是空buffer),然后等待MediaCodec输出带有该标记的内容,再销毁解码器,结束播放。
• iOS平台需要在送完最后一帧数据后,调用VTDecompressionSessionWaitForAsynchronousFrames接口,该接口会等待所有未输出的视频帧输出结束后再返回。
七、VideoToolBox兼容不标准的多slice视频
在iOS平台的硬解的实践中,我们可能会遇到如下图的这种情况(上面一部分有画面,下面部分是绿屏):
这种现象实际上就是多slice视频的组织格式不符合VideoToolBox的要求引起的。
以上图的视频为例,该视频流的每一帧是由3个slice构成的,对于VideoToolBox可以正常解码的组织格式应该如下图所示:
而该视频的帧组织方式则如下图所示:
可以看出,该视频混用了AVCC与Annex-B格式的分隔符,导致iOS VideoToolBox只能解码第一个slice单元,从而出现下半部分绿屏的情况。
• 对于这类问题视频的处理: 如果是源视频流可控,可以调整源视频流的打包方式,按第一种图示的方式打包。
• 对于不可控的场景,播放器也可以做下兼容:因为一个NALU中的内容一定是不包含startcode的,所以如果在一个NALU中找到了startcode,就可以将其处理成第一种图示中的格式。
想要阅读更多技术干货、行业洞察,欢迎关注网易云信博客。
了解网易云信,来自网易核心架构的通信与视频云服务。
__
网易云信(NeteaseYunXin)是集网易18年IM以及音视频技术打造的PaaS服务产品,来自网易核心技术架构的通信与视频云服务,稳定易用且功能全面,致力于提供全球领先的技术能力和场景化解决方案。开发者通过集成客户端SDK和云端OPEN API,即可快速实现包含IM、音视频通话、直播、点播、互动白板、短信等功能。