AVPlayer缓存-OTPlayerCache的介绍

AVPlayer缓存-OTPlayerCache的介绍

##0x00 AVPlayer 实现缓存的两种方式

AVPlayer 实现缓存主要有两种方式,第一种是在 App 内置一个 HTTP 服务器,采用代理的方式实现缓存,这种实现方案需要引入第三方库,实现上也较为复杂,优点是通用性强,不局限于 AVPlayer 的缓存,理论上支持任意播放器,甚至是接口请求和图片的缓存都可以做。

AVAssetResourceLoader 的实现 AVPlayer 缓存最直接的方式,本身也是 AVFoundation 自带的 API ,通过代理的方式实现缓存,技术上比较简单直接,不需要引入第三方库,网上相关的资料也比较多。优先采用此方式。

HTTP 方式的缓存实现,具体可以查看开源库:https://github.com/ChangbaDevs/KTVHTTPCache

0x01 AVAssetResourceLoader 的基本原理

AVAssetResourceLoader 是 AVAsset 的一个属性,字面意思上是用来获取视频内容的,但其实我们只用到它里面的一个属性 AVAssetResourceLoaderDelegate,它是一个代理,当我们设置了代理,那么当AVPlayer需要视频内容片段的时候,就会调用对应的代理,我们在代理里面发起HTTP请求,将内容copy一份给AVPlayer,保存一份到本地磁盘,当视频播放完成的时候,将所有本地磁盘中的数据片段重新组合形成一个mp4视频文件,这个文件就被我们成功缓存下来了。

AVPlayer对视频的请求是分片的,我们可以抓请求看一下:

播放一个很短的视频片段,发起了一系列的请求

这些请求都有一个Range属性,指定了数据段

range 0-1一般是视频开始的第一个请求,主要是为了获取视频元数据,用来确定视频的长度和格式

以下是一些代码:

设置代理

1
2
OTAssetLoaderDelegate *resourceLoader = [OTAssetLoaderDelegate new];
[self.videoURLAsset.resourceLoader setDelegate:resourceLoader queue:dispatch_get_main_queue()];

代理方法定义

1
2
3
4
5
6
7
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {

}

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForRenewalOfRequestedResource:(AVAssetResourceRenewalRequest *)renewalRequest {

}

可以看到在代理方法中传过来一个属性 AVAssetResourceLoadingRequest ,这里面有几个重要的属性需要介绍一下

  • NSURLRequest *request

    它是 AVPlayer 发起的原始请求,里面包含对应的 url,header,range 等 HTTP 请求信息,对于我们非常重要,因为我们自己的请求就是基于这个 request 的。

  • AVAssetResourceLoadingContentInformationRequest *contentInformationRequest

    这个属性包含了关于这个视频文件的一些信息,比如 contentTypecontentLength ,特别是 contentType ,如果设置的值不在 allowedContentTypes 之内,那么播放会失败。所以这里有个需要注意的地方,服务器返回的 contentType 必须是正确的值,不然就算URL实际指向的是一个视频文件,播放也会有问题的。

  • AVAssetResourceLoadingDataRequest *dataRequest

    dataRequest 就是代表实际数据,属性 requestedOffset,requestedLength用来设置请求的Range头,

    requestedOffset当前请求的偏移量,requestedLength表示请求长度。

    因为HTTP请求视频文件是一点一点返回的,所以没当有数据返回,就必须调用respondWithData方法,把数据给AVAssetResourceLoadingRequest。

基本流程其实很简单,当AVPlayer需要数据的时候就不断的调用代理方法 shouldWaitForLoadingOfRequestedResource,然后我们发起一个新的 HTTP 请求,在请求的 didReceiveResponse 中设置 contentInformationRequest 的相关属性,在 didReceiveData 的时候给AVAssetResourceLoadingRequest赋值且保存一份到本地磁盘,当播放完成或者下载完成的时候,合成出一个视频文件出来。

其实在一开始的时候还有一个步骤,就是把视频 URL 的 scheme 从 HTTP 改为我们自定义的 scheme ,因为 AVPlayer 只有当它不知道怎么加载这个资源的时候才会走我们的代理。

0x02 缓存的详细流程

