0%

一种可行的边下边播方案for AVPlayer

前言

什么是边下边播?

个人理解边下边播指的是从服务器获取到的数据一边供给播放器播放一边缓存到本地,即使用一遍的流量完成播放和缓存。下次播放时有缓存的地方则播放缓存数据,没有缓存的地方则从服务器获取再播放。

0.边下边播方案

目前的边下边播实现方案大致有两种:一种是使用本地代理服务器,一种是使用系统的AVAssetResourceLoaderDelegate。

使用本地代理服务器

该方案是在播放器与视频源服务器之间加一层代理服务器,拦截视频播放器发送的请求,根据拦截的请求,向网络服务器请求数据,然后写到本地。本地代理服务器从文件中读取数据并发送给播放器进行播放。

对于 iOS 端代理服务器的实现,可以参考和使用 CocoaHTTPServer。对于 iOS 端的视频缓存管理,可以参考和使用 KTVHTTPCache。这种方案难度较高,但自主可控性更高。

使用系统原生API—AVAssetResourceLoaderDelegate

方案三跟方案二原理基本一致,但是不需要我们自己开启本地服务器,实现相对简单,只需要遵守相关协议,提供播放的数据即可。默认情况下使用AVPlayer播放音视频,系统是不会把数据缓存到本地的。但是我们可以通过设置AVAssetResourceLoader的delegate来代理系统的请求。代理系统请求后我们就可以对数据进行缓存。

本文采用原生API来实现边下边播。具体实现参考我的开源库:GSDMediaCache

1.边下边播的难点—空洞

一个视频在播放的时候,用户难免会快进或快退,频繁的快进或快退会造成大量的空洞,这给缓存带来了巨大的麻烦。

绿色的代表已缓存,红色的代表未缓存。

这里给出的解决方案是稀疏文件+区间记录表。

稀疏文件(Sparse File)其实就是一个普通文件,用于保存音视频数据,当接收到数据时,按区间填充该文件。

区间记录表用于记录哪些区间是已经缓存好的。区间记录表里可能有多个区间,但当视频完全缓存时区间记录表里就只剩一个区间:[0, n-1]。

这样整个工作流程就变为:

1.根据系统的请求范围,查找区间记录表,将系统请求拆分为一个请求列表。

如:[本地请求,远程请求,本地请求…]。

2.依次执行请求列表中的每个请求,并将请求到的数据供给播放器。

在得到请求列表后,我们就需要执行这些请求。对于本地请求则直接从本地音视频缓存文件中查找该区间的数据并返回。对于远程请求则发起HTTP range请求,接收到数据后提供给播放器播放并缓存到本地。

3.对于远程请求接收到数据后缓存到音视频文件,并合并式更新区间记录表

远程请求会发起HTTP range请求,接收到数据后需要写入到音视频缓存文件,与此同时需要合并式更新区间记录表。所谓合并式更新:
比如区间记录表里有:[0, 99]、[200, 299]。如果下一次写入的是[100, 199],则需要融合区间变为[0, 299]。

这里的主要难点就是系统请求的拆分与区间记录表的维护了。当然实际开发中还会遇到各种各样的小问题,比如读写问题,内存问题,甚至是系统的坑。

2.GSDMediaCache

一个基于AVPlayer的边下边播缓存框架。具有如下特点:

  1. 支持边下边播,一遍的流量完成播放和缓存。
  2. 极致的缓存利用,只有未缓存的区间才会从服务器获取。
  3. 线程安全。
  4. 高性能,低内存、低CPU消耗。
  5. 最低支持iOS10。

2.1框架示意图

2.2类图

说明:

一个resource URL对应一个loader,一个loader管理多个loadingRequest,每个loadingRequest对应一个fetchOperation用于获取数据。fetchOperation内部根据区间记录表将range请求拆分为一个请求序列,如:[RemoteRangeTask, LocalRangeTask, RemoteRangeTask, LocalRangeTask, …]。然后依次执行RangeTask并将获取到的数据提供给播放器播放。

这里需要注意的是在iOS10上最好限制一个loader管理的loadingRequest数量,必要时手动取消一些loadingRequest,一般保持在3个左右。否则在频繁seek的时候容易出现一些奇奇怪怪的问题。

由于本SDK使用系统的API来实现,那么自然需要对AVAssetResourceLoaderDelegate进行一番介绍。

3. AVAssetResourceLoaderDelegate

3.1 代理系统的请求

在代理系统的请求前,可以通过抓包,大致了解下AVPlayer播放一个URL时的请求机制:
AVPlayer会先请求0-1的数据段,成功后再请求一大段数据,但只接收一小段数据就cancel掉请求。播放一小段后又请求一大段数据,但接收一小段数据后又cancel掉请求,循环这个操作直到接收所有数据。而当用户seek时会取消之前的请求,然后从seek处发起一个新请求。在整个过程中AVPlayer会不断的发起新请求,取消旧的请求,当然有的请求可能不会被取消因而可以正常完成。发起与取消是AVPlayer的内部行为,外部只能得到通知而不能主动发起一个AVAssetResourceLoadingRequest。

了解这一过程对后面的实现非常有帮助,下面正式代理系统的请求。

代理系统的请求非常简单,只需要将我们的对象设置为 AVAssetResourceLoader 的代理,并实现 AVAssetResourceLoaderDelegate 协议。

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
31
32
33
34
35
36
//1.将正常的scheme替换为自定义的scheme,并将self设置为resourceLoader的代理
- (AVURLAsset *)customSchemeAssetWithURL:(NSURL *)URL options:(NSDictionary<NSString *,id> *)options {
NSURL *customURL = [URL gsd_customSchemeURLByAppendingSuffix:kCustomSchemeSuffix];
AVURLAsset *urlAsset = [[AVURLAsset alloc] initWithURL:customURL options:options];
[urlAsset.resourceLoader setDelegate:self queue:self.assetDelegateQueue];
return urlAsset;
}

//2.实现`AVAssetResourceLoaderDelegate`协议中的如下两个,如果有其他的需求可以实现其他几个协议。
//接收到一个新的loadingRequest。该方法仅在系统不知道如何处理URLAsset资源时才会回调,所以我们需要自定义scheme。如果scheme是我们自定义的则返回YES表示我们将接管资源的请求,否则返回NO。
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
NSURL *resourceURL = loadingRequest.request.URL;
if ([resourceURL gsd_isCustomSchemeURLByContainningSuffix:kCustomSchemeSuffix]) {
NSURL *originalURL = [resourceURL gsd_recoverCustomSchemeURLByRemovingSuffix:kCustomSchemeSuffix];
os_unfair_lock_lock(&_loaderLock);
GSDResourceLoader *loader = self.loaderDict[originalURL];
if (loader == nil) {
loader = [[GSDResourceLoader alloc] initWithResourceURL:originalURL inSession:self.session];
self.loaderDict[originalURL] = loader;
}
os_unfair_lock_unlock(&_loaderLock);
[loader addLoadingRequest:loadingRequest];
return YES;
} else {
return NO;
}
}

//系统取消加载资源后回调。在该方法里我们需要取消掉我们之前发的请求。
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
NSURL *originalURL = [loadingRequest.request.URL gsd_recoverCustomSchemeURLByRemovingSuffix:kCustomSchemeSuffix];
os_unfair_lock_lock(&_loaderLock);
GSDResourceLoader *loader = self.loaderDict[originalURL];
os_unfair_lock_unlock(&_loaderLock);
[loader cancelLoadingRequest:loadingRequest];
}

这里需要注意的是 URL 必须是自定义的 URLScheme,我们需要把原始 URL 的 http://https:// 替换成 xxx://,协议方法才会生效。这里我们在原scheme后面拼接“-mine”作为自定义的scheme。选择在原scheme后面拼接的好处就是当我们自己去服务器请求的时候能够很方便的解析出原scheme。到此为止我们就成功拦截了播放器的请求,拦截请求之后我们就需要获取数据并提供给播放器。数据可以从本地缓存获取也可以从服务器获取,这是我们后面要做的事。

然而非常坑人的事情来了,经过测试发现在iOS10.3.3中

1
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest API_AVAILABLE(macos(10.9), ios(7.0), tvos(9.0)) API_UNAVAILABLE(watchos);

didCancelLoadingRequest:方法除了手机重启后的第一次运行APP会被调用,其他时候都不会被调用,非常诡异。而在iOS14上则正常,AVAssetResourceLoader会时不时调用didCancelLoadingRequest:让你有机会cancel掉之前的请求,从而不会出现同时进行的请求数过多的情况。为了适配iOS10,我们需要自己设计一种规则适时的取消掉一些loadingRequest,这里可谓是一个天坑。

参考:AVAssetResourceLoaderDelegate -resourceLoader: didCancelLoadingRequest: naver called (in the Device only)

3.2 发起请求

在协议

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

中根据loadingRequest参数获取本次的range请求范围,然后向本地缓存或服务器发起请求获取数据。

代理系统的请求后,我们需要根据请求去获取相应的数据,而在shouldWaitForLoadingOfRequestedResource代理方法里,系统给我们提供了一个AVAssetResourceLoadingRequest对象,因此我们需要根据AVAssetResourceLoadingRequest相关的API来获取本次range请求的范围。

AVAssetResourceLoadingRequest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface AVAssetResourceLoadingRequest : NSObject {
@private
AVAssetResourceLoadingRequestInternal *_loadingRequest;
}
AV_INIT_UNAVAILABLE

@property (nonatomic, readonly, nullable) AVAssetResourceLoadingContentInformationRequest *contentInformationRequest API_AVAILABLE(macos(10.9), ios(7.0), tvos(9.0)) API_UNAVAILABLE(watchos);

@property (nonatomic, readonly, nullable) AVAssetResourceLoadingDataRequest *dataRequest API_AVAILABLE(macos(10.9), ios(7.0), tvos(9.0)) API_UNAVAILABLE(watchos);

- (void)finishLoading API_AVAILABLE(macos(10.9), ios(7.0), tvos(9.0)) API_UNAVAILABLE(watchos); //本次LoadingRequest请求完成时调用finishLoading通知系统。

- (void)finishLoadingWithError:(nullable NSError *)error; //本次LoadingRequest请求出错时调用finishLoadingWithError:通知系统。

@end

contentInformationRequest:视频元数据信息。如果没设置过则为nil,否则是设置的值。因此需要在接收到0-1的range请求时设置该值告诉AVPlayer视频的相关信息。

dataRequest:数据请求,里面包含本次range请求的范围。

AVAssetResourceLoadingContentInformationRequest:

1
2
3
4
5
6
7
8
9
10
11
@interface AVAssetResourceLoadingContentInformationRequest : NSObject {
@private
AVAssetResourceLoadingContentInformationRequestInternal *_contentInformationRequest;
}
AV_INIT_UNAVAILABLE

@property (nonatomic, copy, nullable) NSString *contentType; //响应头的content-type,但要转为UTI才能赋值给contentType
@property (nonatomic) long long contentLength; //视频长度(byte)
@property (nonatomic, getter=isByteRangeAccessSupported) BOOL byteRangeAccessSupported; //是否支持range请求。

@end

AVAssetResourceLoadingDataRequest:

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface AVAssetResourceLoadingDataRequest : NSObject {
@private
AVAssetResourceLoadingDataRequestInternal *_dataRequest;
}
AV_INIT_UNAVAILABLE

@property (nonatomic, readonly) long long requestedOffset;
@property (nonatomic, readonly) NSInteger requestedLength;
@property (nonatomic, readonly) BOOL requestsAllDataToEndOfResource API_AVAILABLE(macos(10.11), ios(9.0), tvos(9.0)) API_UNAVAILABLE(watchos);
@property (nonatomic, readonly) long long currentOffset;
- (void)respondWithData:(NSData *)data; //将请求到的数据塞给播放器。

@end

requestedOffset:本次range请求的起始字节位置。

requestedLength:本次range请求的长度。

currentOffset:当前已下载的数据的偏移量。requestedOffset + data.length = currentOffset。每次调用respondWithData:方法后,currentOffset会改变。

示意图:

3.3 塞数据给播放器播放

1
2
3
4
5
@interface AVAssetResourceLoadingDataRequest : NSObject 

- (void)respondWithData:(NSData *)data;

@end

调用AVAssetResourceLoadingDataRequest的respondWithData方法将下载的数据提供给播放器播放。

3.4 取消请求

系统内部会不断的发起请求与取消请求,当我们收到取消请求的通知时,我们需要取消掉对应的数据请求,数据的来源可能是本地缓存也有可能是服务器,因此取消请求分为两种:本地请求取消与远程请求取消。请求的取消至关重要比如系统发起一个0-300M的range请求,恰好本地缓存有这个范围的数据,但系统接收了20M就发起了取消,此时如果你的本地请求不能取消的话APP很快就会耗尽手机的内存直至OOM崩溃。

