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

Під час роботи над проектом по стрімінгу аудіо необхідно було додати підтримку нових сервісів, таких як Яндекс.Диск. Робота з аудіо в додатку реалізована через AVPlayer, який програє файли з url і підтримує стандартні схеми, такі як файл, 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;
}
NO return;
}

- (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> представник;

- (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){
NO return;
}

// This is the total data we have from startOffset to whatever has been so far downloaded
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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.