使用 AVAssetResourceLoader 和 AVPlayer 在 iOS 中进行音频流式传输和缓存





4.00/5 (1投票)
如何在您的应用程序中使用 AVAssetResourceLoader 和 AVPlayer。
引言
本文将介绍 iOS 中的音频流式传输和缓存。如果您想了解我们如何在应用程序中实现音频流式传输,以及如何在您的应用程序中使用 AVAssetResourceLoader 和 AVPlayer,那么本教程将适合您。
我们使用 AVPlayer 来播放本地存储和远程服务器上的音频文件。如果您想从某个云服务流式传输音频文件,您应该获取该文件的直接 URL,并使用接收到的 URL 初始化 AVPlayer。我们对 DropBox、Box、Google Drive 等大多数云服务都采用了这种方法,但对于 Yandex.Disk 和 Web Dav,它不起作用。这是因为您在向服务器发送 GET 请求时需要设置授权标头。AVPlayer 接口中没有公共方法允许您这样做。
因此,我们开始寻找解决方案,并发现了 AVURLAsset 中的 resourceLoader 对象。这实际上是一个很棒的 API,您可以使用它来提供对 AVPlayer 远程文件的受控访问。它的工作方式就像一个本地 HTTP 代理,但没有任何麻烦。
最重要的一点是,当 AVPlayer 不知道如何加载资源时,它会使用 resourceLoader。这里的诀窍是更改协议,以便 AVPlayer 被强制将资源加载推迟到我们的应用程序。因此,我们应该更改资源的方案,并使用新 URL 初始化 AVPlayer。
当您使用 AVAssetResourceLoader 时,您应该实现 AVAssetResourceLoaderDelegate 的两个方法。
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
当需要应用程序协助加载资源时,委托会接收 resourceLoader: shouldWaitForLoadingOfRequestedResource: 消息。在这种情况下,我们保存 AVAssetResourceLoadingRequest 并开始数据加载操作。当资源中的数据不再需要或当加载请求被同一资源的新的数据请求所取代时,委托会接收 resourceLoader: didCancelLoadingRequest:,我们会取消数据加载操作。
资源加载器
因此,让我们创建具有自定义方案的 AVPlayer。
NSURL *url = [NSURL URLWithString:@"customscheme://host/audio.mp3"];
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:asset];
[self addObserversForPlayerItem:item];
self.player = [AVPlayer playerWithPlayerItem:playerItem];
[self addObserversForPlayer];
前两行使用自定义 URL 创建 AVURLAsset,并设置 AVAssetResourceLoaderDelegate 和 dispatch queue,委托方法将在该 queue 上调用。
接下来的两行从 AVURLAsset 创建 AVPlayerItem,然后从 AVPlayerItem 创建 AVPlayer,并添加所需的观察者。
接下来,我们应该创建一个自定义类,该类将从服务器加载请求的资源数据,并将加载的数据传回 AVURLAsset。我们将其命名为 LSFilePlayerResourceLoader,并在 init 方法中添加两个参数。第一个参数是请求的文件 URL,第二个参数是 YDSession 对象。此会话对象负责从 Yandex.Disk 云服务器获取文件数据。
我们的 LSFilePlayerResourceLoader 对象将存储在一个字典中,资源 URL 将是键。
我们的 AVAssetResourceLoaderDelegate 实现将如下所示:
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest{
NSURL *resourceURL = [loadingRequest.request URL];
if([resourceURL.scheme isEqualToString:@"customscheme"]){
LSFilePlayerResourceLoader *loader = [self resourceLoaderForRequest:loadingRequest];
if(loader==nil){
loader = [[LSFilePlayerResourceLoader alloc] initWithResourceURL:resourceURL session:self.session];
loader.delegate = self;
[self.resourceLoaders setObject:loader forKey:[self keyForResourceLoaderWithURL:resourceURL]];
}
[loader addRequest:loadingRequest];
return YES;
}
return NO;
}
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest{
LSFilePlayerResourceLoader *loader = [self resourceLoaderForRequest:loadingRequest];
[loader removeRequest:loadingRequest];
}
首先,我们应该检查 resourceURL 方案,然后获取缓存的 LSFilePlayerResourceLoader 或创建一个新的。之后,我们将 loadingRequest 添加到我们的资源加载器中。
LSFilePlayerResourceLoader 接口如下:
@interface LSFilePlayerResourceLoader : NSObject
@property (nonatomic,readonly,strong)NSURL *resourceURL;
@property (nonatomic,readonly)NSArray *requests;
@property (nonatomic,readonly,strong)YDSession *session;
@property (nonatomic,readonly,assign)BOOL isCancelled;
@property (nonatomic,weak)id <LSFilePlayerResourceLoaderDelegate> delegate;
- (instancetype)initWithResourceURL:(NSURL *)url session:(YDSession *)session;
- (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
- (void)removeRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
- (void)cancel;
@protocol LSFilePlayerResourceLoaderDelegate <NSObject>
@optional
- (void)filePlayerResourceLoader:(LSFilePlayerResourceLoader *)resourceLoader
didFailWithError:(NSError *)error;
- (void)filePlayerResourceLoader:(LSFilePlayerResourceLoader *)resourceLoader
didLoadResource:(NSURL *)resourceURL;
@end
此接口具有管理加载器队列中请求的方法,以及 LSFilePlayerResourceLoaderDelegate 协议,该协议定义了允许您的代码处理资源加载状态的方法。
当我们向队列添加 loadingRequest 时,我们将其保存在 pendingRequests 数组中并开始数据加载操作。
- (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest{
if(self.isCancelled==NO){
NSURL *interceptedURL = [loadingRequest.request URL];
[self startOperationFromOffset:loadingRequest.dataRequest.requestedOffset
length:loadingRequest.dataRequest.requestedLength];
[self.pendingRequests addObject:loadingRequest];
}
else{
if(loadingRequest.isFinished==NO){
[loadingRequest finishLoadingWithError:[self loaderCancelledError]];
}
}
}
第一次,我们为每个即将到来的请求创建了一个新的数据加载操作。这就是为什么文件是从多个线程加载的。但后来我们发现,当 AVAssetResourceLoader 开始新的加载请求时,之前发出的请求可能会被取消。因此,在我们开始操作的方法中,我们取消了所有先前启动的操作。
有两种数据加载操作。contentInfoOperation 用于识别内容长度、内容类型以及资源是否支持字节范围请求。使用字节范围请求,AVPlayer 可以做得更花哨并应用各种优化。第二个是 dataOperation,它以偏移量加载文件数据。我们从 AVAssetResourceLoadingDataRequest 获取请求的数据偏移量。
- (void)startOperationFromOffset:(unsigned long long)requestedOffset
length:(unsigned long long)requestedLength{
[self cancelAllPendingRequests];
[self cancelOperations];
__weak typeof (self) weakSelf = self;
void(^failureBlock)(NSError *error) = ^(NSError *error) {
[weakSelf performBlockOnMainThreadSync:^{
if(weakSelf && weakSelf.isCancelled==NO){
[weakSelf completeWithError:error];
}
}];
};
void(^loadDataBlock)(unsigned long long off, unsigned long long len) = ^(unsigned long long offset,unsigned long long length){
[weakSelf performBlockOnMainThreadSync:^{
NSString *bytesString = [NSString stringWithFormat:@"bytes=%lld-%lld",offset,(offset+length-1)];
NSDictionary *params = @{@"Range":bytesString};
id req =
[weakSelf.session partialContentForFileAtPath:weakSelf.path withParams:params response:nil
data:^(UInt64 recDataLength, UInt64 totDataLength, NSData *recData) {
[weakSelf performBlockOnMainThreadSync:^{
if(weakSelf && weakSelf.isCancelled==NO){
LSDataResonse *dataResponse =
[LSDataResonse responseWithRequestedOffset:offset
requestedLength:length
receivedDataLength:recDataLength
data:recData];
[weakSelf didReceiveDataResponse:dataResponse];
}
}];
}
completion:^(NSError *err) {
if(err){
failureBlock(err);
}
}];
weakSelf.dataOperation = req;
}];
};
if(self.contentInformation==nil){
self.contentInfoOperation = [self.session fetchStatusForPath:self.path completion:^(NSError *err, YDItemStat *item) {
if(weakSelf && weakSelf.isCancelled==NO){
if(err==nil){
NSString *mimeType = item.path.mimeTypeForPathExtension;
CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType,(__bridge CFStringRef)(mimeType),NULL);
unsigned long long contentLength = item.size;
weakSelf.contentInformation = [[LSContentInformation alloc] init];
weakSelf.contentInformation.byteRangeAccessSupported = YES;
weakSelf.contentInformation.contentType = CFBridgingRelease(contentType);
weakSelf.contentInformation.contentLength = contentLength;
[weakSelf prepareDataCache];
loadDataBlock(requestedOffset,requestedLength);
weakSelf.contentInfoOperation = nil;
}
else{
failureBlock(err);
}
}
}];
}
else{
loadDataBlock(requestedOffset,requestedLength);
}
}
当收到内容信息请求且没有缓存文件时,我们初始化数据缓存并开始获取音频文件。
我们使用临时文件来缓存从 Web 接收的数据,并在需要时读取它。
- (void)prepareDataCache{
self.cachedFilePath = [[self class] pathForTemporaryFile];
NSError *error = nil;
if ([[NSFileManager defaultManager] fileExistsAtPath:self.cachedFilePath] == YES){
[[NSFileManager defaultManager] removeItemAtPath:self.cachedFilePath error:&error];
}
if (error == nil && [[NSFileManager defaultManager] fileExistsAtPath:self.cachedFilePath] == NO) {
NSString *dirPath = [self.cachedFilePath stringByDeletingLastPathComponent];
[[NSFileManager defaultManager] createDirectoryAtPath:dirPath
withIntermediateDirectories:YES
attributes:nil
error:&error];
if (error == nil) {
[[NSFileManager defaultManager] createFileAtPath:self.cachedFilePath
contents:nil
attributes:nil];
self.writingFileHandle = [NSFileHandle fileHandleForWritingAtPath:self.cachedFilePath];
@try {
[self.writingFileHandle truncateFileAtOffset:self.contentInformation.contentLength];
[self.writingFileHandle synchronizeFile];
}
@catch (NSException *exception) {
NSError *error = [[NSError alloc] initWithDomain: LSFilePlayerResourceLoaderErrorDomain
code: -1
userInfo: @{ NSLocalizedDescriptionKey : @"can not write to file" }];
[self completeWithError:error];
return;
}
self.readingFileHandle = [NSFileHandle fileHandleForReadingAtPath:self.cachedFilePath];
}
}
if (error != nil) {
[self completeWithError:error];
}
}
当收到新数据时,我们将其缓存到磁盘,更新 receivedDataLength,然后通知所有待处理的请求。
- (void)didReceiveDataResponse:(LSDataResonse *)dataResponse{
[self cacheDataResponse:dataResponse];
self.receivedDataLength=dataResponse.currentOffset;
[self processPendingRequests];
}
cache data response 方法负责使用请求的偏移量缓存接收到的数据。
- (void)cacheDataResponse:(LSDataResonse *)dataResponse{
unsigned long long offset = dataResponse.dataOffset;
@try {
[self.writingFileHandle seekToFileOffset:offset];
[self.writingFileHandle writeData:dataResponse.data];
[self.writingFileHandle synchronizeFile];
}
@catch (NSException *exception) {
NSError *error = [[NSError alloc] initWithDomain: LSFilePlayerResourceLoaderErrorDomain
code: -1
userInfo: @{ NSLocalizedDescriptionKey : @"can not write to file" }];
[self completeWithError:error];
}
}
read data 方法负责从磁盘读取缓存的数据。
- (NSData *)readCachedData:(unsigned long long)startOffset
length:(unsigned long long)numberOfBytesToRespondWith{
@try {
[self.readingFileHandle seekToFileOffset:startOffset];
NSData *data = [self.readingFileHandle readDataOfLength:numberOfBytesToRespondWith];
return data;
}
@catch (NSException *exception) {}
return nil;
}
在 processPendingRequests 方法中,我们填充内容信息并写入缓存数据。当收到所有请求的数据后,我们从队列中删除待处理的请求。
- (void)processPendingRequests{
NSMutableArray *requestsCompleted = [[NSMutableArray alloc] init];
for (AVAssetResourceLoadingRequest *loadingRequest in self.pendingRequests){
[self fillInContentInformation:loadingRequest.contentInformationRequest];
BOOL didRespondCompletely = [self respondWithDataForRequest:loadingRequest.dataRequest];
if (didRespondCompletely){
[loadingRequest finishLoading];
[requestsCompleted addObject:loadingRequest];
}
}
[self.pendingRequests removeObjectsInArray:requestsCompleted];
}
写入关于内容的信息,例如内容长度、内容类型以及资源是否支持字节范围请求。
- (void)fillInContentInformation:(AVAssetResourceLoadingContentInformationRequest *)
contentInformationRequest{
if (contentInformationRequest == nil || self.contentInformation == nil){
return;
}
contentInformationRequest.byteRangeAccessSupported = self.contentInformation.byteRangeAccessSupported;
contentInformationRequest.contentType = self.contentInformation.contentType;
contentInformationRequest.contentLength = self.contentInformation.contentLength;
}
从缓存中读取数据并将其传递给待处理的请求。
- (BOOL)respondWithDataForRequest:(AVAssetResourceLoadingDataRequest *)dataRequest{
long long startOffset = dataRequest.requestedOffset;
if (dataRequest.currentOffset != 0){
startOffset = dataRequest.currentOffset;
}
// Don't have any data at all for this request
if (self.receivedDataLength < startOffset){
return NO;
}
// This is the total data we have from startOffset to whatever has been downloaded so far
NSUInteger unreadBytes = self.receivedDataLength - startOffset;
// Respond with whatever is available if we can't satisfy the request fully yet
NSUInteger numberOfBytesToRespondWith = MIN(dataRequest.requestedLength, unreadBytes);
BOOL didRespondFully = NO;
NSData *data = [self readCachedData:startOffset length:numberOfBytesToRespondWith];
if(data){
[dataRequest respondWithData:data];
long long endOffset = startOffset + dataRequest.requestedLength;
didRespondFully = self.receivedDataLength >= endOffset;
}
return didRespondFully;
}
云 SDK
这就是我们的加载器。是时候修补 Yandex.Disk SDK 并添加从服务器获取部分内容的方法了。在您的情况下,这可能是不同的云服务及其自己的 SDK,但您应该应用与下面描述相同的更改。总共只有 3 个。
第一个,我们应该检查给定 SDK 中的所有请求是否具有 cancel 方法。不幸的是,客户端代码无法在 Yandex.Disk SDK 中取消任何请求,因此我们应该添加此可能性。只需在 YDSession.h 中声明新的协议 YDSessionRequest,并在所有请求中返回它。
@protocol YDSessionRequest <NSObject>
- (void)cancel;
@end
- (id<YDSessionRequest>)fetchDirectoryContentsAtPath:(NSString *)path
completion:(YDFetchDirectoryHandler)block;
- (id<YDSessionRequest>)fetchStatusForPath:(NSString *)path
completion:(YDFetchStatusHandler)block;
接下来,我们应该实现使用给定文件和长度获取请求文件的部分数据的方法。
- (id<YDSessionRequest>)partialContentForFileAtPath:(NSString *)srcRemotePath
withParams:(NSDictionary *)params
response:(YDDidReceiveResponseHandler)response
data:(YDPartialDataHandler)data
completion:(YDHandler)completion{
return [self downloadFileFromPath:srcRemotePath
toFile:nil
withParams:params
response:response
data:data
progress:nil
completion:completion];
}
- (id<YDSessionRequest>)downloadFileFromPath:(NSString *)path
toFile:(NSString *)aFilePath
withParams:(NSDictionary *)params
response:(YDDidReceiveResponseHandler)responseBlock
data:(YDPartialDataHandler)dataBlock
progress:(YDProgressHandler)progressBlock
completion:(YDHandler)completionBlock{
NSURL *url = [YDSession urlForDiskPath:path];
if (!url) {
completionBlock([NSError errorWithDomain: kYDSessionBadArgumentErrorDomain
code: 0
userInfo: @{ @"getPath": path }]);
return nil;
}
BOOL skipReceivedData = NO;
if(aFilePath==nil){
aFilePath = [[self class] pathForTemporaryFile];
skipReceivedData = YES;
}
NSURL *filePath = [YDSession urlForLocalPath:aFilePath];
if (!filePath) {
completionBlock([NSError errorWithDomain: kYDSessionBadArgumentErrorDomain
code: 1
userInfo: @{ @"toFile": aFilePath }]);
return nil;
}
YDDiskRequest *request = [[YDDiskRequest alloc] initWithURL:url];
request.fileURL = filePath;
request.params = params;
request.skipReceivedData = skipReceivedData;
[self prepareRequest:request];
NSURL *requestURL = [request.URL copy];
request.callbackQueue = _callBackQueue;
request.didReceiveResponseBlock = ^(NSURLResponse *response, BOOL *accept) {
if(responseBlock){
responseBlock(response);
}
};
request.didGetPartialDataBlock = ^(UInt64 receivedDataLength, UInt64 expectedDataLength, NSData *data){
if(progressBlock){
progressBlock(receivedDataLength,expectedDataLength);
}
if(dataBlock){
dataBlock(receivedDataLength,expectedDataLength,data);
}
};
request.didFinishLoadingBlock = ^(NSData *receivedData) {
if(skipReceivedData){
[[self class] removeTemporaryFileAtPath:aFilePath];
}
NSDictionary *userInfo = @{@"URL": requestURL,
@"receivedDataLength": @(receivedData.length)};
[[NSNotificationCenter defaultCenter] postNotificationInMainQueueWithName: kYDSessionDidDownloadFileNotification
object: self
userInfo: userInfo];
completionBlock(nil);
};
request.didFailBlock = ^(NSError *error) {
if(skipReceivedData){
[[self class] removeTemporaryFileAtPath:aFilePath];
}
NSDictionary *userInfo = @{@"URL": requestURL};
[[NSNotificationCenter defaultCenter] postNotificationInMainQueueWithName: kYDSessionDidFailToDownloadFileNotification
object: self
userInfo: userInfo];
completionBlock([NSError errorWithDomain:error.domain code:error.code userInfo:userInfo]);
};
[request start];
NSDictionary *userInfo = @{@"URL": request.URL};
[[NSNotificationCenter defaultCenter] postNotificationInMainQueueWithName: kYDSessionDidStartDownloadFileNotification
object: self
userInfo: userInfo];
return (id)request;
}
最后但同样重要的是,我们应该检查我们的 Yandex.Disk SDK 是否有串行回调队列。使用串行队列非常重要,因为使用并行队列可能会导致播放问题。在我们的例子中,Yandex.Disk SDK 默认具有并行回调队列,因此让我们修复它。
- (instancetype)initWithDelegate:(id<YDSessionDelegate>)delegate
callBackQueue:(dispatch_queue_t)queue{
self = [super init];
if (self) {
_delegate = delegate;
_callBackQueue = queue;
}
return self;
}
YDDiskRequest *request = [[YDDiskRequest alloc] initWithURL:url];
request.fileURL = filePath;
request.params = params;
[self prepareRequest:request];
request.callbackQueue = _callBackQueue;
源代码
本教程提供的源代码可在 ** GitHub** 上找到。