Формат коротких видео завоевал мир несколько лет назад, с бумом популярности TikTok. И до сих пор остаётся популярным. Множество медиаплощадок пошли по пути китайской соцсети и начали запускать свои Shorts, Reals и т.п. Форматы могут называться по-разному, но суть у всех одна — это лента из коротких видео, которые автоматически воспроизводятся при прокрутке. У этого даже есть свой технический термин — плавная прокрутка (Smooth scrolling).

Мы продолжаем наш цикл статей про разработку стриминговых приложений для Android. Я уже рассказывал вам, как сделать сервисы для воспроизведения готовых видео, мобильного онлайн-стриминга, как реализовать приостановку трансляций в мобильном стриминге. А сегодня расскажу, как сделать «убийцу тиктока» — приложение с плавной прокруткой видео. Разработать его несложно, но есть свои нюансы, которые нужно учесть.

В чём сложность создания приложения с плавной прокруткой?

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

Если для раздачи видео мы используем стриминговую платформу EdgeЦентр, видеопоток устройствам-клиентам отдаётся по протоколу HLS с адаптивным битрейтом — это позволяет доставлять контент максимально быстро даже в условиях плохого интернета, так как за счёт адаптивного битрейта качество видео подстраивается под скорость соединения.

Именно адаптивный битрейт и осложняет задачу с прекешированием, ведь у нас есть несколько дорожек с разными качествами. Кешировать начало каждой — это непомерно большой размер кеша и лишня нагрузка на сеть. А в условиях мобильного интернета сетевое соединение редко бывает стабильным.

Значит, нужно кешировать только дорожку с качеством видео, соответствующим пропускной способности сети. А ещё нужно добиться того, чтобы плеер воспроизводил именно кешированные дорожки. Так мы сможем реализовать прекеширование, сохранив при этом все преимущества HLS-протокола.

Давайте разбираться, как это сделать.

Реализуем пролистывание видео

Начнём с простого. Чтобы видео плавно пролистывалось, как в TikTok, нам понадобится ViewPager2 из комплекта библиотек JetPack. Обычно он по умолчанию доступен в проекте, поэтому подключать отдельно его не нужно

1. Добавляем ViewPager2 к Layout Activity или Fragment-а

<androidx.viewpager2.widget.ViewPager2
 android:id="@+id/viewPager"
 android:layout_width="0dp"
 android:layout_height="0dp"
 android:orientation="vertical" />

2. Реализуем адаптер для ViewPager2

В качестве адаптера берём FragmentStateAdapter. Он позволит использовать фрагменты в качестве страниц ViewPager2.

class ViewingPagerAdapter(parentFragment: Fragment) :
FragmentStateAdapter(parentFragment) {
 private val videoItems: MutableList<UiVideoItem> = ArrayList()
 fun setData(newVideoItems: List<UiVideoItem>) {
 val diffCallback = VideosDiffCallback(videoItems, newVideoItems)
 val diffResult = DiffUtil.calculateDiff(diffCallback)
 videoItems.clear()
 videoItems.addAll(newVideoItems)
 diffResult.dispatchUpdatesTo(this)
 }
 override fun getItemCount(): Int {
 return videoItems.size
 }
 override fun createFragment(position: Int): Fragment {
 return VodFragment.newInstance(videoItem = videoItems[position], position)
 }
}

В методе createFragment() создаётся VodFragment, который и будет страницей ViewPager2. В нём будет вёрстка страницы и PlayerView для отображения воспроизводимого видео.

3. Реализуем DiffUtil.Callback() для адаптера

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

class VideosDiffCallback(
 private val oldList: List<UiVideoItem>,
 private val newList: List<UiVideoItem>
) : DiffUtil.Callback() {
 override fun getOldListSize(): Int = oldList.size
 override fun getNewListSize(): Int = newList.size
 override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int):
Boolean {
 val oldVideoItem = oldList[oldItemPosition]
 val newVideoItem = newList[newItemPosition]
 return oldVideoItem.id == newVideoItem.id
 }
 override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int):
Boolean {
val oldVideoItem = oldList[oldItemPosition]
 val newVideoItem = newList[newItemPosition]
 return oldVideoItem == newVideoItem
 }
}

4. Настраиваем ViewPager2

class ViewingFragment : Fragment(R.layout.fragment_viewing) {
 private lateinit var binding: FragmentViewingBinding
 private val viewingPagerAdapter by lazy { ViewingPagerAdapter(this) }
 private val _scrollDirection =
 MutableLiveData<ScrollDirection>(ScrollDirection.Forward(0))
 val scrollDirection: LiveData<ScrollDirection> = _scrollDirection
 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
 super.onViewCreated(view, savedInstanceState)
 binding = FragmentViewingBinding.bind(view)
 with(binding.viewPager) {
 adapter = viewingPagerAdapter
 registerOnPageChangeCallback(pageChangeCallback)
 offscreenPageLimit = 2
 }
 }
 override fun onDestroyView() {
 binding.viewPager.unregisterOnPageChangeCallback(pageChangeCallback)
 super.onDestroyView()
 }
 private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
 override fun onPageSelected(position: Int) {
 super.onPageSelected(position)
 val offset = position - (_scrollDirection.value?.displayedPosition ?:
0)
 if (offset > 0) {
 _scrollDirection.value = ScrollDirection.Forward(position)
 } else if (offset < 0) {
 _scrollDirection.value = ScrollDirection.Back(position)
 }
 }
 }
}

Обратите внимание, что мы выставляем параметр viewPager.offscreenPageLimit = 2. Это значит, что помимо отображаемого фрагмента будут созданы ещё 2 следующих. Также в viewPager мы зарегистрировали pageChangeCallback. В нём мы определяем направление скролла пользователя.

Все эти приготовления нужны, чтобы правильно прекешировать видео. Например, если пользователь скроллит вперёд, нужно, чтобы 2 следующих фрагмента начали предварительно кешироваться. А если пользователь скроллит  назад, то прекешировать нужно предыдущие видео (если они стёрлись из кеша).

5. Настраиваем прекеширование и воспроизведение роликов

Мы будем использовать ExoPlayer. В нём можно довольно легко реализовать прекеширование. Чтобы использовать его в проекте, нужно указать зависимости в файле buid.gradle (на уровне приложения).

implementation("com.google.android.exoplayer:exoplayer:2.18.1")

Для начала нужно создать кеш для загрузки видео. Чтобы хранить видео, воспользуемся SimpleCache из библиотеки ExoPlayer. Это реализация Cache, которая поддерживает представление в памяти.

private val simpleCache by lazy {
 return@lazy simpleCacheInstance ?: run {
 val exoPlayerCacheDir = File("${app.cacheDir.absolutePath}/exo")
 val cacheEvictor = LeastRecentlyUsedCacheEvictor(CACHE_SIZE_IN_BYTES)
 val dataBaseProvider = StandaloneDatabaseProvider(app)
 SimpleCache(exoPlayerCacheDir, cacheEvictor, dataBaseProvider).also {
 simpleCacheInstance = it
 }
 }
}

При создании SimpleCache нам нужно указать директорию, где он будет храниться. При этом она должна быть только для ExoPlayer, так как любой не распознанный файл в этой директории плеер может удалить. Обратите внимание, что в определённой директории одновременно может быть только один экземпляр Cache, а другой можно создать только после освобождения кеша через вызов метода cache.release(). Так происходит, потому что Cache после своего создания блокирует директорию, в которой находится.

Кроме этого, нам понадобятся CacheEvictor и DatabaseProvider. Первый отвечает за стратегию очистки кеша. Мы будем использовать реализацию LeastRecentlyUsedCackeEvictor. Он будет следить за количеством данных, и при достижении лимита удалять из кеша файлы, которые давно не использовались.

