Привет, Хабр. Меня зовут Влад. Я работаю iOS разработчиком в FunCorp. Мы делаем приложения в сфере развлечений. Возможно, вы слышали о нашем флагмане iFunny и популярном в СНГ приложении АйДаПрикол. В этой статье я расскажу о том, как получить данные видео, загруженные плеером, для дальнейшей работы с ними.
tl;dr
Если вам нужно только решение, посмотрите вот эту библиотеку.
Проблема
В нашем приложении iFunny лента контента состоит в основном из картинок и видео. Для кеширования картинок мы используем SDWebImage. Для видео раньше мы загружали файл полностью и только после этого начинали воспроизведение. Это работало для коротких видео. На длинных же проходило слишком много времени с момента открытия экрана (старта загрузки) до начала воспроизведения даже на wifi.
Решения
Первой идеей было хранить объекты AVAsset на уровне модели. Этот подход работает в пределах сессии (AVPlayer не будет загружать один и тот же файл несколько раз), но не будет работать между запусками приложения.
После этого я попробовал перевести AVAsset в NSData с помощью AVAssetExportSession. Экспортная сессия хорошо работала для AVAsset, созданных из локальных файлов, но для удалённых ассетов я всегда получал ошибку:
Error Domain=AVFoundationErrorDomain Code=-11800 “The operation could not be completed” UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16974), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x60000025a940 {Error Domain=NSOSStatusErrorDomain Code=-16974 “(null)”}}
Третьим решением было использования поля resourceLoader у AVURLAsset'а. Этот подход сработал, но я столкнулся с некоторыми проблемами во время его реализации.
Реализация
Согласно документации Apple:
An AVAssetResourceLoader mediates requests to load resources required by an AVURLAsset by asking a delegate object that you provide for assistance. When a resource is required that cannot be loaded by the AVURLAsset itself, the resource loader makes a request of its delegate to load it and proceeds according to the delegate’s response.
Для начала вам нужно сделать так, чтобы AVURLAsset не мог загрузить данные самостоятельно и вызывал методы делегата у resourceLoader для каждого запроса. Для этого достаточно поменять схему URL у AVURLAsset'а с HTTP(S) на любую другую. Не забудьте сохранить оригинальную, она вам ещё понадобится.
NSURLComponents *components = [[NSURLComponents alloc] initWithURL:URL resolvingAgainstBaseURL:NO];
components.scheme = @“customscheme”;
AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:[components URL] options:options];
Когда resource loader не может загрузить ресурс самостоятельно, он вызывает метод делегата:
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
Возвращая YES из этого метода, вы говорите загрузчику ресурсов, что теперь вы ответственны за этот запрос. Ответ NO приведёт к ошибке внутри AVURLAsset, так как ни сам загрузчик ресурсов, ни делегат не могут выполнить данный запрос.
С этого момента начинается работа с объектом AVAssetResourceLoadingRequest, который был передан в метод делегата аргументом. Вы можете загружать данные синхронно или асинхронно (не забудьте сохранить объект loadingRequest где-нибудь, если вы будете грузить асинхронно). После окончания загрузки вам нужно вызывать finishLoading или finishLoadingWithError: в зависимости от результата.
Есть два типа запросов на загрузку AVAssetResourceLoadingRequest: запрос данных и запрос информации о контенте. Определить тип можно, проверив поля:
@property (nonatomic, readonly, nullable) AVAssetResourceLoadingContentInformationRequest *contentInformationRequest;
@property (nonatomic, readonly, nullable) AVAssetResourceLoadingDataRequest *dataRequest;
В ответе на запрос на загрузку вам нужно вернуть тип контента (UTI), его длину и флаг "поддерживаются ли range-запросы". Для этого я использовал HTTP HEAD. Когда вы получили ответ, данные нужно заполнить в поля объекта contentInformationRequest и вызвать метод finishLoading.
В ответ на запрос данных вы должны вернуть данные по URL, отступу и длине, лежащих в объекте AVAssetResourceLoadingDataRequest. Если вы захотите написать свою реализацию запроса, внимательно читайте документацию AVAssetResourceLoadingDataRequest, там есть не совсем очевидные моменты.
Я написал реализацию с HTTP GET запросами и range-хедером. Во время написания запроса данных я заметил странное поведение. Запросы и ответы в NSURLSessionDataTas k могли отличаться. Ветка форума на developer.apple подтверждает, что это баг внутри NSURLCache. Range хедер игнорируется и вам может прийти не тот кусок данных, который вы запрашивали. У меня получилось воспроизвести это только на iOS <= 10.
Загруженные данные вы должны положить в dataRequest с помощью метода respondWithData:. Эти же данные вы можете сохранить к себе в кеш. В следующий раз при открытии этого файла можно брать данные сразу из кеша.
Таким образом, мы получили возможность запускать видео сразу после начала загрузки и кешировать его, не делая лишние запросы.
Реализацию всех методов делегата вы можете найти здесь. Библиотека для кеширования AVURLAsset'а лежит здесь, в readme описаны способы работы с ней.
Если у вас остались вопросы, добро пожаловать в комменты или в личку. vdugnist
storoj
А это не мог быть такой NSURLProtocol, чтобы для всего остального приложения это кеширование осталось прозрачным?
vdugnist Автор
Я не думаю, что AVPlayer грузит данные через [NSURLSession defaultSession]. А в этом случае запросы в протокол приходить не будут, пока его не добавили в protocolClasses у сессии.
omaksim
Зарегистрированные стандартным образом кастомные протоколы вроде как раз в любых конфигурациях
NSURLSession
работают.protocolClasses
как раз для локального расширения отдельной конфигурации.Но
AVPlayer
вроде и правда кNSURLProtocol
не привязать, хотя бы потому что логика загрузки там нужна более специфичная и сложная, что и видно в API.vdugnist Автор
В хедере NSURLSession: