[Из песочницы] Стриминг аудио в 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 delegate;

— (instancetype)initWithResourceURL:(NSURL *)url session:(YDSession *)session;  — (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest;  — (void)removeRequest:(AVAssetResourceLoadingRequest *)loadingRequest;  — (void)cancel;

@end

@protocol LSFilePlayerResourceLoaderDelegate

@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 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  — (void)cancel; @end

— (id)fetchDirectoryContentsAtPath:(NSString *)path completion:(YDFetchDirectoryHandler)block;  — (id)fetchStatusForPath:(NSString *)path completion:(YDFetchStatusHandler)block; Второе — добавляем метод загрузки данных произвольного диапазона из файла на сервере.YDSession.h  — (id)partialContentForFileAtPath:(NSString *)srcRemotePath withParams:(NSDictionary *)params response:(YDDidReceiveResponseHandler)response data:(YDPartialDataHandler)data completion:(YDHandler)completion; YDSession.m  — (id)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)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; }

И третье, что нужно исправить — это изменить очередь колбеков с параллельной на последовательную, иначе блоки данных будут приходить не в том порядке, в котором мы запрашивали, и пользователь будет слышать рывки при проигрывании музыки.YDSession.m  — (instancetype)initWithDelegate:(id)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.

© Habrahabr.ru