#问题来由:
项目开始涉及音视频播放这块的逻辑,于是想起了之前团队用的音视频播放框架ijkplayer。ijkplayer有多牛逼?看看GitHub上的Star数量就知道了。ijkplayer优点官网介绍的很详细了,这里就不赘述了。这里主要说下我没有选择继续使用ijkplayer的原因吧。
1、基于FFmpeg实现,需要项目中导入FFmpeg框架,对于安装包的增加还是比较多的(大概在10-20M左右)。比较看重安装包大小的同学可能会是个障碍。
2、FFmpeg内部封装了AVAudioSession的状态切换逻辑,不方便我们全局整体控制AVAudioSession的状态变化(之前做音视频编辑时,就被播放器的AVAudioSession状态修改导致了声音忽大忽小的问题)。
3、一些稳定性问题,ijkplayer的issues可以看到还是有很多待解决的问题的。由于代码量不少再加上对FFmpeg也不是很熟悉,一旦出现BUG,解决问题的难度也是不小。
所以最终决定直接系统的AVPlayer来进行封装了,虽然系统播放器支持的视频格式有限(视频编码格式:H.264、HEVC(iPhone7及以后设备)、MPEG-4。 视频格式(封装格式):.mp4、.mov、.m4v、.3gp、.avi)。但是视频格式我们是可以控制的,保证上传的视频格式或者服务器也可以转码。
那么现在要做的就是实现一套视频缓存框架。
#方案调研
视频播放缓存,我们需要拆分为两个关键点:hook视频下载、视频缓存。接下来就先说下hook视频下载。
Hook视频下载
如果直接通过url创建AVPlayer来播放,视频下载和播放的整个环节都是闭源的。那有什么方法可以让我们拦截URL请求呢?NSURLProtocol? 其实AVURLAsset已经给了一个很好的hook URL下载的方案。AVURLAsset的resourceLoader(AVAssetResourceLoader)属性允许我们拦截系统无法识别的URLScheme,并自定义请求返回数据。AVAssetResourceLoaderDelegate就是用来hook自定义URLScheme的协议。
AVAssetResourceLoaderDelegate 中的resourceLoader:shouldWaitForLoadingOfRequestedResource:会捕获url请求,该方法可以让你选择时候需要自己处理请求。该方法的定义如下:
/*!
@method resourceLoader:shouldWaitForLoadingOfRequestedResource:
@abstract Invoked when assistance is required of the application to load a resource.
@param resourceLoader
The instance of AVAssetResourceLoader for which the loading request is being made.
@param loadingRequest
An instance of AVAssetResourceLoadingRequest that provides information about the requested resource.
@result YES if the delegate can load the resource indicated by the AVAssetResourceLoadingRequest; otherwise NO.
@discussion
Delegates receive this message when assistance is required of the application to load a resource. For example, this method is invoked to load decryption keys that have been specified using custom URL schemes.
If the result is YES, the resource loader expects invocation, either subsequently or immediately, of either -[AVAssetResourceLoadingRequest finishLoading] or -[AVAssetResourceLoadingRequest finishLoadingWithError:]. If you intend to finish loading the resource after your handling of this message returns, you must retain the instance of AVAssetResourceLoadingRequest until after loading is finished.
If the result is NO, the resource loader treats the loading of the resource as having failed.
Note that if the delegate's implementation of -resourceLoader:shouldWaitForLoadingOfRequestedResource: returns YES without finishing the loading request immediately, it may be invoked again with another loading request before the prior request is finished; therefore in such cases the delegate should be prepared to manage multiple loading requests.
If an AVURLAsset is added to an AVContentKeySession object and a delegate is set on its AVAssetResourceLoader, that delegate's resourceLoader:shouldWaitForLoadingOfRequestedResource: method must specify which custom URL requests should be handled as content keys. This is done by returning YES and passing either AVStreamingKeyDeliveryPersistentContentKeyType or AVStreamingKeyDeliveryContentKeyType into -[AVAssetResourceLoadingContentInformationRequest setContentType:] and then calling -[AVAssetResourceLoadingRequest finishLoading].
*/
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 6_0);
PlayerView的视频加载缓存机制会在缓存下载时间较长的情况下,取消之前的下载并减小contentLength重新请求。这时会触发resourceLoader:didCancelLoadingRequest:来取消下载。
/*!
@method resourceLoader:didCancelLoadingRequest:
@abstract Informs the delegate that a prior loading request has been cancelled.
@param loadingRequest
The loading request that has been cancelled.
@discussion Previously issued loading requests can be cancelled when data from the resource is no longer required or when a loading request is superseded by new requests for data from the same resource. For example, if to complete a seek operation it becomes necessary to load a range of bytes that's different from a range previously requested, the prior request may be cancelled while the delegate is still handling it.
*/
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 7_0);
上面两个方法不难看出AVAssetResourceLoadingRequest就是贯穿整个代理请求的核心对象。打开AVAssetResourceLoadingRequest咋一看各种request、response看的是一脸懵逼。但是实际上我们只需要关注会用到的几个核心属性:
-
NSURLRequest *request;
request是PlayerView发起的data请求
-
AVAssetResourceLoadingDataRequest *dataRequest;
当一个视频文件较大时,PlayerView通常不会一次性将视频内容全部请求下来,会通过HTTP Header的Content-Range来设置获取视频的部分内容, dataRequest中就包含了请求的偏移和长度,同时当获取到data数据后,还需要通过respondWithData:将数据返回给PlayerView。
-
AVAssetResourceLoadingContentInformationRequest *contentInformationRequest;
-
NSURLResponse *response;
当请求发出去后,我们首先会收到请求的response,这时我们要通过response来判断时候是否合法,如果合法我们需要将response中返回的文件信息写入contentInformationRequest中。主要需要写入的字段如下:
loadingRequest.response = httpResponse;
loadingRequest.contentInformationRequest.contentType = response.MIMEType;
loadingRequest.contentInformationRequest.contentLength = totalLength;
loadingRequest.contentInformationRequest.byteRangeAccessSupported = YES;
这里有一点需要注意,视频资源正常response返回的状态码应该是206,如果返回遇到其他的状态码,如果返回的是其他状态码,基本可以确实是出错了(重定向除外)。
现在我们大致捋一下大致的实现流程:
1、AVURLAsset设置resourceLoader属性的代理,自定义urlscheme。
2、AVAssetResourceLoaderDelegate中通过resourceLoader: shouldWaitForLoadingOfRequestedResource:捕获请求。
3、通过dataRequest和request快速封装新的url请求,通过NSURLSession创建请求task,发送请求。
4、获取请求返回的response,设置AVAssetResourceLoadingRequest的response和contentInformationRequest中的属性。
5、通过dataRequest的respondWithData:将数据写回给PlayerView。
6、调用AVAssetResourceLoadingRequest的finishLoadingWithError:或者finishLoading来通知PlayerView请求结束。
至此整个playerView的自定义下载逻辑就完成了。但是这样还没有结束,我们目的是实现边下载边缓存。
接下来我们要做的就是实现视频缓存。
视频缓存
视频缓存和http缓存的区别在于,我们不能直接针对request来进行缓存。在实现hook视频下载的过程中,我们知道PlayerView抛出的request中的Content-Range是不固定的,我们无法得知系统内部的缓冲优化逻辑具体的实现,因此也无法推理出每次请求的Content-Range的具体值,这样的话针对每次请求的request做缓存的意义就基本没有了,还是可能出现大量的重复数据的请求。所以我们必须直接对文件来做缓存,而不是针对某个请求。
做视频核心麻烦点在于,Content-Range所指向的区域可能有部分已经缓存,还有部分还没有缓存,需要对请求的Content-Range进行分片处理。Content-Range与一块缓存Range(CacheRange)有交集的4种情况:
image-20190829105401648.png根据上面的几种情况,我们可以将Content-Range分成多个Ranges,有缓存的Range直接读取缓存,没有缓存的部分就需要创建下载请求下载数据。上面的情况只是简单的举例Content-Range与一个CacheRange相交的情况,但实际情况肯定会比这要复杂的多,因为缓存可能出现很多不连续的片段,所以Content-Range会出现同时与多个CacheRange相交的情况:
image-20190829110727731.png
这时我们只需要按顺序从左到右依次将Content-Range的剩余部分与下一个CacheRange做分割处理就可以了。思路有了,接下来就是具体的实现方案。
需要记录CacheRanges,所以我们需要一个plist文件来存储一个CacheRanges列表。需要一个souce文件来储存缓存的资源文件。那我们怎么将下载下来的数据写入到source文件对应的偏移处呢?这里采用的方案如下:
1、通过response获取文件的totalLength
2、使用NSFileHandle 创建source文件,并将一个totalLength的填充满1的NSData写入source文件
3、当有真实数据时,NSFileHangle设置为对应的偏移量,然后将真实数据覆盖写入。
到这里整体的方案就基本说完了,后面介绍几个实现时需要的坑点。
#坑点
1、NSFileHandle 在执行readDataOfLength: 和writeData:一定要做try Catch保护,因为这两个方法在执行出现错误时会直接报出异常,导致crash。项目中遇到的最常见的异常就是,手机储存空间不够,导致writeData:直接抛异常。
2、不要将多个NSFileHandle同时绑定到同一个路径下,再多线程执行时会出现同时写入,导致文件大小异常。
3、一定要强检验responseState==206,一旦服务出现异常返回了错误数据,没有做状态码校验的话会写入一些错误数据,导致数据异常。
网友评论