Вступление

Доброго времени суток всем, кто неравнодушен к мобильной разработке. Меня зовут Надежда и я являюсь старшим инженером Мобильной студии в компании Orion Innovation. Вот уже несколько лет моя жизнь неразрывно связана с разработкой под Андроид. В нашей компании много интересных, а также нестандартных проектов. За последний год мне удалось принять участие в разработке под Android TV, Automotive. И очень часто на моем пути попадается ExoPlayer. В обычных ситуациях разработчики используют его для проигрывания аудио/видео, однако не задумываются, сколько возможностей действительно он предоставляет (и сколько еще можно добавить)! Существуют разные варианты настроек проигрывания, буферизации, способы оптимизации контента разного рода. Они бывают особенно полезны при разработке IPTV - приложений, когда каждая секунда по загрузке видео на счету и может сыграть очень важную роль во впечатлении пользователя от вашего приложения. Сегодня мы начнем цикл статей, раскрывающий эту тему. Он будет полезен всем разработчикам, которые когда-либо использовали ExoPlayer. Предполагается, что читатель знаком с основными терминами, связанными с плеером и воспроизведением медиа. Первая часть будет посвящена работе с DASH манифестом и оптимизацией проигрывания VOD (Video On Demand) контента.

Содержание

Исходные данные. Протоколы. DASH

Оптимизация проигрывания VOD. Три рабочих способа

  • Переключение качества аудио/видео

  • Кэширование манифеста

  • Быстрые закладки

Исходные данные. Протоколы

В современной мобильной разработке для проигрывания стримов могут использоваться разные типы протоколов данных. Наиболее известные среди них – HLS и DASH. Мы будем говорить о DASH протоколе. Он имеет ряд преимуществ:

  1. Работает практически с любыми кодеками

  2. Поддерживает все DRM технологии

  3. C его помощью можно осуществлять перемотку, в том числе используя позиционирование

Рассмотрим структуру DASH манифеста.

Структура DASH манифеста
Структура DASH манифеста

Состоит он из MediaPresentation, который подразделяется на периоды. Например, на схеме мы видим два периода. Периоды разделяются на Adaptation Set. Один Adaptation Set это набор разных вариантов проигрывания одного типа медиа (только аудио или только видео). Внутри каждого – свой Representation. Это как раз является особенностью DASH манифеста. Блок нескольких Representation имеет набор медиа одного типа, которые будут подходить под определенное качество сети или иметь конкретные параметры – frame rate, width, height, тип кодека. Например, с помощью BandwithMeter выполняется анализ качества сети, и ExoPlayer будет выбирать определенный трек с видео или аудио, наиболее оптимальный для проигрывания в текущих условиях. Каждый Representation представляет собой информацию о cегментах. Их может быть несколько, однако размер сегмента (также его называют чанком) постоянный. В примере на схеме размер чанка равен 15 секунд. Эта информация нам понадобится далее для понимания оптимизации проигрывания видео.

Оптимизация проигрывания VOD (Video On Demand)

Давайте определимся, что важно для проигрывания видео. В частности, контента VOD – фильмы, сериалы и тп.

  1. Быстрый старт проигрывания

  2. Оптимальное качество с учетом сети

  3. Возможность сохранения текущей позиции не во вред производительности.  Здесь мы говорим о функционале букмарков

Способ 1. Переключение качества аудио видео

Начнём с первой части – возможности переключения качества видео для быстрого старта (по аудио всё аналогично). Для этого сначала заглянем внутрь ExoPlayer, чтобы понять, как работает адаптивный выбор качества в DASH плейлисте. Рассмотрим схему.

Имеем три основных сущности, которые многим, думаю, уже знакомы: TrackSelector, BandwidthMeter и MediaSource.Factory. MediaSource.Factory отвечает за хранение источника данных, то есть видео/аудио. В нашем случае это будет DashMediaSource. TrackSelector отвечает за выбор треков на основе прогноза от BandwithMeter. Таким образом, входной точкой для проигрывания видео является BandwidthMeter. С помощью предыдущих операций скачивания данных или с учетом текущей страны он определяет качество сети. Затем уже TrackSelector выбирает конкретный трек из AdaptationSet, который мы можем проиграть при текущих условиях. Говоря о реализации переключения качества видео, можно использовать надстройку как над TrackSelector, так и над BandwidthMeter. Покажу пример для первого случая.

Шаг 1. Создаем кастомный TrackSelector

class ExtendedTrackSelector(
    context: Context,
    ...
    ) : DefaultTrackSelector(ExtendedAdaptiveTrackSelection.Factory(...)) {
        ...
}

Шаг 2. Переопределим AdaptiveTrackSelection. Он как раз позволяет через метод getSelectedIndex выбирать именно тот трек, который будет проигрываться, в нашем случае с минимальным качеством. Переопределяя его, мы осуществим проход по массиву, который содержится в классе AdaptiveTrackSelection и содержит все треки. Таким образом мы берем тот трек, который имеет минимальный битрейт. Флажком useMinBitrate мы можем оперировать, стоит нам включить минимальный битрейт или нет. И тогда на старте, когда только пользователь нажимает Play, мы флажок включаем. Далее, как только мы получим эвент от ExoPlayer, что у нас первый фрейм показался, мы этот флажок можем выключить и дальше будем идти уже по стандартной реализации getSelectedIndex, где учитываются эстимации от BandwidthMeter.

class ExtendedAdaptiveTrackSelection private constructor(...)
                    : AdaptiveTrackSelection(...) {
    var useMinBitrate: Boolean = true
    override fun getSelectedIndex(): Int =
        if (useMinBitrate) {
            (0 until length).minByOrNull { getFormat(it).bitrate }
        } else { super.getSelectedIndex() }
    ...
}

Следующий способ чуть сложнее чем первый, однако тоже достаточно прост в исполнении любому программисту, который когда-либо работал с ExoPlayer. Этот способ называется Кэширование манифеста.

Способ 2. Кэширование манифеста

Первичная загрузка самого DASH манифеста может занимать определенное время, которое влияет на скорость старта проигрывания видео. Это может быть особенностью сервера или сети. В таких случаях можно манифест кэшировать заранее. Однако это возможно только при выполнении двух условий: если он не динамический (не Live-стрим) и если плейлист имеет тип DASH (у ExoPlayer только для таких плейлистов есть возможность проигрывать медиа с подготовленным манифестом). Рассмотрим следующий кейс.

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

Концепт загрузки манифеста
Концепт загрузки манифеста

Допустим, что когда пользователь находится на скрине с деталями мы уже можем запросить ссылку на манифест у сервера и параллельно можем создать плеер просто пустой, без каких-либо ссылок на медиа. Далее мы получаем эту ссылку, и теперь нам нужно каким-то образом (пока непонятно каким) проиграть видео. Значит нам нужен класс, который бы в зависимости от того, есть ли у нас в кэше манифест, выполнял действия по загрузке манифеста в кэш или проигрывал видео со скаченным уже манифестом. Давайте начнем писать код и создадим Loader. Он будет имплементировать интерфейсы по загрузке (ManifestCacheInterface) и предоставлению файла манифеста для MediaSource (MediaSourceFactorySet.ManifestCache).

class ManifestLoader() : MediaSourceFactorySet.ManifestCache, 
ManifestCacheInterface {
    private val manifests = SparseArray<DashManifest>()
    override fun download(uri: String): Int {
        val manifest: DashManifest
        ...
        manifest = DashUtil.loadManifest(dataSourceFactory.createDataSource(),
                                                                Uri.parse(uri))
        ...
        
        manifests.put(nextID, manifest)
        return nextID++
    }
}

Взглянем на код. Метод download позволяет загрузить манифест по имеющемуся Url, используя стандартный метод в DashUtil. Скаченный манифест сохраняем в массиве с присвоенным id. Далее нужно построить связь между Loader и MediaSourceFactory.

/**
 * It is created by analogy with
 * {@link com.google.android.exoplayer2.source.DefaultMediaSourceFactory}
 */
class MediaSourceFactorySet(val manifestCache: ManifestCache)
: MediaSourceFactory {
    interface ManifestCache {
        fun getCachedManifest(id: Int?): DashManifest?
    }
}

Унаследуем MediaSourceFactorySet от MediaSourceFactory. Пусть она в свою очередь определяет интерфейс для взятия кэшированного манифеста. А реализацию этого интерфейса сделаем в Loader.

class ManifestLoader() : MediaSourceFactorySet.ManifestCache,
                                        ManifestCacheInterface {    
    private val manifests = SparseArray<DashManifest>()
    override fun getCachedManifest(id: Int?): DashManifest? =
        if (id != null) manifests[id] else null
}

