Как кешировать данные AVURLAsset, загруженные AVPLayer'ом

    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

    FunCorp 189,87
    Разработка развлекательных сервисов
    Поделиться публикацией
    Похожие публикации
    Комментарии 11
    • 0

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

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

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


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

          • 0

            В хедере 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. 
      • 0
        Только передо мной встала подобная задача, и вот её решение. :) Спасибо, попробую воспользоваться, и надеюсь не забыть отписаться потом тут. У нас AVURLAsset создаётся из m3u, кажется, куда запрятан ts-поток.

        > Для этого достаточно поменять схему URL у AVURLAsset'а с HTTP(S) на любую другую. Не забудьте сохранить оригинальную, она вам ещё понадобится.
        • 0
          Я на плейлистах не тестировал. Если будут проблемы, открывайте issue на гитхабе, будем смотреть.
          • 0
            Отправил не дописав…

            > Для этого достаточно поменять схему URL у AVURLAsset'а с HTTP(S) на любую другую. Не забудьте сохранить оригинальную, она вам ещё понадобится.
            Я не понял почему в DVURLAsset в init не сохраняется схема внутри, а просто затирается. Хотя я пока ещё не разобрался как пользоваться этой библиотечкой, но кажется там чего-то всё же не хватает. Мне не совсем понятно где кеширование происходит? Зачем у делегата делегат? Подход ясен, но исходники покурю ещё. Ещё раз спасибо.
            • 0
              > Я не понял почему в DVURLAsset в init не сохраняется схема внутри, а просто затирается.
              В конструктор AVURLAsset передаётся изменённая схема для того, чтобы ассет вызывал методы делегата, а не загружал данные своими силами. Оригинальная схема передаётся в DVAssetLoaderDelegate строкой ниже, там и используется.

              > Мне не совсем понятно где кеширование происходит?
              У DVURLAsset есть делегат. В него приходят данные, которые можно сохранить самостоятельно.

              > Зачем у делегата делегат?
              Когда ассет не может загрузить данные самостоятельно, он просит это сделать делегата. Как только он (делегат) закончил загрузку, он оповещает об этом свой делегат. Получается делегат делегата :)
              Пожалуй, переименую его в observer.
              • 0
                > Оригинальная схема передаётся в DVAssetLoaderDelegate строкой ниже, там и используется.
                Вот честно, не вижу такой строки. Возможно просто туплю.

                > У DVURLAsset есть делегат. В него приходят данные, которые можно сохранить самостоятельно.
                Сохранить да, но переиспользовать?
                • 0
                  > Вот честно, не вижу такой строки. Возможно просто туплю.
                  код
                  NSURLComponents *components = [[NSURLComponents alloc] initWithURL:URL resolvingAgainstBaseURL:NO];
                  components.scheme = [DVAssetLoaderDelegate scheme];

                  if (self = [super initWithURL:[components URL] options:options]) {
                  DVAssetLoaderDelegate *resourceLoaderDelegate = [[DVAssetLoaderDelegate alloc] initWithURL:URL];

                  В нижней строке используется оригинальный URL, а не модифицированный.

                  > Сохранить да, но переиспользовать?
                  В следующий раз можно создавать AVURLAsset из локального URL.
                  • 0
                    Спасибо, теперь вижу. :)
                    А вот с переиспользованием перез ручное управление кешем (через делегат делегата) у меня не получится, из-за m3u как раз. :( Эта задачка у меня откладывается на недельку пока. Если заказчик решит к ней вернуться, то постараюсь не забыть поделиться кодом того что наваяю в итоге.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое