Во время работы над проектом по стримингу аудио необходимо было добавить поддержку новых сервисов, таких как Яндекс.Диск. Работа с аудио в приложении реализована через AVPlayer, который проигрывает файлы по url и поддерживает стандартные схемы, такие как file, http, https. Все работает отлично для сервисов, в которых токен авторизации передается в url запроса, среди них DropBox, Box, Google Drive. Для таких сервисов, как Яндекс.Диск, токен авторизации передается в заголовке запроса и к нему AVPlayer доступ не предоставляет.
Поиск решения этой проблемы среди имеющегося API привели к использованию объекта resourceLoader в AVURLAsset. С его помощью мы предоставляем доступ к файлу, размещенному на удаленном ресурсе, для AVPlayer. Работает это по принципу локального HTTP прокси но с максимальным упрощением для использования.
Нужно понимать что AVPlayer использует resourceLoader в тех случаях когда сам не знает как загрузить файл. Поэтому мы создаем url c кастумной схемой и инициализируем плеер с этим url. AVPlayer не зная как загрузить ресурс передает управление resourceLoader`y.
AVAssetResourceLoader работает через AVAssetResourceLoaderDelegate для которого нужно реализовать два метода:
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
Первый вызывается когда AVAssetResourceLoader начинает загрузку ресурса и передает нам AVAssetResourceLoadingRequest. В этом случае мы запоминаем запрос и начинаем загрузку данных. Если запрос уже не актуальный то AVAssetResourceLoader вызывает второй метод и мы отменяем загрузку данных.
Для начала создадим AVPlayer, используя url с кастумной схемой, назначим AVAssetResourceLoaderDelegate и очередь на которой будут вызываться методы делегата:
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[NSURL URLWithString:@"customscheme://host/myfile.mp3"] 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];
Заниматься загрузкой ресурса будет некий класс LSFilePlayerResourceLoader. Он инициализируется с url загружаемого ресурса и сессией YDSession, которая и будет непосредственно загружать файл с сервера. Хранить объекты LSFilePlayerResourceLoader мы будем в NSDictionary, а ключем будет url ресурса.
При загрузке ресурса с неизвестного источника AVAssetResourceLoader вызовет методы делегата.
- (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];
}
В начале метода загрузки мы проверяем что схема соответствует нашей. Далее берем LSFilePlayerResourceLoader из кеша или создаем новый и добавляем к нему запрос на загрузку ресурса.
Интерфейс нашего 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;
@end
@protocol LSFilePlayerResourceLoaderDelegate <NSObject>
@optional
- (void)filePlayerResourceLoader:(LSFilePlayerResourceLoader *)resourceLoader didFailWithError:(NSError *)error;
- (void)filePlayerResourceLoader:(LSFilePlayerResourceLoader *)resourceLoader didLoadResource:(NSURL *)resourceURL;
@end
Он содержит методы для добавления/удаления запроса в очередь и метод для отмены всех запросов. LSFilePlayerResourceLoaderDelegate сообщит когда ресурс полностью загружен или возникла ошибка при загрузке.
При добавлении запроса в очередь, вызовом addRequest, мы запоминаем его в 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) получает информацию о размере и типе файла. Вторая (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<YDSessionRequest> 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);
}
}
После получения информации о файле на сервере мы создаем временный файл, в который будем записывать данные из сети и считывать их по мере надобности.
- (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];
}
Метод кеширования записывает данные в файл с нужным смещением.
- (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];
}
}
Метод чтения делает обратную операцию.
- (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;
}
Для оповещения запросов находящихся в очереди о новой порции данных мы сначала записываем информацию о контенте, а затем данные из кеша. Если все данные для запроса были записаны, то мы удаляем его из очереди.
- (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 Яндекс.Диска, для того чтобы мы могли загружать данные произвольного диапазона из файла на сервере. Изменений всего три.
Первое — нужно добавить для каждого запроса в YDSession возможность отмены. Для этого добавляем новый протокол 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;
- (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<YDSessionRequest>)request;
}
И третье, что нужно исправить — это изменить очередь колбеков с параллельной на последовательную, иначе блоки данных будут приходить не в том порядке, в котором мы запрашивали, и пользователь будет слышать рывки при проигрывании музыки.
- (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.
Автор: leshko