Flash视频播放器开发经验总结
HTTP协议更优
目前几乎所有的视频点播网站全部采用HTTP协议传输数据。因为相对于诸如RTMP等协议来说,HTTP协议是无状态的,数据传输完毕就断开连接,这样服务器就可以腾出资源来服务更多的用户。而RTMP则会在用户播放期间一直维护一个连接,这样服务器的负载就非常有限。而且HTTP服务器,CDN等都已经是非常成熟的技术,成本低性能好。另外HTTP的请求可以直接使用浏览器Cookie,容易和网站业务打通。最后,HTTP还能使用浏览器缓存,这算优点也算缺点,优点是请求同样的资源可以直接从缓存中取,缺点是安全性差了点。
HTTP拥有更好的性能,但是没法传输太实时性的东西,否则性能还不如RTMP,比如视频聊天,直播这些。
安全性
有时候我们被访问的视频可能需要做一些限制,比如防盗链,视频收费等等。如果采用HTTP协议的话,传统的鉴权方式就足够,Cookie里带token什么的判断是否有权限访问视频资源,细节我就不说了。唯一的问题是一旦用户有权限访问视频,就有可能把视频下载下来用作他用。
通过分段进一步提升负载能力
为了让HTTP能服务更多的用户,同时维护更少的连接,我们需要传输尽快完毕。但这是我们分段的理由吗?不是,因为无论分段不分段,一个用户加载完全部的视频数据对服务器占用时间是一定的(假设传输速度一定),甚至会多占用很多创建连接和销毁连接的资源。
但是我们看到各大视频网站实际上都是有对视频分段的,这里我就谈谈视频分段的好处。
- 节约网站流量,也就是节约服务器资源提高负载能力。当用户打开一个视频的时候,很有可能不会把视频看完,只看一部分。如果不对视频做分段,用户一打开网站就把所有视频数据加载完,那么对流量就是极大的浪费。把视频分段后我们可以一段一段加载视频,做到用户看多少我们就加载多少。
- 更加灵活的seek(拖动),对于一个不做任何分段的视频,比如HTTP服务器上的静态视频文件,我们是无法通过NetStream对象seek方法跳转到未加载的视频部分的。所以为了解决这个问题,apache和nginx都提供了flv模块,支持start参数。当指定start参数的时候,我们可以重新从指定位置加载视频,解决了上述问题。但是带来的问题是流量浪费,可能原本加载过的地方又要重新加载一遍。如果我们采用分段的方式,就可以避免这个问题,具体实现方法后面会详细介绍。
NetStream对象
我们在Flash端如何播放视频很大程度上受NetStream提供的功能所限。所以这里大致介绍下NetStream提供的功能和一些限制,这也是为什么后面程序要这么设计的原因。
- NetStream提供两种可以播放HTTP视频的模式,普通模式和数据生成模式。
- 在普通模式下,往NetStream传入我们要播放的HTTP视频资源地址,NetStream就会开始加载视频并开始播放。我们可以暂停视频播放,但是不能暂停数据的加载,我们可以在已经加载过的数据部分随意seek,但是不能seek到未加载的部分。数据加载完毕之后我们任然可以进行播放,seek等操作,但是如果调用了close方法关闭流,那么如果数据未加载完毕,就会停止加载,并且不能做任何播放,seek等操作,这相当于我们原来加载的数据都白费,不能再使用。所以如果我们要把视频分段后随意在各个视频分段里来回seek,我们必须让一个分段视频对应一个NetStream实例,换句话说有几个分段就需要几个NetStream伺候他们(我们暂且这么认为,后面我们会对这个问题做优化)。
- 在数据生成模式下,NetStream提供更加灵活的加载方式。NetStream通过appendBytes方法可以添加外部的二进制数据来播放视频,添加数据的顺序就是播放的顺序。这种情况下我们可以通过URLStream对象加载视频文件数据,理论上所有加载过的数据都可以被重复利用。但是注意不要把所有数据往内存里塞,否则内存会被撑爆。具体的缓存策略后续具体讲。
- 和其他平台的视频播放器不同,Flash不能直接访问本地文件,但是可以通过加载已经加载过的视频让浏览器从缓存中快速取得视频数据。所以如何有效利用缓存是优化的关键。
- 不要迷信NetStream的NetStatusEvent事件,在不同服务器和浏览器环境下,这个事件发生的时机可能略有差别,所以事件只能做参考,需要另外做一些前提判断。
视频分段需要的服务器支持
- 静态分段:把视频分为固定的可以独立播放的几段保存到服务器上,播放的时候需要获得一个视频地址列表。每个静态分片都只能从头开始请求不能从切片的中间开始请求。这是最容易做到也是性能做好的方式。
- 静态分片+start参数:第一个方案的改进,可以支持从分片的中间开始请求到分片的结尾。优酷土豆都是这么做的噢。这样方便seek。也有现成的nginx和apache模块可以支持。
- 动态分片:同时提供start和end参数,这样可以由播放器来决定如何请求分片,对播放器来说更灵活,对服务器的文件管理来说也更方便。这个解决服务器解决方案nginx和apache应该也有,没有细究过。
- 以上三种最后请求出来的数据都是一个能完整独立播放的视频文件,服务器会自动帮你加上视频文件头。如果Flash使用的是数据生成模式,那么实际上返回的直接是一个文件数据片段就行了,不需要另外加上文件头。
朴素的分段视频播放
直接播放单个视频文件的方式我就不说了,我这里介绍的是如何像播一个完整文件一样播放经过分段的视频。这个方案有些许瑕疵,后续的方案都是基于这个方案进行优化的。
服务器我们采用上面提到的第一种最简单的静态分段。并且在视频开始播放前我们会拿到一个包含视频分段的开始时间,结束时间,以及分段地址的列表,还有个总的视频metadata信息。
当视频列表加载完毕后就可以开始依次通过NetStream加载播放各个视频分片了,每个分片用一个NetStream实例控制。如图所示。
我们可以设定一个最大缓冲距离,结合当前播放进度,算出一个允许缓冲位置,在这个允许缓冲位置之内的切片都可以依次开始加载,开始加载的时候暂停住不播放。当一个切片开始加载之后是不会停止的,所以实际缓冲进度可能会大于允许缓冲位置。
当一个切片播放完毕之后不要急着把它关掉,它可能需要留着供后续的seek使用。紧接着,我们把下一个分片执行resume方法来让他播放。这样多个分片按照顺序播放,对外界来说就像播放一个完整的视频一样。
这种结构下,若外界需要对视频进行seek操作,可以分三种情况:
- seek到已加载分片的已加载部分,这种情况效率最高,直接暂停当前播放的分片(如果是seek的位置就是当前切片这步都可以省了),让seek目标时间所在分片seek到对应位置恢复播放就行了。
- seek到已加载分片的未加载部分,操作和上面的类似,由于要seek的部分还未加载完,所以我们只能seek到该分片已加载的最接近位置让视频尽快开始播放。
- seek到一个还未开始加载的切片的某个位置,同样暂停当前播放的切片,转到目标切片让目标切片开始加载并尽快开始播放。(当前正在加载的切片有两种处理策略,一种是还让切片继续加载完毕,另外一种是直接关闭。还有一种折中策略,如果已经加载超过一半就让他继续加载完,否则关闭)
分段视频播放改进:增加start参数
所以我们可以看到,静态分片方式的在seek的处理还是还是有很多不足的,对未加载部分内容的seek都不能做到非常精确。不过如果将切片切得比较短小的话这个问题可以有所改善,但是还会带来另外的问题,这个问题我后面讲。另外我们可以再静态分片的基础上引入了start参数,也就是上文提到的“静态分片+start参数”类型服务器。
引入了start参数后对上面的2、3两种seek情况进行了改进:
- seek到已加载分片的未加载部分,关闭这个分片正在加载的流,并用这个分片的NetStream重新从seek位置指定的位置开始加载(通过指定start参数)。不过这个start参数不是随便什么都可以的,需要是视频关键帧位置,否则返回回来将不能播。关键帧位置在metadata里面可以查询到。
- seek到未加载切片也同样,从根据seek位置设定start参数后开始加载。
如此以来在任何情况下seek都可以精确到关键帧,缺点是把正在加载的切片关掉会造成数据浪费。从切片中间开始加载也会造成一个切片内容不完整。下次seek的时候如果不巧是在这个切片start位置之前,就需要重新加载该切片。这些都会造成数据浪费。好在一般用户不会吃饱了没事儿seek来seek去。
通过连接池来限制连接数量
从上文的几个策略可以看出,如果视频分得越短小,无论对seek的精确度,还是数据浪费情况都是有好处的,但是这带来的一个问题是需要实例化更多的NetStream来维护切片。另外对于时长较长的视频来说,NetStream的数量也会变得很多。但实际上NetStream能同时开启的连接数量是有限的,这不是内存问题,而是Flash提供的连接数有限。超过了这个限制NetStream就没办法正常工作了,而且也不报错。这个限制在不同浏览器下还不一样,我怀疑这和浏览器底层有关。
所以为了限制NetStream的数量,我们需要设计一个NetStream连接池来管理所有的NetStream。连接池上限不能小于最大缓冲举例可能加载的最多分片数,否则逻辑上就是有问题滴。
我们可以从连接池中取得一个新的NetStream来使用(这个NetStream可能是别的NetStream关闭后的,不过你可以把它当新的用)当连接池数量满的时候,他就会自动把一些老的处于连接状态的NetStream关闭掉。这个淘汰原则是基于空间局部性原理的,也就是说和当前播放头位置距离最远的切片应该首先被关掉(处于最大缓冲距离之内的切片不能关闭)。因为根据概率统计发现大部分的seek都出现在播放头附近(可能为了找什么情节)。
推荐使用数据生成模式
通过多个NetStream切换的方式播放视频,在切换的时候会出现不明显的爆音,但是仔细听还是能够发现。这也是我在上文中提到的文件分割得太短小出现的另外一个问题,爆音太频繁了,可能影响视频观看。
所以要从根本上解决这个问题,我们就要放弃NetStream切换的方式,转用数据生成模式。数据生成模式可以把请求的切片做得很小(但也不要太小,否则服务器性能降低)。切片做小的一个好处是请求更快的完成,那么请求被打断的几率就会降低,当请求完成之后,下次请求同样的资源就能从浏览器缓冲中取。所以小切片更容易被缓存。而上文中的小切片产生的问题在这里不复存在。如图所示。
我们根据播放头的位置,往后加载分片数据,直到最大缓冲距离,这和前面提到的方式类似。而后我们把这些加载的二进制数据保存在内存中。从播放后往后一定的距离(我们称作NS缓冲长度),如果有分片进入,那么就把它appendBytes到用于播放的NetStream中。图中所示的蓝色部分就是保存在内存中的数据,它也有前面提到的连接池类似的淘汰机制用于控制内存总大小。被从内存中释放掉的数据,我们可以在浏览器缓存中找到(因为已经加载过了),如果要使用的话,我们可以像请求服务端数据一样的方式快速请求到这些数据(当然比从内存中慢一些)。图中白色方框的是还未加载过的数据,他们在服务器上等待加载。如图所示就是数据的三级查询。
如果用户进行seek:
- seek的位置位于内存中的数据:先清空NetStream的缓冲,然后把内存中响应位置的数据往后一定距离(NS缓冲长度以上)加入到NetStream中用于播放。
- seek的位置位于缓存中:先把缓存中的数据加载到内存中,然后通过第一条的方式实现。
- seek的位置位于服务器上:从服务器上加载数据分片数据到内存中,然后通过第一条的方式实现。
如果分片数据较大,seek的位置在分片中间,那么也可以从分片中间开始加载,这样可以从逻辑上把一个分片分为了两个。
数据生成模式从本质上保证了播放质量,杜绝了数据浪费,保证了seek精确度,服务器实现上也异常简单,真是视频播放首选!
赠品:用分段方式做直播
这种方式需要服务器做实时分片并分发到CDN。比如服务器从直播数据源里把30秒的视频数据打包成一个数据包分发到CDN上,所以理论上直播至少会延迟30秒。不过对于实时性不是特别强的直播,这种方式的负载能力会更好。
传统的长连接方式直播,需要客户端和服务器一直保持连接,服务器需要维护每个客户端的连接,但实际上传输30秒的视频数据只需要1秒,所以如果采用HTTP的方式,因为传输完毕就可以服务别人了,所以理论上维护连接的效率可以提高30倍。
这里我们要求服务器提供一个视频地址列表,列表里提供了最新的N个视频分片地址。这样客户端通过轮询这个视频列表就能让客户端和直播保持同步。
如图所示客户端维护着一个切片列表队列,通过轮询服务器,我们把最新的视频地址添加到队列中,而播放模块则从队列中取出最老的切片地址加载播放。
如果用户网络较差,那么播放就会卡顿,所以从队列中取出切片地址的频率就会降低,队列会越来越长。队列越长说明视频播放的延迟越大。
所以当队列长于某一个临界值时(我们设定的),我们就把队列清空到只剩一个最近的地址,直到下一次这个地址被取出时,才允许队列继续变长。这个队列清空的操作实际上是对因播放卡顿引起的延迟做了矫正,让直播不要延迟得太厉害。