iOS音视频缓存方案实现

#问题来由:

项目开始涉及音视频播放这块的逻辑,于是想起了之前团队用的音视频播放框架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、当有真实数据时,NSFileHandle设置为对应的偏移量,然后将真实数据覆盖写入。

到这里整体的方案就基本说完了,后面介绍几个实现时需要的坑点。

#坑点

1、NSFileHandle 在执行readDataOfLength: 和writeData:一定要做try Catch保护,因为这两个方法在执行出现错误时会直接报出异常,导致crash。项目中遇到的最常见的异常就是,手机储存空间不够,导致writeData:直接抛异常。

2、不要将多个NSFileHandle同时绑定到同一个路径下,在多线程执行时会出现同时写入,导致文件大小异常。

3、一定要强检验responseState==206,一旦服务出现异常返回了错误数据,没有做状态码校验的话会写入一些错误数据,导致数据异常。

可以前往GitHub查看Demo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容