基于AVPlayer实现边缓存边播放

AVPlayer + AVAssetResourceLoaderDelegate实现音频的边缓存边播放

写在最前面

如果你已经在使用AVPlayer + AVAssetResourceLoaderDelegate的方式,而且已经参考了很多前辈的文章、Demo,发现“都对啊!怎么就不是马上放出来的呢?!”。那么,请:
先检查下,AVPlayer有个属性<automaticallyWaitsToMinimizeStalling>,你是否设置了呢?是否设置为NO了呢?如果没有...快去试试啊~~~ 有意外惊喜哦!希望您能回来给我点个❤️呦~(下文中有原因的详细解释)
如果还是没有达到预期效果,那么请继续向下浏览全文吧。(Demo见文末)


2019年7月9日更新:
增加cocoapods支持:

pod 'XTAudioPlayer'

2018年5月18日更新:
1.新增AVPlayerViewController视频播放模式;
2.新增视频加载卡顿及恢复可播放状态的代理方法;
3.加强对弱网络条件的支持;
4.修复指定缓存地址的支持;
5.修正重复添加通知的bug;

//API Document
@protocol XTAudioPlayerDelegate<NSObject>

@optional

/**
 Tells the delegate the player is suspended because of the buffer is empty.

 @param player The AVPlayer object informing the delegate of this event.
 */
-(void)suspendForLoadingDataWithPlayer:(AVPlayer *)player;


/**
 Tells the delegate the player is ready to continue to playback.

 @param player The AVPlayer object informing the delegate of this event.
 */
-(void)activeToContinueWithPlayer:(AVPlayer *)player;

@end

/**
 Playback a video by AVPlayerViewController.

 @param urlStr Url for a media file, or a path for a media file in sandbox or boundle
 @param cachePath Cache path for the media file, if you set it nil, the file will cache in a default path
 @param playCompleteBlock The block to execute after the play has been end. If the play is fail to end, there is a error in the block
 @return An AVPlayerViewController object which playback this video
 */
- (AVPlayerViewController *)playByPlayerVCWithUrlStr:(nonnull NSString *)urlStr cachePath:(nullable NSString *)cachePath completion:(PlayCompleteBlock)playCompleteBlock;


//How To Use
@interface PlayerVC ()
<
XTAudioPlayerDelegate
>

@property (nonatomic,assign) NSTimeInterval lastSuspendTime;
@end

......

//Playback a video by AVPlayerViewController
[XTAudioPlayer sharePlayer].delegate = self;
AVPlayerViewController *playerVC = [[XTAudioPlayer sharePlayer] playByPlayerVCWithUrlStr:self.urlArray[indexPath.row] cachePath:nil completion:nil];
[self presentViewController:playerVC animated:NO completion:nil];

#pragma mark - XTAudioPlayerDelegate
-(void)suspendForLoadingDataWithPlayer:(AVPlayer *)player{
    //Do something when the player is suspended for loading data...
    NSTimeInterval currentTime = [[NSDate date] timeIntervalSinceNow];
    self.lastSuspendTime = currentTime;
}

-(void)activeToContinueWithPlayer:(AVPlayer *)player{
    //The player is ready to continue...
    /**
     It is not recommended to continue play the player immediately, because this selector will be called when the player only buffer a little data, so this selector will be called very frequently.
     Therefore it is recommended to play the player after buffering several seconds.
     */
    dispatch_after(dispatch_time(self.lastSuspendTime, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [player play];
    });
}

2018年5月9日更新:
新增对视频播放的支持!
新增对本地资源(沙盒和Boundle)内容播放的支持!
新增播放完成回调!
新增配置选项!
API更好用!

//Configure properties for XTAudioPlayer and playback a video.
[XTAudioPlayer sharePlayer].config.playerLayerRotateAngle = M_PI_2;
[XTAudioPlayer sharePlayer].config.playerLayerVideoGravity = AVLayerVideoGravityResizeAspectFill;
[XTAudioPlayer sharePlayer].config.audioSessionCategory = AVAudioSessionCategoryPlayback;

[[XTAudioPlayer sharePlayer] playWithUrlStr:self.urlArray[indexPath.row] cachePath:nil videoFrame:[UIScreen mainScreen].bounds inView:self.view completion:^(NSError *error) {
        //code what you want to code
}];

//Playback a audio.
[[XTAudioPlayer sharePlayer] playWithUrlStr:self.urlArray[indexPath.row] cachePath:nil completion:^(NSError *error) {
        //code what you want to code
}];

实现基础

AVPlayer
不用过多介绍(刚打出这行字就觉得这个x装得太不应该了,被打脸打成那样还不长记性,还装x,后文详述),大家应该都使用过,无论是视频还是音频,本地还是在线的播放都十分好用,能满足很多基本需求。(所以本文虽然主要实现的是音频的播放,但同样适用于视频播放。)

AVAssetResourceLoaderDelegate
“边缓存边播放”,播放现在我们能够通过AVPlayer解决,所以现在需要想办法来解决同步缓存数据的问题。我原本天真的以为,AVPlayer会直接提供一个接口,直接给我们数据,甚至直接给我们一个沙盒的缓存地址呢~然而…并没有!
因此通过拜读前辈们的文章发现,AVPlayer在使用时除了可以直接设置url,还可以通过设置AVURLAsset来控制播放内容:

#import <AVFoundation/AVFoundation.h>

AVURLAsset *asset = [AVURLAsset URLAssetWithURL:audioUrl options:nil];
AVPlayerItem *playerItem = [AVPlayerItem playerItemWithAsset:asset];
AVPlayer *player = [AVPlayer playerWithPlayerItem:playerItem];

而AVURLAsset还有一个resourceLoader属性。我们可以对其设置代理,

[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];

那么实现边缓存边播放的核心基础就来了,在上面设置的代理中有一个代理方法:

-(BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
shouldWaitForLoadingOfRequestedResource:
(AVAssetResourceLoadingRequest *)loadingRequest;

通过这个方法,我们就可以截获AVPlayer发出的请求,进而自行控制网络数据的请求和接收,也就能够实现网络数据的自由缓存!

小坑①
AVPlayer并不是设置完AVAssetResourceLoaderDelegate就会自动调用上面的代理方法的,而是需要修改资源url的协议头为自定义协议头
从该代理方法的注释中可以看到

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.

因此,我们必须将赋予AVURLAsset的url的协议头从常用的http、https修改成自定义的协议头,例如本文中的XTShow,才能让AVPlayer的请求“慌不择路”的进入代理方法之中。

核心思路

-(BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
shouldWaitForLoadingOfRequestedResource:
(AVAssetResourceLoadingRequest *)loadingRequest;

该代理方法中,我们拦截到的是AVPlayer发出的loadingRequest,后续的操作也都是围绕着loadingRequest进行的。

1.请求数据
我们通过自定义协议头截取了loadingRequest,也就意味着,AVPlayer自身不再会发起网络请求,所以,我们要代替其发出网络请求,获取所需的数据,那么我们怎么知道AVPlayer需要哪些数据呢?就是在这个loadingRequest中:

NSUInteger requestOffset = (NSUInteger)loadingRequest.dataRequest.requestedOffset;
NSUInteger requestLength = (NSUInteger)loadingRequest.dataRequest.requestedLength;
NSRange requestRange = NSMakeRange(requestOffset, requestLength);

这样我们就知道了当前AVPlayer需要哪些数据,我们就可以根据这个范围去请求数据了。

2.回填数据
请求到了数据,我们就可以将其缓存起来了。但是,我们需要数据进行缓存,AVPlayer也需要数据进行播放啊!所以怎么将数据回填给AVPlayer呢?还是通过loadingRequest:

[self.loadingRequest.dataRequest respondWithData:data];

这里很直接,使用respondWithData方法直接将data返回给loadingRequest即可。
但是这样就结束了吗?NONONO~在填充完所需的数据后,还要对loadingRequest进行finishLoading操作:

[self.loadingRequest finishLoading];

这样才能保证AVPlayer正常发起后续的loadingRequest。

小坑②
shouldWaitForLoadingOfRequestedResource会被调用多次,但第一次调用时产生的loadingRequest中一定是一个请求范围为0-2的请求,服务端针对该请求的返回中包含了所请求文件的长度、类型等文件信息,这为AVPlayer发起后续loadingRequest提供了依据,因此,一定要对首个loadingRequest进行finishLoading,不然你真的会发现,发出一个请求,然后就结束了,等再久也什么都不会再发生了。后续的loadingRequest按照官方文档所说:

If a dataRequest is present and the resource does not contain the full extent of the data that has been requested according to the values of the requestedOffset and requestedLength properties of the dataRequest, or if requestsAllDataToEndOfResource has a value of YES, you may invoke -finishLoading after you have provided as much of the requested data as the resource contains.

如果一个数据请求是范围请求或者是直接请求当前至尾的数据(requestsAllDataToEndOfResource = YES),我们就需要在填充完全部所需的数据后调用finishLoading方法。
但实际上,我并没有发现在除了第一个loadingRequest外的其他loadingRequest上,填充完数据后不进行finishLoading操作,会有什么不对的表现。但是!既然官方文档这么写了,我们还是正常操作吧~毕竟没坏处是一定的。

整体结构

其实,通过上面的描述,可能很多人会认为这很好实现啊:直接发起一个从头至尾的请求,然后把这些数据缓存起来;同时将获取到的符合loadingRequest需求的data返回给他。
刚开始我也是这么实现的,而且效果还不错哦~但是,直到遇到了某些音频,发现差不多全部缓存完毕了才开始播放,或者全部缓存完也根本不会播放。后来通过查阅资料发现:播放器开始播放的节点是取决于媒体文件的moov,其存储着媒体播放所需的元数据,包括例如声道、采样率、码率、时长等数据。播放器只有获取到moov后,才会开始播放。一般媒体文件,moov都会在非常靠前的位置,这样,我们在获取到很少的数据时,就能够开始播放;但是,有些媒体文件,会把moov数据放在文件的最后…额…如果是从头开始请求的话,就会造成几乎请求完全部数据,才能够播放的情况;而且实际上经常出现甚至是直接无法播放的情况(具体原因未知)。

因此,我采用的是一套完全根据loadingRequest发起分片式网络请求的方案:loadingRequest需要哪部分数据我就获取哪部分数据并回填给他。
主要原因有二:
1.通过抓包发现,AVPlayer默认的播放在线媒体文件的方式(不修改为私有协议),就会发起多个网络请求,而且针对这类moov数据在文件尾部的媒体文件,AVPlayer在首个文件数据请求没有获取到moov数据后,下一个请求会自动获取尾部数据,而且,shouldWaitForLoadingOfRequestedResource代理方法中返回的loadingRequest也同样拥有该特性!因此,采取这种方式,可以完全规避掉moov数据在后的文件,开始播放慢或者不播放的尴尬问题。
2.在需要seek,也就是调整进度、拖拽进度条时,该方式以及后面具体实现方案中部分对数据range的操作,可以更好的满足该需求。(PS:本方案暂时并未实现调整进度的功能,因为现在并没有这个需求,但是应该在此基础上微调即可实现。)

具体结构如下:

XTAudioPlayer:用户直接调用类。负责控制AVPlayer及实现AVAssetResourceLoaderDelegate代理;控制其他各个工具类,总调度的角色;

XTRangeManager:由于本方案采用的是根据loadingRequest进行请求,前后会发起多个网络请求,因此其中会有部分数据会重复出现在多个loadingRequest的请求范围中。为了节约用户的流量,针对这种情况,我们要计算哪部分已经在本地缓存了,哪部分没有缓存,已缓存的直接用本地数据respondWithData回填;未缓存的,再发起网络请求。同时记录已缓存的数据的范围。

XTRangeModel:loadingRequest的原始请求,在经过XTRangeManager处理后,会被拆分成多个XTRangeModel,model中记录了请求的类型(本地、网络),还有请求的范围,方便后续发起请求使用;

XTDownloader:负责数据请求以及loadingRequest的数据回填;

XTDataManager:负责数据的本地存/取以及沙盒文件操作。

具体实现

是不是有点啰嗦了...本文最大的优势可能就是讲得比较细了…

  • XTAudioPlayer

生成AVPlayer来播放音频,通过AVURLAsset控制播放资源,对AVURLAsset的resourceLoader设置代理并实现代理方法。
在获取到AVPlayer生成的loadingRequest后,进入该类中的核心处理方法:

- (void)handleLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest{
    
    //取消上一个requestsAllDataToEndOfResource的请求
    if (loadingRequest.dataRequest.requestsAllDataToEndOfResource) {
        if (self.lastToEndDownloader) {
            [self.lastToEndDownloader cancel];
        }
    }
    
    XTRangeManager *rangeManager = [XTRangeManager shareRangeManager];
    //将当前loadingRequest根据本地是否已缓存拆分成本多个rangeModel
    NSMutableArray *rangeModelArray = [rangeManager calculateRangeModelArrayForLoadingRequest:loadingRequest];
    
    NSString *urlScheme = [NSURL URLWithString:self.originalUrlStr].scheme;
    //根据loadingRequest和rangeModel进行下载和数据回调
    XTDownloader *downloader = [[XTDownloader alloc] initWithLoadingRequest:loadingRequest RangeModelArray:rangeModelArray UrlScheme:urlScheme InDataManager:self.dataManager];
    
    if (loadingRequest.dataRequest.requestsAllDataToEndOfResource) {
        self.lastToEndDownloader = downloader;
    }else{
        if (!self.nonToEndDownloaderArray) {//对于不是requestsAllDataToEndOfResource的请求也要收集,在取消当前请求时要一并取消掉
            self.nonToEndDownloaderArray = [NSMutableArray array];
        }
        [self.nonToEndDownloaderArray addObject:downloader];
    }

}

除此之外,还有一点我想强调一下:在其他很多前辈的文章中,还会实现代理方法:

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 7_0);

但是我却并没有,是因为,对于loadingRequest的请求的取消,我是自行手动管理的:在出现了一个新的请求至尾(requestsAllDataToEndOfResource = YES)的请求时,就会取消掉上一个请求至尾的网络请求。
通过实践发现,didCancelLoadingRequest代理方法的取消策略也是如此的,因此我就采用了自行控制的方案。由此也可以看出,并不一定要返回每一个loadingRequest所请求的全部数据,只要保证返回了文件的全部数据给loadingRequest们即可。

小坑③
其实这对我来说可以是个天坑了...因为我在这上面卡了三周…
我在刚开始写的时候,也觉得主要的核心应该是从进入shouldWaitForLoadingOfRequestedResource代理方法之后才开始,最外面的AVPlayer都用了那么多次了,应该没什么东西可注意的了,按部就班的写出来放那就可以了,然后就写啊写啊写啊写。。。写了好久,参考了很多前辈的文章和Demo都不对!而且我还惊讶的发现!其中绝大部分的Demo本身就不能实现边缓存边播放的效果!都是全部缓存完成之后才能开始播放的!其中只有一位vitoziv的Demo能够真正的实现,开始返回数据就能开始播放的效果。但是,这位大神的实现方式也真的是我看到的文章里最最复杂的。。。我真的研究了好久好久才能略懂一二,而且竟然还联系到了这位大神,大神还慷慨无私的对我指点了不少!真是感激不尽啊!然!而!还是不行!真的快要崩溃了!!!但是!我在翻看大神的Demo的issue时,发现另一位前辈和我遇到的类似的问题,然后最后大家讨论出来得解决方案是设置AVPlayer的automaticallyWaitsToMinimizeStalling为NO,我马上去大神的Demo里全局搜索,果然设置了!然后我也就抱着试试看的态度加了一下。。。竟然他妹的可以了!!!!!我当时眼泪都掉下来了!!!终于成了啊!!!太不容易了!!!研究了那么多,竟然问题出在最表面上!而且又尝试了一下我的历史版本,发现最初的版本都能直接打到边缓存边播放的效果了!热泪盈眶啊!!!所以这就是我为什么把这个属性的设置写在了全文的最前面,希望大家别和我犯一样的错误。而且我发现对于这个属性,没有那篇文章里着重提过,可能是因为太基本了,就默认大家都会了吧。。。

废话说了这么多,来解释下这个属性吧:

Indicates that the player is allowed to delay playback at the specified rate in order to minimize stalling

指定AVPlayer是否允许延迟开始播放以达到最小化延迟的目的(多缓存点,保证后面更少的出现等待缓冲的情况)。

For clients linked against iOS 10.0 and running on that version or later or linked against OS X 10.12 and running on that version or later, the default value of this property is YES.
In versions of iOS prior to iOS 10.0 and versions of OS X prior to 10.12, this property is unavailable, and the behavior of the AVPlayer corresponds to the type of content being played. For streaming content, including HTTP Live Streaming, the AVPlayer acts as if automaticallyWaitsToMinimizeStalling is YES. For file-based content, including file-based content accessed via progressive http download, the AVPlayer acts as if automaticallyWaitsToMinimizeStalling is NO.

iOS10以上可用,iOS10以下不可用。那么iOS10以下的时候,AVPlayer在这个属性上的表现与传输文件的类型有关:对于流式内容(streaming content, including HTTP Live Streaming),AVPlayer的表现形式与该属性为yes时一致,也就是说需要缓冲所需的内容量后才能开始播放;对于以文件为基础的内容(file-based content, including file-based content accessed via progressive http download)(这里我也不是很理解file-based具体是什么类型,如果有了解的小伙伴麻烦在评论里介绍下呗),AVPlayer的表现形式与该属性为NO时一致,即可以实现得到数据即播放。(由于手边没有系统版本在iOS10以下的机器,因此,实际表现有待验证)

If you employ an AVAssetResourceLoader delegate that loads media data for playback, you should set the value of your AVPlayer’s automaticallyWaitsToMinimizeStalling property to NO. Allowing the value of automaticallyWaitsToMinimizeStalling to remain YES when an AVAssetResourceLoader delegate is used for the loading of media data can result in poor start-up times for playback and poor recovery from stalls, because the behaviors provided by AVPlayer when automaticallyWaitsToMinimizeStalling has a value of YES depend on predictions of the future availability of media data that that do not function as expected when data is loaded via a client-controlled means, using the AVAssetResourceLoader delegate interface.

“如果你使用AVAssetResourceLoader代理的方式来加载媒体数据用以播放,那么你应该设置AVPlayer的automaticallyWaitsToMinimizeStalling为NO。当你使用AVAssetResourceLoader代理的方式加载媒体数据时,如果仍保持automaticallyWaitsToMinimizeStalling为yes的话,将造成播放启动时间变差(延迟)和从卡顿中恢复过来的能力变差,因为当automaticallyWaitsToMinimizeStalling为yes时,AVPlayer的行为(何时开始播放)取决于预测媒体数据未来的可获取性(需要缓冲到AVPlayer认为能够保证正常流畅播放完成的程度),但是使用AVAssetResourceLoader的代理的接口的话,数据是通过客户端控制的方式加载的,数据并不一定按预期发挥作用(由于我们在中间控制获取到的数据,所以并不一定数据返回了就用于播放)。”

由此可见,如果使用AVAssetResourceLoaderDelegate的方式,就一定要将automaticallyWaitsToMinimizeStalling设置为NO。

  • XTRangeManager

该类的主要重难点是一些逻辑处理,没有什么与功能实现相关的内容,估计对于逻辑或者算法基础比较扎实的同学可以直接略过。
从类名上就可以看出,这是一个专门用来管理range的类。在该方案中,主要涉及到两类range的处理:已缓存数据的range合并loadingRequest请求范围range的拆分

已缓存数据的range合并:由于loadingRequest的请求range不一定是连续的,所以返回的data并不一定是依次排列的,在存储时需要指明其range。
直白点讲,就是要把已缓存data的range放入到一个数组中并进行适当的合并。
由于经过处理后的range数组可以保证每个元素range见互不相交且不首尾相连。因此该问题可以进一步简化为两种情况:newRange与cacheRange相交newRange与cacheRange不相交

newRange与cacheRange相交

newRange与cacheRange相交

简单思考下,就可以发现这个问题可以简化为newRange是与单个还是多个cacheRange相交的问题。实现代码如下:

[self.cachedRangeArray enumerateObjectsUsingBlock:^(NSValue * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    
    NSRange cacheRange = [obj rangeValue];
    
    NSRange intersectionRange = NSIntersectionRange(cacheRange, newRange);
    
    if (intersectionRange.length > 0) {//如果和已缓存range有交集的话,必能与其融为一体,融合后代替其位置
        if (!hasIntersection) {//第一次出现有交集的,直接融合替换即可
            hasIntersection = YES;
            firstMergeIndex = idx;
            NSUInteger startOffset = MIN(newRange.location, cacheRange.location);
            NSUInteger mergeLength = MAX(newRange.location + newRange.length, cacheRange.location + cacheRange.length) - startOffset;
            NSRange mergeRange = NSMakeRange(startOffset, mergeLength);
            [self.cachedRangeArray replaceObjectAtIndex:idx withObject:[NSValue valueWithRange:mergeRange]];
        }else{
            //有时newRange可能和多个cacheRange有交集,那就都合到一起
            NSRange lastMergedRange = [self.cachedRangeArray[firstMergeIndex] rangeValue];//提出第一个被merge的range
            
            NSUInteger startOffset = lastMergedRange.location;
            NSUInteger mergeLength = MAX(lastMergedRange.location + lastMergedRange.length, cacheRange.location + cacheRange.length) - startOffset;
            
            NSRange mergeRange = NSMakeRange(startOffset, mergeLength);
            [self.cachedRangeArray replaceObjectAtIndex:firstMergeIndex withObject:[NSValue valueWithRange:mergeRange]];
            
            [shouldRemoveArray addObject:[self.cachedRangeArray objectAtIndex:idx]];
        }
    }
}];

newRange与cacheRange不相交

newRange与cacheRange不相交

简单思考下,newRange与cacheRange间可能存在的情况无外乎以上三种,但是需要注意的是,及时没有交集,也可以进行合并,那就是首尾相接的情况。代码实现如下:

[self.cachedRangeArray enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    
    if (![shouldRemoveArray containsObject:obj]) {//此时shouldRemoveArray中包含的是已经合并过的,就不再做处理
        
        NSRange cacheRange = [obj rangeValue];
        
        if (newRange.location < cacheRange.location) {//newRange比cacheRange小(此处的小为range.location的大小,下文一致)
            if (newRange.location + newRange.length == cacheRange.location) {//new与cache首尾相接
                [self.cachedRangeArray replaceObjectAtIndex:idx withObject:[NSValue valueWithRange:NSMakeRange(newRange.location, newRange.length + cacheRange.length)]];
            }else{
                [self.cachedRangeArray insertObject:[NSValue valueWithRange:newRange] atIndex:idx];
            }
            
            *stop = YES;//被合并或加入cachedRangeArray就不需要继续遍历其他cacheRange与其比较了
        }else{//newRange比cacheRange大
            
            if (cacheRange.location + cacheRange.length == newRange.location) {//当new在cache之后时,有一种特殊情况就是首尾相接,那么此时仍要做合并处理

                BOOL hasHandle = NO;
                
                if (idx + 1 < self.cachedRangeArray.count) {//如果当前cacheRange不是self.cachedRangeArray中的最后一个元素时(保证cachedRangeArray中有下一位)
                    NSRange nextRange = [self.cachedRangeArray[idx + 1] rangeValue];
                    if (newRange.location + newRange.length == nextRange.location) {//正好newRange的尾又与下一个的头相接(最多只能与两个cachedRange首尾相接,因为newRange和此时的所有cacheRange都没有交集,第一个循环处理已经把有交集的都合并了)
                        hasHandle = YES;
                        [self.cachedRangeArray replaceObjectAtIndex:idx withObject:[NSValue valueWithRange:NSMakeRange(cacheRange.location, cacheRange.length + newRange.length + nextRange.length)]];
                        [shouldRemoveArray addObject:self.cachedRangeArray[idx + 1]];
                    }
                }
                
                if (!hasHandle) {//如果只是单纯的一个首尾相接,则执行此处
                    [self.cachedRangeArray replaceObjectAtIndex:idx withObject:[NSValue valueWithRange:NSMakeRange(cacheRange.location, cacheRange.length + newRange.length)]];
                }
                
                *stop = YES;

            }else{//首尾不相接
                if (idx == self.cachedRangeArray.count - 1) {//在cachedRange后且不首尾相接的newRange,正常是交给下一个cachedRange处理的,但如果是最后一个cachedRange,则没有下一个,直接在此做判断
                    [self.cachedRangeArray addObject:[NSValue valueWithRange:newRange]];
                    *stop = YES;
                }
            }
        }
    }
}];

