ExoPlayer里里外外之:流媒体播放与数据结构
播放器中的Buffer(从source读到视频数据经过处理送给Decoder解码之前存放视频数据的缓冲,“source->Buffer->decoder”)设计往往很重要,涉及读、写、跳转等操作的效率,流媒体播放器更是如此,经典的设计比如rolling buffer,还有叫ring-buffer的,简单理解就是一个数组然后让首、尾连起来,通过读指针和写指针的移动来控制读写的位置更新。
ExoPlayer这个Buffer实做是怎么样的?上一篇文章我们有提到DataQueue和InfoQueue,就是说这个Buffer是个Queue,就是先入先出(FIFO)的队列,下面会具体描述下这个Queue的细节。ExoPlayer中除了用到这个Queue,还有用到别的数据结构,也结合流媒体的相关知识一并描述。
本文包括如下部分:
1.基于Queue实现的Buffer
2.SparseArray与HLS TS的解析
3.Dash fMP4与Stack
4.HLS的演进:fMP4
基于Queue实现的Buffer
DefaultTrackOutput的DataQueue的定义如下:
private final LinkedBlockingDeque<Allocation> dataQueue;
LinkedBlockingDeque是Android SDK中提供的使用双向链表实现的Deque,Node的类型使用ExoPlayer自己定义的Allocation,定义如下:
public final class Allocation {
public final byte[] data;
private final int offset;
}
每个Allocation的大小都是64K bytes,也就是说链表里面的每个Node的大小是64K bytes(一个Allocation对象),DataQueue是由一连串64K bytes大小的Allocation对象构成的,示意图如下:
DefaultTrackOutput::sampleData在往DataQueue写入数据的时候,prepareForAppend判断当前allocation(lastAllocation)是否已经满了,不满就把当前Sample写入,如果满了,就重新new一个64K bytes的Allocation,lastAllocation重新指向刚才新建的Allocation,继续写入数据。
把一个个64K bytes大小的小buffer串联成一个大buffer后,还需要InfoQueue的配合来管理这个DataQueue;InfoQueue存放Sample的metaData信息,通过DefaultTrackOutput::sampleMetadata调用infoQueue.commitSample写入每个Sample的timeUs,size,offset,encryptionKey以及是否Key Frame等信息。
InfoQueue初始容量为SAMPLE_CAPACITY_INCREMENT=1000,timesUs[], offsets[], sizes[]数组大小均为1000, 通过relativeWriteIndex来管理写,每写入一个Sample,relativeWriteIndex++; offset是重要的参数,需要通过它去DataQueue找到sample的绝对位置。
通过relativeReadIndex来管理读,解码器通过DefaultTrackOutput::readData读数据,首先调用infoQueue.readData,根据relativeReadIndex获得timeUs,size,offset; 根据offset算出DataQueue里前面可以remove的Allocation数据并remove,dataQueue.peek()获取当前offset对应数据节点Allocation,并写入DecoderInputBuffer.data。
queueSize不超过SAMPLE_CAPACITY的情况下,relativeWriteIndex和relativeReadIndex环形读写,index到CAPACITY(1000)再从0开始循环;这样以来,InfoQueue就成了rolling buffer。
如果queueSize超出了SAMPLE_CAPACITY_INCREMENT,queueSize扩大一倍。
这么设计buffer的好处:
1.DataQueue和InfoQueue分开,通过InfoQueue来管理DataQueue
2.DataQueue是动态的,要多少分配多少,不用了就收回;比给定一个const的bufferSize大小要灵活的多
3.DataQueue只在被读取的时候根据offset做queue的remove,DataQueue.add的时候只管往队尾放;那有人要问了,queue不就是往队尾写,从队头读吗,这有什么奇怪的?这时候你考虑下,自适应码率切换,码率切换的时候,为了尽早切换,相同sequenceNum不同码率的chunk会重复下载一次,这时候前面已经下载成功的这个sequenceNum的数据肯定有部分不会被解码播放,那这部分的数据是不是可以提前从queue队尾remove掉,然后补上新的数据?不用考虑这么多,你尽管往队尾写好了,它的管理者InfoQueue会帮你搞定这些事情,relativeReadIndex的更新安全地把这些不用的数据在readData的时候remove掉。seek的情形也是一样的。预告下,下期写自适应码率切换的实现,到时候可以看看这个DataQueue是如何被巧妙摆布的。
4.还有一点,关于rolling buffer的读写要不要加锁?ExoPlayer的做法是:InfoQueue相关的调用都会加锁(使用Java的synchronized方法);也就是说DataQueue读写不受锁的控制;只有操作存放metaData以及read、write index信息的InfoQueue时才会加锁,这些数据跟ES数据比起来,读、写量太小了,以至于这个锁根本不会影响视频数据读写的效率。
2. SparseArray与HLS TS的解析
ExoPlayer中TsExtractor相关类关系如下:
PesReader处理ES数据,SectionReader处理各种表,对HLS来数,TS简单的多,主要是PAT和PMT。
Ts解析用到了SparseArray,SparseArray是Android SDK提供的integers to Objects的Map,详细的可以查看Android SDK中SparseArray的实做,总体来数,设计它的目的是在某些条件下性能更好,尤其是在获取数据的时候,看到下面的代码的时候你大概明白了:
public void put(int key, E value) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
...
}
public E get(int key, E valueIfKeyNotFound) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
...
}
SparseArray存储的元素都是按元素的key值从小到大排列好的,二分查找在获取数据的时候,某些情况下比需要遍历的HashMap快的多。
回到ts的解析,SparseArray在TsExtractor被用作为pid与相应Reader的Map,如下:
private final SparseArray<TsPayloadReader> tsPayloadReaders;
TsExtractor构造函数首先会把PAT表的pid TS_PAT_PID(0)加到Map里:
tsPayloadReaders.put(TS_PAT_PID, new SectionReader(new PatReader()));
TsExtractor从defaultExtractorInput读到的TS数据,首先拿到的包是PAT,根据PAT的pid从SparseArray返回处理PAT的Reader如下:
TsPayloadReader payloadReader = tsPayloadReaders.get(pid);
payloadReader.consume(tsPacketBuffer, payloadUnitStartIndicator);
在PatReader::consume()中,PatReader会把PmtReader放到SparseArray中:
for (int i = 0; i < programCount; i++) {
...
tsPayloadReaders.put(pid, new SectionReader(new PmtReader(pid)));
}
当解析到PMT的时候:
PmtReader::consume(),PmtReader会把真正audio/video对应的PesReader放到SparseArray中tsPayloadReaders.put(elementaryPid, reader);
这样,所有的pid以及对应的reader都放到了SparseArray中,之后TsExtractor read数据,根据ts包里面的pid通过:
TsPayloadReader payloadReader = tsPayloadReaders.get(pid);
直接就可以找到处理数据的reader,如下调用consume就能分别处理:
payloadReader.consume(tsPacketBuffer);
这样就完成了ts的demux,a/v数据最终被分别存到不同的defaultTrackOutput dataQueue中,等待MediaCodecRenderer来readData。
再回顾下上篇文章提到的HLS的数据流,如下:
ExoPlayer的实做针对HLS的ts数据包,已经算是相当不错了,然而,不比不知道,接下来再看看Dash
fMP4的实现,看完后你会发现,码农真的很可怜。
3.Dash fMP4与Stack
直接上图,下图是Dash的数据流:
看到跟HLS数据流的对比,就是上面少了好几个框框,是的,HLS解析里面放在SparseArray里面的Reader全都不见了,FragmentedMp4Extractor直接往DefaultTrackOutput的DataQueue里面写数据。
FragmentedMp4Extractor中只用到了Stack一个数据结构,主要用来协助做moov和moof的解析,Android SDK中Stack的实做是这样的:
public class Stack<E> extends Vector<E>{};
是继承Vector而来的,所以实现非常简单;C++熟悉的马上会想到,C++ STL中Stack的实现默认是基于std::deque,在C++中把其称为allocator,这个allocator也可以是std::vector,一点问题没有。所以语言层面的东西,也都是通的。
FragmentedMp4Extractor除了包含这个Stack外,剩下的就是MP4各种box的数据解析,如此简单的封装设计,你的代码效率想不高都不行。
HLS解析里面牛掰的码农们使用的SparseArray在Dash根本不需要,为什么?Dash的协议就是这么定的:manifest文件里不仅把A/V分开了,还会告诉播放器codec的类型、mimeType、resolution、sampleRate等等信息,它把复杂的工作留给了码流生产端,那里有性能强劲的服务器集群,干这点事分分钟搞定;客户端只需要简单的解析下box就可以了。
就是这样的一个标准上的差别,实际应用中我们发现,即使在性能优越的电视芯片上,播放同样4K的影片,HLS的流播放丢帧非常明显,而同样的Dash流却是异常的流畅,为此你需要为ExoPlayer HLS流畅播放做很多优化的工作。关于优化的思路,后续会有一遍专门探讨。
感谢牛掰的标准制定者,你们的简单标准改进就能解放码农一大部分的工作
4.HLS的演进: fMP4
上面我们看到Dash相比于HLS(或者fMP4比TS)的优点,但是Apple也在往前走,从HLS v7(https://tools.ietf.org/html/draft-pantos-http-live-streaming-20)开始,HLS正式引入了MPEG-4 Fragmented,playlist示例如下:
#EXTM3U
#EXT-X-TARGETDURATION:4
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4.0
segment_0.m4s
#EXTINF:4.0
segment_1.m4s
...
#EXT-X-ENDLIST
长远来讲,CP(Content Provider)们是最开心的,之后不需要encode content in different formats,fMP4就能满足所有需求,come on。。。
ExoPlayer官方的Demo已经有HLS master playlist advanced(fMP4)的支持,只是例子中所用的m3u8实在是有点过于复杂了,有兴趣的可以先行研究下,url:https://tungsten.aaplimg.com/VOD/bipbop_adv_fmp4_example/master.m3u8
总结
前面写了ExoPlayer里用到的三个重要的数据结构:Queue、SparseArray、Stack,都是Android SDK提供的基本的数据结构。
最后说一下HashMap,HashMap在ExoPlayer的HttpDataSource和MediaCodec相关模块都有使用;HashMap感觉上是网络世界中最重要的数据结构了,由于太经典,就不废话了。
还有最后提到了Dash和HLS在协议上的差别的导致的Performance问题,后续会有详细篇幅说明;以及HLS对fMP4的支持。
下一篇写自适应码率切换,感受下本文开始描述的ExoPlayer这个Queue的灵活。
下一篇: Cocoapods安装及使用