在协议

1
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;

中取消正在进行的请求。但在iOS10上该方法不会被回调,因此对于iOS10可以在发起请求的时机中取消掉之前的一些loadingRequest,这里不能不取消也不能全取消保留2~3个loadingRequest目前来看效果比较好。

3.5 完成请求

1
2
3
4
5
6
7
@interface AVAssetResourceLoadingRequest : NSObject 

- (void)finishLoading API_AVAILABLE(macos(10.9), ios(7.0), tvos(9.0)) API_UNAVAILABLE(watchos); //本次LoadingRequest请求完成时调用finishLoading通知系统。

- (void)finishLoadingWithError:(nullable NSError *)error; //本次LoadingRequest请求出错时调用finishLoadingWithError:通知系统。

@end

本次LoadingRequest请求成功完成时调用 finishLoading 通知系统,如果失败则调用 finishLoadingWithError: 通知系统。

4.缓存数据,并建立区间记录表

数据的缓存主要涉及音频元数据的保存,音频文件的保存,区间记录表的保存。

在第一次播放的时候,是没有任何缓存的,区间记录表也没有创建,当远程请求发起HTTP range请求接收到数据后如何保存呢?这就需要用到 NSFileHandle

可以调用 NSFileHandletruncateFileAtOffset:

1
2
3
- (void)truncateFileAtOffset:(unsigned long long)offset;
- (void)seekToFileOffset:(unsigned long long)offset;
- (void)writeData:(NSData *)data;

创建一个与音视频大小相等的”稀疏文件”,然后往里填充数据即可。写入完成后,同步更新区间记录表,记录本次写入的数据区间,比如[0, 99]这100个字节。往后写入的时候需要合并式更新区间记录表。

比如区间记录表里有:[0, 99]、[200, 299]。如果下一次写入的是[100, 199],则需要融合区间变为[0, 299]。

注意:区间记录表需要正确维护否则依据区间记录表从缓存文件里读取相应的数据给播放器时会出现各种画面问题或声音问题。

5.缓存清除策略

缓存清除可以按照最大时间和最大空间两个维度来进行清除。进入后台后优先删除较旧的缓存,如果检测超出最大空间则进一步删除旧缓存直至小于最大空间的0.5倍。当然也可以实现LFU策略。

注意:在删除缓存时要确保音视频数据文件和区间记录表同时删除。如果音视频数据文件删除了但却遗留下了区间记录表显然会有问题。

5.1 计算文件大小

在实现的过程中发现文件的大小计算跟想象中的还不太一样。

系统提供了一些key用于获取文件信息

1
2
3
4
5
6
7
8
/* Resource keys applicable only to regular files
*/
FOUNDATION_EXPORT NSURLResourceKey const NSURLFileSizeKey API_AVAILABLE(macos(10.6), ios(4.0), watchos(2.0), tvos(9.0)); // Total file size in bytes (Read-only, value type NSNumber)
FOUNDATION_EXPORT NSURLResourceKey const NSURLFileAllocatedSizeKey API_AVAILABLE(macos(10.6), ios(4.0), watchos(2.0), tvos(9.0)); // Total size allocated on disk for the file in bytes (number of blocks times block size) (Read-only, value type NSNumber)
FOUNDATION_EXPORT NSURLResourceKey const NSURLTotalFileSizeKey API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0)); // Total displayable size of the file in bytes (this may include space used by metadata), or nil if not available. (Read-only, value type NSNumber)
FOUNDATION_EXPORT NSURLResourceKey const NSURLTotalFileAllocatedSizeKey API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0)); // Total allocated size of the file in bytes (this may include space used by metadata), or nil if not available. This can be less than the value returned by NSURLTotalFileSizeKey if the resource is compressed. (Read-only, value type NSNumber)
FOUNDATION_EXPORT NSURLResourceKey const NSURLIsAliasFileKey API_AVAILABLE(macos(10.6), ios(4.0), watchos(2.0), tvos(9.0)); // true if the resource is a Finder alias file or a symlink, false otherwise ( Read-only, value type boolean NSNumber)
FOUNDATION_EXPORT NSURLResourceKey const NSURLFileProtectionKey API_AVAILABLE(macos(10.11), ios(9.0), watchos(2.0), tvos(9.0)); // The protection level for this file

注意NSURLTotalFileAllocatedSizeKey的说明:This can be less than the value returned by NSURLTotalFileSizeKey if the resource is compressed. 也就是说文件大小和文件实际占用的磁盘大小有可能不一致,这得益于APFS文件系统。

eg: 一个553M的视频,在缓存时会先初始化一个553M的占位文件,然后边下载边往里面填充实际数据。如果没有缓存完就停止缓存,此时用NSURLFileSizeKey 和 NSURLTotalFileAllocatedSizeKey去获取文件大小就会发现二者的值不一样。

NSURLFileSizeKey 返回的是文件自身的大小。而NSURLTotalFileAllocatedSizeKey 返回的是该文件占用的磁盘大小,因为实际只缓存了一点点数据,系统会对他进行压缩。所以该值会小于NSURLFileSizeKey 返回的值。当完全缓存时,该值就会约等于NSURLFileSizeKey 返回的值(实际会稍大于,因为NSURLTotalFileAllocatedSizeKey 还会计算元数据占用的空间)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2021-01-26 11:01:24.590838+0800 AudioDemo[6292:586528] attrs:{
NSFileCreationDate = "2021-01-25 09:31:02 +0000";
NSFileExtensionHidden = 0;
NSFileGroupOwnerAccountID = 501;
NSFileGroupOwnerAccountName = mobile;
NSFileModificationDate = "2021-01-26 02:43:28 +0000";
NSFileOwnerAccountID = 501;
NSFileOwnerAccountName = mobile;
NSFilePosixPermissions = 420;
NSFileProtectionKey = NSFileProtectionCompleteUntilFirstUserAuthentication;
NSFileReferenceCount = 1;
NSFileSize = 553363384;
NSFileSystemFileNumber = 4470737601;
NSFileSystemNumber = 16777223;
NSFileType = NSFileTypeRegular;
}

2021-01-26 11:03:29.875546+0800 AudioDemo[6292:586700] 文件:file:///private/var/mobile/Containers/Data/Application/407F45FC-F6D8-411F-89FA-F014ADC5F4EA/Library/Caches/com.xq.GSDMediaCache/data/ddcada74ee08d06dda5cd13b4117ad30.mp4,totalAllocatedSize:553365504

iOS 14.3上“ iPhone存储空间”里的App“文稿与数据”只显示App沙盒Documents目录下文件占用的空间,并且展示的是文件实际占用的磁盘大小即NSURLTotalFileAllocatedSizeKey的值,而Cache目录下的文件并不会计算在内。可能是因为Cache目录下的文件在磁盘空间紧张时系统会自动清理。

iOS 10.3.3上“ iPhone存储空间”里的App“文稿与数据”显示的是所有沙盒目录下的文件占用的空间。

文件系统

发展过程:分层文件系统(Hierarchical File System,HFS)—> HFS Plus —> APFS (Apple File System)

APFS :

Apple File System replaces HFS Plus as the default file system for iOS 10.3 and later, and for macOS High Sierra and later. Apple File System offers improved file system fundamentals as well as several new features, including cloning, snapshots, space sharing, fast directory sizing, atomic safe-save, and sparse files.

里面提到了sparse file(稀疏文件),正是由于APFS文件系统支持稀疏文件,所以一个稀疏文件如果不往里填充数据那么它实际不会占用那么大的磁盘空间。

参考:

About Apple File System

What is a sparse file and why do we need it?

How can I calculate the size of a folder?

“文件大小”和“占用空间”的区别

6.最后

如果这个库有帮助到你,欢迎点赞,收藏,转发。当然支持一下也是可以的。最后转载请注明出处。

7.参考

Audio streaming and caching in iOS using AVAssetResourceLoader and AVPlayer

iOS音视频实现边下载边播放

AVPlayer支持的视频格式 原出处: AVPlayer支持的视频格式

音视频封装格式、编码格式

iOS AVPlayer 视频缓存的设计与实现

可能是目前最好的 AVPlayer 音视频缓存方案

AVPlayer 视频缓存

iOS音视频开发——-流媒体

iOS 获取视频的任意一帧

IOS音视频(四十五)HTTPS 自签名证书 实现边下边播

AVPlayer 边下边播与最佳实践

基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出

如果视频拖动快进这时候会有声音但画面卡住了

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+

点播中的流量成本优化

腾讯研发总监王辉:十亿级视频播放技术优化揭秘

NicooM3u8Downloader

觉得文章有帮助可以打赏一下哦!