loadingRequest请求范围range的拆分:loadingRequest的请求范围内,可能会出现部分数据本地已缓存的情况,那么在发起网络请求时,就不再请求该范围数据,而直接使用本地缓存数据回填。因此就涉及到将请求数据范围与本地已缓存数据范围作比较
首先我们先找到newRange与cacheRange的交集部分,这些交集就是直接从本地缓存读取数据进行回填的部分,接下来,围绕已缓存的range来收集那些剩下的被拆散的需要网络请求的range,也就类似与上图《newRange与cacheRange不相交》中所示的情况。实现代码如下:

if (self.cachedRangeArray.count == 0) {
    XTRangeModel *model = [[XTRangeModel alloc] initWithRequestType:XTRequestFromNet RequestRange:requestRange];
    [rangModelArray addObject:model];
}else{
    
    //先处理loadingRequest和本地缓存有交集的部分
    NSMutableArray *cachedModelArray = [NSMutableArray array];
    
    [self.cachedRangeArray enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        
        NSRange cacheRange = [obj rangeValue];
        
        NSRange intersectionRange = NSIntersectionRange(cacheRange, requestRange);
        
        if (intersectionRange.length > 0) {
            XTRangeModel *model = [[XTRangeModel alloc] initWithRequestType:XTRequestFromCache RequestRange:intersectionRange];
            [cachedModelArray addObject:model];
        }
        
    }];
    
    //围绕交集,进行需要网络请求的range的拆解
    if (cachedModelArray.count == 0) {
        XTRangeModel *model = [[XTRangeModel alloc] initWithRequestType:XTRequestFromNet RequestRange:requestRange];
        [rangModelArray addObject:model];
    }else{
        
        [cachedModelArray enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            
            if (idx == 0) {
                
                XTRangeModel *firstRangeModel = cachedModelArray[0];
                if (firstRangeModel.requestRange.location > requestRange.location) {//在第一个cacheRange前还有一部分需要net请求
                    
                    XTRangeModel *model = [[XTRangeModel alloc] initWithRequestType:XTRequestFromNet RequestRange:NSMakeRange(requestRange.location, firstRangeModel.requestRange.location - requestRange.location)];
                    
                    [rangModelArray addObject:model];
                }
                [rangModelArray addObject:firstRangeModel];//注意此处的rangModelArray是最终的包含该loadingRequest的全部rangeModel的数组,因此不要忘记将刚才cachedModelArray中的model也添加进来,而且要注意顺序,依次添加
                
            }else{
                //除了首尾可能存在的两个(小于首个cachedModel 和 大于最后一个cachedModel)range,其他range都应该是夹在两个cachedModel之间的range,在此处处理
                XTRangeModel *lastCachedRangeModel = cachedModelArray[idx - 1];
                XTRangeModel *currentCachedRangeModel = cachedModelArray[idx];
                
                NSUInteger startOffst = lastCachedRangeModel.requestRange.location + lastCachedRangeModel.requestRange.length;
                
                XTRangeModel *model = [[XTRangeModel alloc] initWithRequestType:XTRequestFromNet RequestRange:NSMakeRange(startOffst, currentCachedRangeModel.requestRange.location - startOffst)];
                
                [rangModelArray addObject:model];
                [rangModelArray addObject:currentCachedRangeModel];
            }
            
            if (idx == cachedModelArray.count - 1) {//最后一个cachedRange后面可能还有一段需要网络请求
                
                XTRangeModel *lastRangeModel = cachedModelArray.lastObject;
                if (requestRange.location + requestRange.length > lastRangeModel.requestRange.location + lastRangeModel.requestRange.length) {

                    NSUInteger lastCacheRangeModelEndOffset = lastRangeModel.requestRange.location + lastRangeModel.requestRange.length;
                    
                    XTRangeModel *model = [[XTRangeModel alloc] initWithRequestType:XTRequestFromNet RequestRange:NSMakeRange(lastCacheRangeModelEndOffset, requestRange.location + requestRange.length - lastCacheRangeModelEndOffset)];
                    [rangModelArray addObject:model];
                }
            }
        }];
    }
}
  • XTRangeModel