Теперь мы имеем возможность скачать манифест и предоставить его MediaSource. Остается только найти способ запуска проигрывания с готовым манифестом. Для этих целей у класса DashMediaSource есть специальный метод. Используем его в нашем MeidaSource.

override fun createMediaSource(mediaItem: MediaItem): MediaSource {
    val properties = mediaItem.playbackProperties!!
    val uri = properties.uri
    if (uri.scheme == CACHE) {
        val factory = mediaSourceFactories.get(C.TYPE_DASH) as? DashMediaSource.Factory
        val manifest = manifestCache.getCachedManifest(uri.host?.toIntOrNull())
        if (manifest != null && factory != null) {
            return factory.createMediaSource(manifest, mediaItem)
        }
    }
    …    
    return factory.createMediaSource(mediaItem)
}

Теперь рассмотрим полный флоу, который у нас получился.

Способ 3. Быстрые закладки

Следующий способ уже касается не стартового проигрывания, а повторного, когда пользователь возвращается к просмотру того или иного фильма спустя время.

На схеме представлен таймлайн в секундах. Отрезки времени – размер сегмента, равный 4 секундам. Здесь достаточно важно, в каком месте мы сохраняем текущую позицию по отношению к сегменту. Если делать это в то время, которое попадает примерно на середину сегмента, то пользователь будет дольше ждать, прежде чем вернется к просмотру видео. Это происходит потому, что придется загрузить б`ольшую часть сегмента для того, чтобы показать первый кадр. Таким образом, необходимо делать закладки именно по краям сегмента, потому что первый кадр всегда грузится быстрее, чем все остальные. А для пользователя это будет незаметно.  Сделать это можно, предоставив доступ к размеру сегмента и времени его начала. Код приведен ниже. Берем представление сегмента через VideoFormat

private fun getSegmentRepresentation(): Representation.MultiSegmentRepresentation? {
        currentVideoFormat?.also { videoFormat ->
            (currentManifest as? DashManifest)?
                        .getPeriod(currentPeriodIndex)?.also {
                for (adaptationSet in it.adaptationSets) {
                    if (adaptationSet.type == C.TRACK_TYPE_VIDEO) {
                        for (represenation in adaptationSet.representations) {
                            if (represenation.format.id == videoFormat.id) {
                                if (represenation 
                                    is Representation.MultiSegmentRepresentation) {
                                    return represenation
                                }
        ...
        return null
}

Даем доступ к размеру сегмента

fun getSegmentDuration(): Long {
        ...
        val segmentRepresentation = getSegmentRepresentation()
        ...
        val periodPositionUs = 
                currentTimeline.getPeriodPosition(..., C.msToUs(currentPosition))
       val num = 
                segmentRepresentation.getSegmentNum(periodPositionUs.second,...)
        return C.usToMs(segmentRepresentation.getDurationUs(num, ...))
    }
}

Даем доступ ко времени начала сегмента

fun getSegmentStartTime(): Long {
        ...
        val w = currentTimeline.getWindow(...)
        val segmentRepresentation = getSegmentRepresentation()
        ...
        val periodPositionUs = currentTimeline
                                .getPeriodPosition(..., C.msToUs(currentPosition))
       val num = segmentRepresentation.getSegmentNum(periodPositionUs.second, ...)
        return C.usToMs(segmentRepresentation.getTimeUs(num))
}

Заключение

Время старта проигрывания медиа контента очень важно для пользователя. Для достижения наилучшего результата есть простые и быстрые способы:

  • TrackSelector и выбор минимального качества на старте. Здесь мы рассмотрели именно видео дорожку, однако всё то же самое можно при желании сделать и с аудио. Такой способ вполне рабочий и можно даже варьировать качество от минимального до среднего.

  • Кэширование манифеста. Способ идеально подходит для VOD контента и прост в реализации. Однако нужно его доработать под вашу ситуацию, если у вас есть реклама. А именно, определить время жизни манифеста.

  • Закладки только по началу сегмента. Понимание того, как устроен DASH манифест, как происходит загрузка контента, какие внутренние структуры в этом задействованы - ключ к решению проблем оптимизации.

В следующих выпусках цикла статей о ExoPlayer мы рассмотрим оптимизацию Live стримов, работу с проблемными кодеками а также функцию быстрой перемотки.

А каков был ваш опыт использования ExoPlayer?  Буду рада вашим комментариям!

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