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