
Часто ли вы пользуетесь Telegram?
Если да, то скорее всего вы хотя бы раз отправляли "кружочки". В этой серии статьей мы напишем небольшой проект с отображением списка видео-сообщений.
Для отображения будем использовать ExoPlayer, настроим сохранение видео в кеш, а также напишем свой TimeBar для управления видео.
Оглавление
Введение
Вначале каждой части я прикреплю git-ветку, в которой будут изменения, описанные в статье. Вы можете либо сразу скачать ее и запустить проект, либо самостоятельно пошагово писать код.
Git-ветки первой части.
Настройка проекта
Так как кружок будет элементом чата, стоит позаботиться о его корректном отображении внутри RecyclerView. Добавим его в верстку нашего экрана.
activity_main.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" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/bubbles_rv" android:layout_width="match_parent" android:layout_height="wrap_content" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
Создаем data-модель нашего видео сообщения с ссылкой на видео.
BubbleModel.kt
class BubbleModel(val videoUrl: String)
Подключим библиотеку ExoPlayer к проекту.
app/build.gradle.kts
... dependencies { ... implementation("com.google.android.exoplayer:exoplayer:2.16.1") implementation("com.google.android.exoplayer:extension-okhttp:2.16.1") }
Создаем верстку элемента списка
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"> <com.google.android.exoplayer2.ui.PlayerView android:id="@+id/li_bubble_player_view" android:layout_width="220dp" android:layout_height="220dp" app:resize_mode="zoom" app:surface_type="texture_view" app:use_controller="false" app:shutter_background_color="@android:color/transparent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
app:resize_mode="zoom" - то как видео будет подстраиваться под размеры плеера.
app:surface_type="texture_view" - тип view на котором будет отрисовываться наше вью. Почему-то при дефолтном surface_view ячейки recyclerView накладываются друг на друга. Поэтому используем texture_view.
app:use_controller="false" - скрываем контроллы видео (кнопка плей, паузы, перемотки итд).
app:shutter_background_color="@android:color/transparent" - задаем прозрачный цвет у фона плеера.
Для отображения элементов списка нам понадобится реализация RecyclerView.ViewHolder.
BubbleViewHolder.kt
class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val playerView = itemView.findViewById<PlayerView>(R.id.li_bubble_player_view) fun bind(model: BubbleModel) { // реализация будет ниже } }
Для управления viewHolder'ами добавим реализацию RecyclerView.Adapter.
BubbleAdapter.kt
class BubbleAdapter( private val items: List<BubbleModel> ) : RecyclerView.Adapter<BubbleViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BubbleViewHolder { val inflater = LayoutInflater.from(parent.context) val view = inflater.inflate(R.layout.li_bubble, parent, false) return BubbleViewHolder(view) } override fun getItemCount() = items.size override fun onBindViewHolder(holder: BubbleViewHolder, position: Int) { holder.bind(items[position]) } }
Подключим adapter к recyclerView и добавим 30 видео-сообщений.
MainActivity.kt
class MainActivity : AppCompatActivity() { private val items = mutableListOf<BubbleModel>().apply { repeat(30) { add(BubbleModel("https://i.imgur.com/3Y8IRmz.mp4")) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val recyclerView = findViewById<RecyclerView>(R.id.bubbles_rv) recyclerView.adapter = BubbleAdapter(items) } }
Для загрузки видео нам потребуются права на доступ в сеть.
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest> <uses-permission android:name="android.permission.INTERNET"/> <application ... </application> </manifest>
Добавим кеш для отображения загруженных видео.
VideoCache.kt
private const val DOWNLOAD_CONTENT_DIRECTORY = "inner_video_cache" private const val MAX_CACHE_SIZE_IN_BYTES = 100 * 1024 * 1024 object VideoCache { private var cache: SimpleCache? = null fun getInstance(context: Context): SimpleCache { return cache ?: run { //путь до файла в котором будет храниться кеш видео val cacheDir = File(context.externalCacheDir, DOWNLOAD_CONTENT_DIRECTORY) //стратегия очистки кеша (очистка последнего использованного кеша) val evictor = LeastRecentlyUsedCacheEvictor(MAX_CACHE_SIZE_IN_BYTES.toLong()) val databaseProvider = StandaloneDatabaseProvider(context) SimpleCache(cacheDir, evictor, databaseProvider).apply { cache = this } } } }
За загрузку видео внутри библиотеки ExoPlayer отвечает MediaSource. Добавим фабрику для этой сущности.
MediaSourceCreator.kt
class MediaSourceFactory( private val context: Context ) { private val mediaSourceFactory by lazy { val cacheSink = CacheDataSink.Factory().setCache(VideoCache.getInstance(context)) val upstreamFactory = DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory()) val cacheDataSourceFactory = CacheDataSource.Factory() //то куда будет сохраняться наш кеш. Если не указывать, то кеш будет read-only .setCacheWriteDataSinkFactory(cacheSink) //собственно сам кеш .setCache(VideoCache.getInstance(context)) //то откуда будет подргужаться наше видео .setUpstreamDataSourceFactory(upstreamFactory) //игнорируем ошибки при зависи в кеш .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) //Нужно выбрать MediaSource в соответсвии с форматом видео. Для большинства форматов подходит ProgressiveMediaSource ProgressiveMediaSource.Factory(cacheDataSourceFactory) } fun createMediaSource(url: String): MediaSource { return mediaSourceFactory.createMediaSource(MediaItem.fromUri(url)) } }
Добавим загрузку видео внутри BubbleViewHolder.
BubbleViewHolder.kt
class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val mediaSourceFactory = MediaSourceFactory(itemView.context) private val playerView = itemView.findViewById<PlayerView>(R.id.li_bubble_player_view) // плеер, отвечающий за взаимодействие с видео private val player = ExoPlayer.Builder(itemView.context).build().apply { //настройка повтора видео. В нашем случае воспроизводим одно видео по кругу repeatMode = ExoPlayer.REPEAT_MODE_ONE } init { playerView.player = player } fun bind(model: BubbleModel) { val mediaSource = mediaSourceFactory.createMediaSource(model.videoUrl) player.setMediaSource(mediaSource) //начинает загрузку видео player.prepare() //начинаем воспроизведение как только видео загрузится player.play() } }
Заключение
Все готово! Запускаем проект и сразу видим несколько проблем:
Видео не успевают воспроизвестись при прокрутке элементов.
При быстрой прокрутке проседает fps.
В следующей части мы займемся исправлением этих проблем.
Читать далее: Часть 2. Оптимизация