以下是详细流程,主要是一些代码,或者伪代码

  1. 设置自定义 Scheme

    1
    2
    3
    4
    5
    NSURL *playUrl = @"http://120.25.226.186:32812/resources/videos/minion_01.mp4";
    playUrl = [OTVideoDownloadModel getSchemeVideoURL:self.videoURL];

    // result playUrl = @"OTStreamhttp://120.25.226.186:32812/resources/videos/minion_01.mp4";

  2. 代理方法 - resourceLoader:shouldWaitForLoadingOfRequestedResource:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    - (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
    // 1. 新的request
    NSMutableURLRequest * request = loadingRequest.request.mutableCopy;
    NSURLComponents * comps = [[NSURLComponents alloc] initWithURL:request.URL resolvingAgainstBaseURL:NO];
    comps.scheme = [comps.scheme stringByReplacingOccurrencesOfString:OTCustomSchemePrefix withString:@""];
    request.URL = comps.URL;
    request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
    [self setupRequestRangeField:request dataRequest:loadingRequest.dataRequest];

    OTLog(@"request: %@", request.allHTTPHeaderFields[@"Range"]);

    // 2. 发起请求
    NSURLConnection * connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];

    // 3. OT封装
    OTVideoDownloadModel * model = [OTVideoDownloadModel new];
    model.AVPlayerRequest = loadingRequest;
    model.connection = connection;
    model.url = request.URL;
    [self.requestList addObject:model];

    [connection start];

    self.url = request.URL;

    return YES;
    }
  3. 代理方法-resourceLoader:didCancelLoadingRequest

    1
    2
    3
    - (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
    [self removeRequest:loadingRequest];
    }

    这是取消请求的方法,这个方法会被重复调用,内部是AVPlayer在控制,唯一要做的就是在这里取消对应的HTTP请求。

    具体的内部调用策略不是很清楚,有时候会请求一大段数据,然后中途断开。

  4. NSURLConnection的代理方法之 -connection:didReceiveResponse:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    // 1. 获取contentType
    NSString *contentType = [response MIMEType];
    unsigned long long contentLength = [response expectedContentLength];

    // 2. 获取contentLength
    NSString *rangeValue = [(NSHTTPURLResponse *)response allHeaderFields][@"Content-Range"];
    if (rangeValue) {
    NSArray *rangeItems = [rangeValue componentsSeparatedByString:@"/"];
    if (rangeItems.count > 1) {
    contentLength = [rangeItems[1] longLongValue];
    } else {
    contentLength = [response expectedContentLength];
    }
    }
    // 3. 设置 AVAssetResourceLoadingRequest
    AVAssetResourceLoadingRequest * request = [self loadingRequestForConnection:connection];
    request.response = response;
    request.contentInformationRequest.contentLength = contentLength;
    request.contentInformationRequest.contentType = contentType;
    request.contentInformationRequest.byteRangeAccessSupported = YES;
    self.fileLength = contentLength;
    }
  5. NSURLConnection的代理方法之 -connection:didReceiveData:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    // 1. 给 AVAssetResourceLoadingRequest 数据
    AVAssetResourceLoadingRequest * request = [self loadingRequestForConnection:connection];
    [request.dataRequest respondWithData:data];
    // 2. 写一份到磁盘中
    OTVideoDownloadModel * model = [self downloadModelWithConnection:connection];
    [model openFileWriterIfNeed];
    model.realRequestedLength += data.length;
    [model.fileHandler seekToEndOfFile];
    [model.fileHandler writeData:data];
    }
  6. NSURLConnection的代理方法之 -connectionDidFinishLoading:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    - (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    // 1. AVAssetResourceLoadingRequest 完成数据加载
    AVAssetResourceLoadingRequest * request = [self loadingRequestForConnection:connection];
    [request finishLoading];
    [self removeRequest:request];

    // 2. 关闭本地文件句柄
    OTVideoDownloadModel * model = [self downloadModelWithConnection:connection];
    [model.fileHandler closeFile];
    }
  7. NSURLConnection的代理方法之 -connection:didFailWithError:

    1
    2
    3
    4
    5
    6
    7
    - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    // 1. 调用 AVAssetResourceLoadingRequest 的加载失败
    AVAssetResourceLoadingRequest * request = [self loadingRequestForConnection:connection];
    [request finishLoadingWithError:error];
    // 2. 取消HTTP请求
    [self removeRequest:request];
    }
  8. 注意

    AVAssetResourceLoadingRequest 中的request默认是包含Range的,但是在iOS11中没有了,所以需要使用 AVAssetResourceLoadingDataRequest 中的 requestedOffsetrequestedLength来组成一个Range。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    - (void) setupRequestRangeField:(NSMutableURLRequest *)request dataRequest:(AVAssetResourceLoadingDataRequest *)dataRequest {
    // iOS 11 中 request 不包含Range,所以需要自己手动填充进去
    if ([request valueForHTTPHeaderField:@"Range"] != nil) {
    return ;
    }
    long long offset = dataRequest.requestedOffset;
    NSInteger length = dataRequest.requestedLength;
    NSRange range = NSMakeRange((NSUInteger)offset, length);
    long long fromOffset = range.location;
    long long endOffset = range.location + range.length - 1;
    NSString *rangeStr = [NSString stringWithFormat:@"bytes=%lld-%lld", fromOffset, endOffset];
    [request setValue:rangeStr forHTTPHeaderField:@"Range"];
    }
  9. 以上这些代理实现后就已经能顺利播放,并且将数据保存到本地了。现在还缺少一个方法来将本地的数据片段组合成一个完成的视频文件。这个方法的话不同的人有不同的写法,下一小节会介绍我的算法。

##0x03 OTPlayerCache 的结构

AVAssetResourceLoader 虽然是一个比较简单的东西,但还是有很多细节,比如本地缓存目录的管理,这里面涉及到老旧文件清理,缓存目录大小,最大缓存数量限制等。

临时缓存文件的管理,包括创建、删除、管理NSFileHandle的生命周期

视频文件的合成

1 - OTVideoDownloadModel

这个类就是对一个AVPlayer请求的封装,主要保存了几个关键的属性,在后续NSURLConnection回调中可以方便的取回

还有一个功能就是替换scheme

2 - OTVideoCacheService

这个类管理了视频缓存这边的所有本地文件的管理,包括成功缓存到的视频文件管理,临时文件的管理。比如外部需要做缓存大小查询、缓存清楚相关任务,就可以使用这个类,播放器在判断是否本地已有缓存的时候,也使用这个类。

3 - 视频文件合成

缓存目录如下:

命名方式是 文件名-offset-length。

代码主要分为两部分

第一部分是排序,就是针对这个文件列表,文件从小到大排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[self.requestList sortUsingComparator:^NSComparisonResult(OTVideoDownloadModel *  _Nonnull obj1, OTVideoDownloadModel *  _Nonnull obj2) {
NSArray<NSString *> * obj1Arr = [obj1.filePath componentsSeparatedByString:@"-"];
NSArray<NSString *> * obj2Arr = [obj2.filePath componentsSeparatedByString:@"-"];

if ([obj1Arr[obj1Arr.count - 2] integerValue] > [obj2Arr[obj2Arr.count - 2] integerValue]) {
return NSOrderedDescending;
} else if ([obj1Arr[obj1Arr.count - 2] integerValue] < [obj2Arr[obj2Arr.count - 2] integerValue]) {
return NSOrderedAscending;
} else {
if ([obj1Arr[obj1Arr.count - 1] integerValue] > [obj2Arr[obj2Arr.count - 1] integerValue]) {
return NSOrderedDescending;
} else if ([obj1Arr[obj1Arr.count - 1] integerValue] < [obj2Arr[obj2Arr.count - 1] integerValue]) {
return NSOrderedAscending;
} else {
return NSOrderedSame;
}
}
return [obj1.filePath compare:obj2.filePath];
}];

先判断offset,如果相等的话,再判断length

第二部分是从临时文件里面拿到合适的data,写到视频文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[self.requestList enumerateObjectsUsingBlock:^(OTVideoDownloadModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
OTLog(@"file list %@", obj.filePath);
// 1. 拿到Data
NSData * writeData = [NSData dataWithContentsOfFile:obj.filePath];
// 2. 如果文件有缺失, 停止合成
if (obj.AVPlayerRequest.dataRequest.requestedOffset > self.writeLength) {
OTLog(@"文件有缺失, 停止合成");
[self.fileHandler closeFile];
[OTVideoCacheService removeVideoCacheWithURL:self.url complete:nil];
return ;
}

if (obj.AVPlayerRequest.dataRequest.requestedOffset + writeData.length <= self.writeLength) {
// 3. 如果已经这个Data已经写进去了,那么忽略
// 已经有的数据
} else {
// 4. 如果Data有重合部分,那么去重
if (obj.AVPlayerRequest.dataRequest.requestedOffset < self.writeLength) {
long cha = self.writeLength - obj.AVPlayerRequest.dataRequest.requestedOffset;
writeData = [writeData subdataWithRange:NSMakeRange(cha, writeData.length - cha)];
}
// 5. 将Data写入
[self.fileHandler seekToEndOfFile];
[self.fileHandler writeData:writeData];
writedLength += writeData.length;

self.writeLength += writeData.length;
}

}];

解释一下第二点,为什么文件会有缺失。

有一种情况,用户拖动了进度条,导致中间有一段数据是不会去请求的。然后解释一下判断,requestedOffset表示请求当前所处视频的offset,那么当你拖动了之后,这个offset必然会比当前写的位置更靠后,判断也就不成立了。

如果有缺失片段,果断放弃合成,不要犹豫

图示:

解释一下第三点,什么叫这个Data已经写进去了,要忽略

主要是因为AVPlayer的请求有点跳,它不是顺序请求的,有些已经请求的数据,它会再请求一次,所以有些数据段会重合,甚至完全重合,比如第一个请求,总是重合的。

图示:

解释第四点,为什么会有重合部分。

原因其实在第三点已经说过了。看一下图示:

跟第三点差不多,在排除完全重合的条件之后,只要判断 requestedOffset < self.writeLength,那么这个片段必然是有重合部分的。

排除这几个特殊情况后,正常情况,直接把数据插入到文件末尾就可以了。

##0x04 OTPlayerCache 还需要完善的地方

  1. 播放过程中如果有拖动,导致缓存缺失的情况下,所有已缓存下来的文件都会被删除

    已经下载到本地的片段无需删除,下次再播放的时候可以重复利用,不过这样代码会比较复杂。

  2. 缺少性能优化

  3. 缺少有效的测试

  4. 需要增加对预加载的支持

  5. 合成算法应该有更好的方案

0x05 资料

  1. HTTP方式实现的缓存方案:KTVHTTPCache,https://github.com/ChangbaDevs/KTVHTTPCache