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

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

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

Итак, задача поставлена, пришло время её декомпозировать:

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

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

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

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

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

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

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

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

public interface IMediaDataSource

Разработчики 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 она, скорее всего, будет схожей. Возможно, кто-то уже сталкивался с этим и ему есть что сказать с позиции опыта. Замечания и предложения буду ждать в комментах. Обязательно пишите, интересно узнать, как вы справлялись с такого рода задачами!


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

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Как увидеть черную дыру?

Полученное на прошлой неделе усилиями восьми телескопов и тринадцати институтов изображение черной дыры оказалось размытым и менее интересным, чем рисунки художников, поэтому историческое событие породило множество иногда ехидных мемов (если вдруг не видели: пончик, око Саурона, котик, котики, маркетинг). Как ...

Как Мегафон спалился на мобильных подписках

Уже давно как не смешные анекдоты ходят истории о платных мобильных подписках на IoT устройствах. С Пикабу Всем понятно, что без действий сотовых операторов эти подписки не обходятся. Но операторы сотовой связи упорно утверждают, что это абоненты лохи: оригинал Но ...