DatabaseProvider предназначен для хранения метаданных кешированного видео. Здесь используем реализацию StandaloneDatabaseProvider. Она подойдёт, если в нашем приложении ещё не используются базы данных, или если мы хотим хранить метаданные кешированного видео изолированными в БД приложения.

Дальше, когда плеер дойдёт до конца кешированного фрагмента, ему нужно будет получить оставшуюся часть из сети. Для этого определим upstreamDataSourceFactory:

private val simpleCache by lazy {
 return@lazy simpleCacheInstance ?: run {
 val exoPlayerCacheDir = File("${app.cacheDir.absolutePath}/exo")
 val cacheEvictor = LeastRecentlyUsedCacheEvictor(CACHE_SIZE_IN_BYTES)
 val dataBaseProvider = StandaloneDatabaseProvider(app)
 SimpleCache(exoPlayerCacheDir, cacheEvictor, dataBaseProvider).also {
 simpleCacheInstance = it
 }
 }
}

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

val cacheDataSourceFactory = CacheDataSource.Factory()
 .setCache(simpleCache)
 .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
 .setUpstreamDataSourceFactory(upstreamDataSourceFactory)

Обратите внимание, при его создании мы как раз передаём в него уже созданные simpleCache и upstreamDataSourceFactory.

Теперь переходим непосредственно к прекешированию. В ExoPlayer предварительно кешировать HLS-видеопоток позволяет HlsDownloader. Чтобы его использовать, нам понадобится правильно сконфигурированный mediaItem (как его получить, рассмотрим позже, там есть некоторые особенности) и cacheDataSourceFactory, который мы получили на предыдущем шаге.

Для старта прекеширования мы вызываем метод hlsDownloader.download(), а отменяем его методом hlsDownloader.cancel(). Отменить прекеширование нужно будет, когда в кеш загрузится 30% видео. Или когда пользователь уже доскролил до ролика — здесь ExoPlayer во время воспроизведения сам загрузит оставшуюся часть, когда кешированный фрагмент закончится. Для асинхронной загрузки в фоновом режиме используем Kotlin корутины.

fun startPreCacheVideo(mediaItem: MediaItem) {
 hlsDownloader = HlsDownloader(
 mediaItem,
 cacheDataSourceFactory
 )
 viewModelScope.launch(Dispatchers.IO) {
 preCacheVideo()
 }
}
private suspend fun preCacheVideo() = withContext(Dispatchers.IO) {
 runCatching {
 hlsDownloader?.download { _, _, percentDownloaded->
   if (percentDownloaded >= PRE_CACHE_PERCENT) {
 hlsDownloader?.cancel()
 }
 }
 }.onFailure {
 if (it is InterruptedException || it isCancellationException)
return@onFailure
 it.printStackTrace()
 }
 }
companion object {
 const val PRE_CACHE_PERCENT: Float = 30f
}

И наконец воспроизводим прекешированное видео:

private fun preparePlayer(mediaItem: MediaItem) {
 val hlsMediaSource = HlsMediaSource.Factory(cacheDataSourceFactory)
 .setAllowChunklessPreparation(true)
 .createMediaSource(mediaItem)
 exoPlayer?.let {
 it.setMediaSource(hlsMediaSource)
 it.prepare()
 it.playWhenReady = true
 }
}

Сейчас поясню, что именно мы здесь сделали. Чтобы воспроизвести видео, нужно создать HlsMediaSource. В него мы передаём:

  • cacheDataSourceFactory, чтобы ExoPlayer переключился на сеть, когда кешированный фрагмент закончится

  • mediaItem — тот же, что мы передавали в HlsDownloader, иначе плеер может попытаться адаптироваться к дорожке, которая не была загружена, и начать загружать её из сети.

