Стриминг аудио в iOS на примере Яндекс.Диск

от автора

Во время работы над проектом по стримингу аудио необходимо было добавить поддержку новых сервисов, таких как Яндекс.Диск. Работа с аудио в приложении реализована через 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 вызовет методы делегата.

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]; } 

В начале метода загрузки мы проверяем что схема соответствует нашей. Далее берем LSFilePlayerResourceLoader из кеша или создаем новый и добавляем к нему запрос на загрузку ресурса.

Интерфейс нашего 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 и устанавливаем его в качестве возвращаемого значения в запросах.

YDSession.h

@protocol YDSessionRequest <NSObject> - (void)cancel; @end  - (id<YDSessionRequest>)fetchDirectoryContentsAtPath:(NSString *)path completion:(YDFetchDirectoryHandler)block; - (id<YDSessionRequest>)fetchStatusForPath:(NSString *)path completion:(YDFetchStatusHandler)block; 

Второе — добавляем метод загрузки данных произвольного диапазона из файла на сервере.

YDSession.h

- (id<YDSessionRequest>)partialContentForFileAtPath:(NSString *)srcRemotePath                                          withParams:(NSDictionary *)params                                            response:(YDDidReceiveResponseHandler)response                                                data:(YDPartialDataHandler)data                                          completion:(YDHandler)completion; 

YDSession.m

- (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; }  

И третье, что нужно исправить — это изменить очередь колбеков с параллельной на последовательную, иначе блоки данных будут приходить не в том порядке, в котором мы запрашивали, и пользователь будет слышать рывки при проигрывании музыки.

YDSession.m

- (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.

ссылка на оригинал статьи http://habrahabr.ru/post/249605/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *