Часто ли вы пользуетесь Telegram?
Если да, то скорее всего вы хотя бы раз отправляли "кружочки". В этой серии статьей мы напишем небольшой проект с отображением списка видео-сообщений.
Для отображения будем использовать ExoPlayer, настроим сохранение видео в кеш, а также напишем свой TimeBar для управления видео.

Оглавление

Введение

Вначале каждой части я прикреплю git-ветку, в которой будут изменения, описанные в статье. Вы можете либо сразу скачать ее и запустить проект, либо самостоятельно пошагово писать код.
Также можете взять git-ветку предыдущей части и сразу понять контекст.

Git-ветка этой части.

Оптимизация

В этой части мы будем решать проблемы, с которыми столкнулись в предыдущей статье:

  • Видео не успевают воспроизвестись при прокрутке элементов

  • При быстрой прокрутке проседает fps

Оптимизация будет состоять из трех этапов:

  • Асинхронный inflate вью плеера

  • Отложенная загрузка видео

  • Отображение миниатюры первого кадра видео

Асинхронный inflate вью плеера

После профилирования приложения становится понятно, что плавность прокрутки ухудшается из-за тяжелого инфлейта PlayerView.
Сделаем создание вью асинхронным с помощью AsyncLayoutInflater.

Подключим его к проекту.

app/build.gradle.kts

...

dependencies {
    ...
    implementation("androidx.asynclayoutinflater:asynclayoutinflater:1.0.0")
}

Добавим сущность ViewHolderPool, который будет хранить в себе созданные вью плеера.

ViewHolderPool.kt

private const val CACHE_SIZE = 7

class ViewHolderPool(recyclerView: RecyclerView) {
    
    private var inflater = AsyncLayoutInflater(recyclerView.context)
    private var viewCache = ArrayDeque<View>()

    // при инициализации асинхронно создаем 7 view.
    // кол-во кеша можно настраивать в зависимости от того
    // сколько view помещается на экран
    init {
        repeat(CACHE_SIZE) {
            inflater.inflate(R.layout.li_bubble, recyclerView) { view, _, _ ->
                viewCache.add(view)
            }
        }
    }

    fun getOrNull(): View? {
        return viewCache.removeFirstOrNull()
    }
}

Добавим ViewHolderPool в BubbleAdapter.

BubbleAdapter.kt

class BubbleAdapter(
    private val items: List<BubbleModel>
) : RecyclerView.Adapter<BubbleViewHolder>() {

    private var viewHolderPool: ViewHolderPool? = null

    // при присоединении adapter'а к recycler'у инициализируем ViewHolderPool
    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        super.onAttachedToRecyclerView(recyclerView)
        viewHolderPool = viewHolderPool ?: ViewHolderPool(recyclerView)
    }

    // при отсоединении adapter'а от recycler'у зануляем ViewHolderPool
    override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
        super.onDetachedFromRecyclerView(recyclerView)
        viewHolderPool = null
    }

    // при создании viewHolder'а либо используем либо созданную асинхронно view,
    // либо создаем новую в методе inflateNewView()
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BubbleViewHolder {
        val cachedView = viewHolderPool?.getOrNull()
        val inflatedView = cachedView ?: inflateNewView(parent)
        return BubbleViewHolder(inflatedView)
    }

    private fun inflateNewView(parentView: ViewGroup): View {
        val inflater = LayoutInflater.from(parentView.context)
        return inflater.inflate(R.layout.li_bubble, parentView, false)
    }
    ...
}

Запускаем и видим что скролл стал более плавным, но еще далек от идеала.
При fling-прокрутке плавность все же проседает и видео не успевает подгружаться.

Отложенная загрузка контента

Профилируем еще и видим, что теперь плавность прокрутки ухудшается из-за загрузки видео внутри player.setMediaSource(mediaSource).

Чтобы минимизировать кол-во загрузок поставим debounce в 700мс внутри метода bind().

BubbleViewHolder.kt

private const val LOAD_VIDEO_DEBOUNCE_MS = 700L

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {

    private var loadVideoJob: Job? = null

    ...

    // подробно на теме корутин останавливаться не буду, тк не по теме статьи
    override val coroutineContext: CoroutineContext
        get() = Job() + Dispatchers.Main

    fun bind(model: BubbleModel) {
        // отменяем предыдущую загрузку предыдущего видео
        loadVideoJob?.cancel()
        loadVideoJob = launch {
            delay(LOAD_VIDEO_DEBOUNCE_MS)
            val mediaSource = mediaSourceFactory.createMediaSource(model.videoUrl)
            // начинаем загрузку нового видео после 700мс задержки
            player.setMediaSource(mediaSource)
            player.prepare()
            player.play()
        }
    }
}

Запускаем и видим что скролл стал плавным. Но появилась другая проблема - после прокрутки видео "дергается". Происходит это из-за того что новое видео начинает загружаться с задержкой, и на мгновение мы видим старое видео с предыдущего вызова метода bind().

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

Таким образом пока видео будет загружаться его перекроет ImageView и старого видео мы не увидим.

Отображение миниатюры первого кадра видео

Для большинства форматов видео есть возможность отображения первого кадра с помощью обычных библиотек загрузки картинок (Glide, Picasso итд).

Подключим Glide к проекту.

app/build.gradle.kts

dependencies {
    ...
    implementation("com.github.bumptech.glide:glide:4.16.0")
}

Добавим ImageView для отображения превью видео.

li_bubble.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="50dp">

    ...

    <!-- убедись что AppCompatImageView перекрывает PlayerView -->
    <androidx.appcompat.widget.AppCompatImageView
      android:id="@+id/li_bubble_thumbnail"
      android:layout_width="0dp"
      android:layout_height="0dp"
      android:scaleType="centerCrop"
      app:layout_constraintBottom_toBottomOf="@+id/li_bubble_player_view"
      app:layout_constraintEnd_toEndOf="@+id/li_bubble_player_view"
      app:layout_constraintStart_toStartOf="@+id/li_bubble_player_view"
      app:layout_constraintTop_toTopOf="@+id/li_bubble_player_view" />

</androidx.constraintlayout.widget.ConstraintLayout>

Чтобы отследить начало воспроизведения видео нам нужна реализация Player.Listener.

BubblePlayerListener.kt

class BubblePlayerListener(
    private val viewHolder: BubbleViewHolder
) : Player.Listener {
    
    override fun onIsPlayingChanged(isPlaying: Boolean) {
        if (isPlaying) {
            viewHolder.onFinishLoadVideo()
        }
    }
}

В BubbleViewHolder добавим методы, которые будут показывать и скрывать наше превью.

BubbleViewHolder.kt

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {

    ...

    private val listener = BubblePlayerListener(this)
    private val thumbnail = itemView.findViewById<ImageView>(R.id.li_bubble_thumbnail)
    private val player = ExoPlayer.Builder(itemView.context).build().apply {
        repeatMode = ExoPlayer.REPEAT_MODE_ONE
        addListener(listener)
    }

    ...

    fun bind(model: BubbleModel) {
        onStartLoadVideo(model.videoUrl)
        ...
    }
    
    fun onFinishLoadVideo() {
        thumbnail.isVisible = false
    }
    
    private fun onStartLoadVideo(videoUrl: String) {
        thumbnail.isVisible = true
        Glide.with(thumbnail)
            .load(videoUrl)
            // загружаем первый кадр
            .apply(RequestOptions().frame(0))
            .into(thumbnail)
    }
}

На этом работа по оптимизации закончена. Давайте добавим скругление углов в нашем сообщении и посмотрим на результат.

BubbleViewHolder.kt

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {

    ...

    init {
        ...
        itemView.outlineProvider = object : ViewOutlineProvider() {
            override fun getOutline(view: View, outline: Outline) {
                outline.setOval(0, 0, view.width, view.height)
            }
        }
        itemView.clipToOutline = true
    }
    ...
}

Заключение

Все готово! Запускаем и видим достаточно плавное отображение наших видео-сообщений.
В следующей статье мы обработаем раскрытие видео по нажатию и добавление контроллов для управления видео.

Читать далее: Часть 3. Контролы и раскрытое состояние

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