setAllowChunklessPreparation() мы вызываем, чтобы включить подготовку без фрагментов (если это возможно), когда основной плейлист (манифест) предоставляет достаточную информацию, чтобы сократить время подготовки. Дальше передаём hlsMediaSoucre плееру и воспроизводим видео сразу по готовности.

Реализуем адаптивное качество видео

Итак, напомню: чтобы ролики грузились максимально быстро, и при этом мы не перегружали сеть и не раздували кеш, нам нужно прекешировать только одну дорожку из прейлиста, и её качество должно соответствовать пропускной способности сети.

Какая именно дорожка будет загружаться и проигрываться, определяется при создании mediaItem. Это тот самый компонент, который выше мы передавали HlsDownloader и HlsMediaSource. Как и обещал, объясняю, как его получить.

В первую очередь нам нужно получить информацию, какие дорожки у нас есть в плейлисте. Это нужно сделать ещё до того, как мы начнём реализовывать прекеширование. И здесь нам понадобится DownloadHelper:

val hlsDownloadHelper = run {
 val mediaItem = MediaItem.Builder()
 .setUri(videoData?.uri)
 .build()
 DownloadHelper.forMediaItem(
 app,
 mediaItem,
 DefaultRenderersFactory(app),
 exoPlayerUtils.upstreamDataSourceFactory
 )
}

Здесь мы тоже используем mediaItem. Но это ещё не тот mediaItem, что нам нужен. Он используется, только чтобы создать hlsDownloadHelper.

Далее мы вызываем метод hlsDownloadHelper.prepare(prepareCallback) и передаём в него коллбэк, в котором и будем создавать нужный нам mediaItem.

fun prepareMediaItem(mediaItemPrepared: (mediaItem: MediaItem) -> Unit) {
 val prepareCallback = object : DownloadHelper.Callback {
 override fun onPrepared(helper: DownloadHelper) {
 val trackIndex = VideoTrackSelector.selectTrack(helper,
bandwidthMeter)
 val cacheStreamKeys = arrayListOf(
 StreamKey(HlsMultivariantPlaylist.GROUP_INDEX_VARIANT,
trackIndex)
 )
 val preparedMediaItem = MediaItem.Builder()
 .setUri(videoData?.uri)
 .setStreamKeys(cacheStreamKeys)
 .build()
 mediaItemPrepared(preparedMediaItem)
}
 override fun onPrepareError(helper: DownloadHelper, e: IOException) {
 e.printStackTrace()
 val preparedMediaItem = MediaItem.Builder()
 .setUri(videoData?.uri)
 .setStreamKeys(defaultCacheStreamKeys)
 .build()
 mediaItemPrepared(preparedMediaItem)
 }
 }
 hlsDownloadHelper.prepare(prepareCallback)
}

Здесь важный метод — setStreamKeys(), который на вход получает List<StreamKey>. Конструктор StreamKey получает 2 целочисленных параметра: индекс группы и индекс потока. Про последний сейчас расскажу подробнее.

Индекс потока — это, по сути, индекс дорожки с определённым качеством видео. Например: 0 — это видеопоток с качеством Full HD, 1 — это HD, 2 — SD и т.д. Получается, нам нужно определить подходящий индекс дорожки на основе пропускной способности сети. И для этого мы пишем VideoTrackSelector. Как он работает, я расскажу дальше.

Если вдруг нам не удастся получить информацию из плейлиста, просто передадим в preparedMediaItem первый индекс дорожки:

val defaultCacheStreamKeys = arrayListOf(
 StreamKey(HlsMultivariantPlaylist.GROUP_INDEX_VARIANT, 0),
)

Созданный здесь preparedMediaItem — это и есть нужный нам mediaItem. Его мы передаём на вход HlsDownloader для прекеширования и HlsMediaSource для воспроизведения. 

Но чтобы это всё работало, нам нужно измерить пропускную способность сети. В ExoPlayer для этого есть BandwidthMeter. Его экземпляр мы отдаём плееру. И это позволит нам получать актуальную пропускную способность сети. BandwidthMeter нужно передать VideoTrackSelector.

val bandwidthMeter = DefaultBandwidthMeter.Builder(app).build()
val exoPlayer = ExoPlayer.Builder(requireContext())
 .setBandwidthMeter(bandwidthMeter)
 .build().apply {
 repeatMode = Player.REPEAT_MODE_ONE
 playWhenReady = true
 }

Дальше определяем подходящую дорожку на основе пропускной способности сети. Методу VideoTrackSelector.selectTrack() на вход подаются DownloadHepler и BandwidthMeter, которые мы получили раньше. Метод возвращает индекс подходящей дорожки на основе пропускной способности сети.

Индекс дорожки мы берём из группы дорожек, которая будет доступна в DownloadHelper после успешного выполнения метода DownloadHelper.prepare().

object VideoTrackSelector {
 fun selectTrack(helper: DownloadHelper, bandwidthMeter: BandwidthMeter): Int {
 val trackResolution = pickResolution(bandwidthMeter)
 val trackGroup = helper.getTrackGroups(0)
 .get(HlsMultivariantPlaylist.GROUP_INDEX_VARIANT)
 return if (trackResolution != null) {
 getMatchingTrackIndex(trackResolution, trackGroup)
 } else {
 getMinResolutionTrackIndex(trackGroup)
 }
 }
 //...
}

Определяем подходящее качество видео:

private fun pickResolution(bandwidthMeter: BandwidthMeter): VideoResolution? {
 val networkSpeedInMbs = bandwidthMeter.bitrateEstimate / 1024 / 1024
 return when (networkSpeedInMbs) {
 in 20..1000 -> VideoResolution.HIGH
 in 12 until 20 -> VideoResolution.MIDDLE
 in 5 until 12 -> VideoResolution.LOW
 in 2 until 5 -> VideoResolution.LOWEST
 else -> null
 }
}
private enum class VideoResolution(val range: IntRange) {
 LOWEST(300 until 400),
 LOW(400..500),
MIDDLE(700..800),
 HIGH(1000..1100)
}

Получаем индекс дорожки с подходящим качеством:

private fun getMatchingTrackIndex(
 trackResolution: VideoResolution,
 trackGroup: TrackGroup
): Int {
 var matchingTrackIndex = -1
 for (trackIndex in 0 until trackGroup.length) {
 val width = trackGroup.getFormat(trackIndex).width
 val height = trackGroup.getFormat(trackIndex).height
 val isVerticalVideo = width < height
 if (isVerticalVideo) {
 if (width in trackResolution.range) {
 matchingTrackIndex = trackIndex
 break
 }
 } else {
 if (height in trackResolution.range) {
 matchingTrackIndex = trackIndex
 break
 }
 }
 }
 return if (matchingTrackIndex >= 0) {
 matchingTrackIndex
 }
 else {
 getMinResolutionTrackIndex(trackGroup)
 }
}

Получаем индекс дорожки с минимальным качеством:

private fun getMinResolutionTrackIndex(trackGroup: TrackGroup): Int {
 var minResolution = Int.MAX_VALUE
 var minResolutionIndex = -1
 for (trackIndex in 0 until trackGroup.length) {
 val width = trackGroup.getFormat(trackIndex).width
 val height = trackGroup.getFormat(trackIndex).height
 val isVerticalFormat = width < height
 if (isVerticalFormat) {
 if (width < minResolution) {
 minResolution = width
 minResolutionIndex = trackIndex
 }
 } else {
 if (height < minResolution) {
 minResolution = height
 minResolutionIndex = trackIndex
 }
 }
 }
 return minResolutionIndex
}

Подведём итоги

Как видите, в целом в реализации плавной прокрутки нет ничего сложного. Если настроить всё правильно, видео будет грузиться максимально быстро и воспроизводиться без буферизации.

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