...

пятница, 11 мая 2018 г.

«Стартуем! Я сказала: стартуем!», или как мы реализовывали работу с faststart-видео под Android

Большинство владельцев смартфонов, планшетов и других гаджетов ежедневно потребляют огромное количество цифровой информации, включая медиа: изображения, музыку и, конечно же, видео. На последнем остановимся поподробнее. Очень важно не заставлять пользователей ждать контент, особенно когда приложением ежедневно пользуются миллионы людей. В приложении iFunny, над которым мы работаем, очень много видеоконтента, и мы подумали, что скачивать видео целиком — долго, неинтересно и не масштабируемо. А что, если в ближайшем будущем потребуется загрузить видео длительностью не в 30-60 секунд, а в 5-10 минут? Заставлять пользователя ждать полминуты, пока видео скачается? А если соединение плохое? Так и интерес к приложению потерять недолго. Поэтому мы решили сделать faststart-видео. Подробности под катом.

Итак, задача поставлена, пришло время её декомпозировать:
  • изучить, структуру и особенности faststart-видео;
  • выяснить, поддерживает ли наш плеер видео такого типа;
  • реализовать faststart-воспроизведение;
  • обработать ситуации, когда скачивание останавливается во время воспроизведения.

Для начала давайте разберёмся, что вообще из себя представляет faststart-видео.

Сразу оговорюсь, что речь пойдёт о MP4-формате (GIF и WebM также поддерживаются), так как в основном видео в iFunny имеют именно этот формат. Думаю, я не открою вам Америку, если скажу, что видеофайл состоит из фрагментов и его можно декодировать частями. Но как его декодировать частями, если метаданные (Moov-атомы) видео находятся в разных частях файла? Тут-то и вступает в игру флаг faststart. С помощью него все Moov-атомы при конвертации переносятся в начало файла. Чтобы выполнить такого рода операцию с помощью FFmpeg, нужно добавить faststart в movflags, например:

ffmpeg -i <input> -c:v libx264 -c:a aac -movflags +faststart output.mp4

И после нехитрых манипуляций у нас будет готово видео для тестирования.

Итак, с faststart разобрались, пора бы проверить, есть ли поддержка воспроизведения видео такого типа у нашего плеера.

Мы используем Ijkplayer от bilibili, так как на момент разработки первых версий приложения он предоставлял функционал намного шире, чем тот же ExoPlayer.

В документации Ijkplayer ничего не написано про faststart-видео. Но у нас уже есть реализованная логика воспроизведения файлов. Тогда, посмотрев на метод setDataSource(Context context, Uri uri), которому в качестве параметра мы передаём URI на видеофайл, возник вопрос: а какие ещё у него есть реализации? Можно ли просто передать URL на видео, а плеер сам разберётся? Можно. Но у такого способа есть ограничения, которые нам категорически не подходят: отсутствие возможности перематывать и зацикливать видео. После изучения реализаций метода setDataSource нашёлся ответ с ранее неисследованным нами параметром setDataSource(IMediaDataSource mediaDataSource). Всё стало понятно после того, как мы заглянули внутрь интерфейса:

public interface IMediaDataSource {
  
   int readAt(long position, byte[] buffer, int offset, int size) throws IOException;

   long getSize() throws IOException;

   void close() throws IOException;
  
}

Разработчики Ijkplayer сделали интерфейс, с помощью которого мы можем «скормить» плееру видео частями в виде массива байт, предварительно указав его итоговый размер.
Из этого следует, что мы можем рендерить видео по частям, а это как раз то, что нам нужно. Теперь перейдём от слов к делу.

Разобьём нашу подзадачу «Реализовать faststart-воспроизведение» ещё на 2 части:

  • реализовать скачивание видео по частям;
  • реализовать чтение видео по частям и возможность чтения из любой части файла.

Нам нужно одновременно писать в файл при скачивании и читать из него во время воспроизведения, а также менять указатель в файле, чтобы воспроизводить видео заново. Такую возможность предоставляет класс RandomAccessFile — значит, будем использовать его. Для скачивания используем библиотеку OkHttp. Может ли возникнуть ситуация, когда у нас есть наполовину скачанное видео? Конечно, может! Значит, нам нужно где-то хранить информацию о размере скачанной части, а также знать итоговый размер видео. Поэтому сделаем отдельную модель для этих целей и назовем её MediaCache.
data class MediaCache(val cacheFile: File) {

   var finalSize: Long = 0
   var downloadedSize: Long = 0

   val isCachePartiallyDownloaded: Boolean
       get() =  downloadedSize != 0L && finalSize > downloadedSize

}

В этой статье вы не найдёте, как управлять этим кешем, так как для каждого это будет, скорее всего, индивидуальной особенностью реализации. Просто скажу, что мы используем свой LRU-кеш. Подробнее об LRU можно почитать здесь.
Вернёмся к скачиванию. Мы обозначили основные моменты. Посмотрим, как это выглядит на деле:
fun download(url: String) {
   val requestBuilder = Request.Builder().url(url)
   if (mediaCache.isCachePartiallyDownloaded) {
       requestBuilder.addHeader("Range", "bytes=${mediaCache.downloadedSize}-")
   }
   val response = mediaHttpClient.newCall(requestBuilder.build()).execute()

   //Если всё прошло успешно, то код ответа будет 200 или 206 при запросе без Range и с Range соответственно.
   val responseCode = response.code()
   if (responseCode == 200 || responseCode == 206) {
       handleResponse(response.body())
   } else {
       //Обработка ошибок.
   }
}

override fun handleResponse(responseBody: ResponseBody?) {
   responseBody?.let { body ->
       if (mediaCache.finalSize == 0L) {
           mediaCache.finalSize = body.contentLength()
       }
       val file = RandomAccessFile(mediaCache.cacheFile, "rw")
       file.use {
           it.seek(mediaCache.downloadedSize)
           writeResponseInFile(body, it)
       }
   }
}

private fun writeResponseInFile(responseBody: ResponseBody, file: RandomAccessFile) {
   responseBody.use {
       val inputStream = BufferedInputStream(it.byteStream())
       inputStream.use { stream ->
           var currentDownloadedSize: Long = mediaCache.downloadedSize
           val finalSize = mediaCache.finalSize
           val data = ByteArray(BYTE_ARRAY_SIZE)
           var count: Int
           while (currentDownloadedSize != finalSize) {
               count = stream.read(data)
               if (count != -1) {
                   file.write(data, 0, count)
                   currentDownloadedSize += count.toLong()
                   mediaCache.downloadedSize = currentDownloadedSize
               }
           }
       }
   }
}

Очень похоже на обычное скачивание файла, не правда ли? Вот только параллельно плеер сразу же будет читать из этого файла. На этом моменте мы переходим ко второй части нашей подзадачи: заставим плеер воспроизводить то, что у нас есть. Как оказалось, это тривиально: нужно просто отдавать плееру байты по позиции, которую он запрашивает. На деле вышло всего два десятка строк:
class CustomMediaDataSource(val mediaCache: MediaCache): IMediaDataSource {

   private val cacheFile: RandomAccessFile = RandomAccessFile(mediaCache.cacheFile, "r")

   @Throws(IOException::class)
   override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
       if (cacheFile.filePointer != position) {
           cacheFile.seek(position)
       }
       var count = cacheFile.read(buffer, offset, size)
       /**
        * Если файл вернул -1, то это ещё не значит, что видео закончилось: возможно, мы его ещё не докачали.
        * Поэтому проверяем дополнительно, действительно ли по этой позиции конец видео.
        * Если не конец — возвращаем 0, тогда плеер будет ждать догрузки файла.
        */
       if (count == -1 && position != mediaCache.finalSize) count = 0
       return count
   }

   override fun getSize(): Long {
       return mediaCache.finalSize
   }

   override fun close() {
       try {
           cacheFile.close()
       } catch (e: Throwable) {
           Assert.fail(e.message)
       }
   }
}


Отлично! Всё заработало, осталось лишь доделать пару мелочей, а именно — изучить колбэки от плеера и реализовать показ прогресс-бара во время буферизации/дозагрузки видео. Для этого заглянем в интерфейс IMediaPlayer, в котором кроме основных методов есть множество констант.
public interface IMediaPlayer {
   /*
    * Do not change these values without updating their counterparts in native
    */
   int MEDIA_INFO_UNKNOWN = 1;
   int MEDIA_INFO_STARTED_AS_NEXT = 2;
   int MEDIA_INFO_VIDEO_RENDERING_START = 3;
   int MEDIA_INFO_VIDEO_TRACK_LAGGING = 700;
   int MEDIA_INFO_BUFFERING_START = 701;
   int MEDIA_INFO_BUFFERING_END = 702;
   int MEDIA_INFO_NETWORK_BANDWIDTH = 703;
   int MEDIA_INFO_BAD_INTERLEAVING = 800;
   int MEDIA_INFO_NOT_SEEKABLE = 801;
   int MEDIA_INFO_METADATA_UPDATE = 802;
   int MEDIA_INFO_TIMED_TEXT_ERROR = 900;
   int MEDIA_INFO_UNSUPPORTED_SUBTITLE = 901;
   int MEDIA_INFO_SUBTITLE_TIMED_OUT = 902;

   int MEDIA_INFO_VIDEO_ROTATION_CHANGED = 10001;
   int MEDIA_INFO_AUDIO_RENDERING_START  = 10002;
   int MEDIA_INFO_AUDIO_DECODED_START    = 10003;
   int MEDIA_INFO_VIDEO_DECODED_START    = 10004;
   int MEDIA_INFO_OPEN_INPUT             = 10005;
   int MEDIA_INFO_FIND_STREAM_INFO       = 10006;
   int MEDIA_INFO_COMPONENT_OPEN         = 10007;
   int MEDIA_INFO_MEDIA_ACCURATE_SEEK_COMPLETE = 10100;

   int MEDIA_ERROR_UNKNOWN = 1;
   int MEDIA_ERROR_SERVER_DIED = 100;
   int MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK = 200;
   int MEDIA_ERROR_IO = -1004;
   int MEDIA_ERROR_MALFORMED = -1007;
   int MEDIA_ERROR_UNSUPPORTED = -1010;
   int MEDIA_ERROR_TIMED_OUT = -110;

   void setOnInfoListener(OnInfoListener listener);

   interface OnInfoListener {
       boolean onInfo(IMediaPlayer mp, int what, int extra);
   }

   void setDataSource(IMediaDataSource mediaDataSource);
   /*
    * Остальные методы были удалены из листинга, дабы не отвлекать.
    */
}

Из всех этих констант для реализации показа/скрытия прогресс-бара нам необходимы только 3:
int MEDIA_INFO_VIDEO_RENDERING_START = 3;
int MEDIA_INFO_BUFFERING_START = 701;
int MEDIA_INFO_BUFFERING_END = 702;

Нужно понимать, что плееру можно задать вручную размер буферизации видео (у нас он задаётся в фиче с сервера), поэтому может возникнуть ситуация, когда для рендеринга видео требуется меньше байт, чем минимальный буфер. То есть нам нужно учитывать, что рендеринг может начаться раньше, чем вызовется колбэк с кодом окончания буферизации. А за эти колбэки отвечает интерфейс OnInfoListener, который нам нужно реализовать:
class InfoListener: IMediaPlayer.OnInfoListener {

   override fun onInfo(mp: IMediaPlayer, what: Int, extra: Int): Boolean {
       when (what) {
           IMediaPlayer.MEDIA_INFO_BUFFERING_START -> onBufferingStart() //Здесь показываем прогрессбар
           IMediaPlayer.MEDIA_INFO_BUFFERING_END -> onBufferingEnd() //Здесь скрываем прогрессбар
           IMediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START -> onBufferingEnd()
       }
       return true
   }

}

Вот и всё, наша реализация готова. Посмотрим, что получилось:

Сравнение при средней скорости соединения (Throttling 2 Mbps ADSL, Faststart справа).

image

При таком сценарии воспроизведение началось у 5 видео при обычной загрузке против 9 видео faststart.

Сравнение при слабом соединении (Throttling 256 Kbps ISDN/DSL, Faststart справа).

image

В этом же случае faststart сильно отличается от обычного воспроизведения: 6 секунд до начала воспроизведения против 27!

Итак, задача выполнена! Результат говорит сам за себя. Благодаря такому подходу мы обеспечили не только почти мгновенный показ видео, но и возможность прерывать загрузку и кешировать её частями, что немаловажно. Это дало нам экономию ресурсов при возврате на неполностью скачанное видео, так как догрузка всегда быстрее, чем скачивание файла заново. А освободившиеся ресурсы мы потратили на дополнительный prefetch соседних элементов, если они ещё не были загружены, чтобы обеспечить пользователю поставку контента максимально быстро и без раздражающих прогресс-баров.

Этот метод реализации не позиционируется как серебряная пуля, тем более здесь мы рассмотрели faststart только на Ijkplayer, хотя на ExoPlayer она, скорее всего, будет схожей. Надеюсь, эта статья сподвигнет вас начать улучшать свой продукт (даже если он идеален), ведь все мы хотим пользоваться максимально быстрыми и удобными приложениями. Замечания и предложения буду ждать в комментах. Возможно, кто-то уже сталкивался с этим и ему есть что сказать с позиции опыта. Обязательно пишите, интересно узнать, как вы справлялись с такого рода задачами!

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий