iFunny app image


Привет, Хабр. Меня зовут Влад. Я работаю 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

Комментарии (4)


  1. storoj
    19.01.2018 20:21

    А это не мог быть такой NSURLProtocol, чтобы для всего остального приложения это кеширование осталось прозрачным?


    1. vdugnist Автор
      19.01.2018 20:42

      Я не думаю, что AVPlayer грузит данные через [NSURLSession defaultSession]. А в этом случае запросы в протокол приходить не будут, пока его не добавили в protocolClasses у сессии.


      1. omaksim
        19.01.2018 21:10

        Зарегистрированные стандартным образом кастомные протоколы вроде как раз в любых конфигурациях NSURLSession работают. protocolClasses как раз для локального расширения отдельной конфигурации.


        Но AVPlayer вроде и правда к NSURLProtocol не привязать, хотя бы потому что логика загрузки там нужна более специфичная и сложная, что и видно в API.


        1. vdugnist Автор
          19.01.2018 23:34

          В хедере NSURLSession:


          You should not use +[NSURLProtocol registerClass:], as that
             method will register your class with the default session rather
             than with an instance of NSURLSession.