Часто ли вы пользуетесь 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. Оптимизация