用来存储XTRangeManager中拆分loadingRequest之后的产生的请求类型和请求范围,方便发起请求时使用。

  • XTDownloader

根据rangeModel使用NSUrlSession发起分片式数据请求,将得到的data通过XTDataManager进行缓存,同时回填给loadingRequest。

小坑④
由于一个loadingRequest可能会拆分成多个rangeModel,我尝试过同时发起多个网络请求并回填数据,但是发现在data缓存range正确的情况下,还是会出现杂音或者播放顺序不对的情况;修改成rangeModel依次顺序请求,完成后再开始下一个的方式就没有问题了。
小坑⑤
在NSURLSessionDataDelegate的代理方法

-(void)URLSession:(NSURLSession *)session 
dataTask:(NSURLSessionDataTask *)dataTask 
didReceiveResponse:(NSURLResponse *)response 
completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler;

中,我们需要填充loadingRequest.contentInformationRequest。实现代码如下:

- (void)fillContentInfo:(NSURLResponse *)response {

    if (![response isKindOfClass:[NSHTTPURLResponse class]]) {
        return;
    }
    
    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
    //服务器端是否支持分段传输
    BOOL byteRangeAccessSupported = [httpResponse.allHeaderFields[@"Accept-Ranges"] isEqualToString:@"bytes"];
    
    //获取返回文件的长度
    long long contentLength = [[[httpResponse.allHeaderFields[@"Content-Range"] componentsSeparatedByString:@"/"] lastObject] longLongValue];
    self.dataManager.contentLength = (NSUInteger)contentLength;
    //获取返回文件的类型
    NSString *mimeType = httpResponse.MIMEType;
    CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, (__bridge CFStringRef)mimeType, NULL);//此处需要引入<MobileCoreServices/MobileCoreServices.h>头文件
    NSString *contentTypeStr = CFBridgingRelease(contentType);
    
    AVAssetResourceLoadingContentInformationRequest *contentInfoRequest = self.loadingRequest.contentInformationRequest;
    if (contentInfoRequest) {
        contentInfoRequest.byteRangeAccessSupported = byteRangeAccessSupported;
        contentInfoRequest.contentLength = contentLength;
        contentInfoRequest.contentType = contentTypeStr;
    }
}

这里主要涉及到的有3个字段:
byteRangeAccessSupported:表示当前请求是否支持分段请求,如果其值为"bytes"则支持。由响应头中的"Accept-Ranges"字段而来。理论上,边缓存边播放这类需求的实现方案,都是建立在分段请求的基础上的,所以如果服务器不支持分段请求的话,该方案也就无法实现;但实际上是什么效果,因为并没有找到不支持分段请求的url,所以没能实践测试下~ 希望有的小伙伴能够提供下哦~
contentLength:返回文件长度,我发现该字段各位前辈的文章里取值的方式各有不同,估计可能是服务器返回头设置的不同吧~经过我的实践发现,还是通过"Content-Range"取除数的方式比较稳妥,因为我们首个请求一定是分段请求,所以响应头中一定会含有"Content-Range"字段。
contentType:返回文件类型。这里主要涉及到的是一系列的字符串转换操作。
除此之外,需要强调的是,这个方法实际上只会生效一次,即首次0-1的文件信息请求。后续的loadingRequest.contentInformationRequest为空,也就无法填充了。

小坑⑥
网络请求的取消需要使用

[self.dataTask cancel]

的方式,不然的话,实际上的网络数据传递是不会停止下来的,还是会消耗用户的流量。

  • XTDataManager

使用NSFileHandle进行数据的读写操作;沙盒文件创建、文件复制等沙盒文件操作。

给自己定个小目标

✌️更新一版英文的api说明和github说明!✌️ 搞定!


Demo(其中有更加详细的注释哦)

推荐阅读更多精彩内容