Часто ли вы пользуетесь 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. Контролы и раскрытое состояние