メリークリスマスイブ🎄🎅
ロコガイドでAndroidエンジニアをしております、横山(yokomii)です。
この記事はロコガイド Advent Calendar 2020の24日目です。
今回は、Androidの動画再生用 OSSライブラリである「ExoPlayer」を用いて
カルーセル(RecyclerView in RecyclerView)上に動画プレイヤーを複数並べて表示するサンプルコードとTipsを紹介します。
Google Play ストアにも同様のレイアウトが存在しますが、似た実装のサンプルコードが他に見当たらなかったので知見共有です🙏
本実装は、昨日(23日目)の小椋さんの記事でも紹介がありました、スポットライト動画の機能追加に伴い開発しました。
ご興味のある方はトクバイアプリをダウンロードしてご確認ください!
ExoPlayer × カルーセル
主な用件は以下の通りです。
- カルーセル上にPlayerViewを複数表示
- スクロールスナップ(スクロール完了時に動画Viewを中心位置で静止)
- スクロール後に動画を自動再生
PlayerManager
Playerリソースをカルーセル上の動画と同じ数生成してしまうと、あっという間にOOMが発生してしまいます。
リソースは適切な数を生成し、不要になったら都度開放する必要があります。
そこで、Playerリソースの生成〜開放までを一元管理するPlayerManager
クラスを作成しました。
PlayerManager.kt
class PlayerManager(private val context: Context) { private val cache = mutableMapOf<MediaData, SimpleExoPlayer>() private var currentMedia: MediaData? = null [...] }
MediaData
はMedia情報を保有する単純なDTOクラスです。
MediaData.kt
data class MediaData( val id: Long, val mediaUrl: String, )
Player
list_item_exo_player.xml
<com.google.android.exoplayer2.ui.PlayerView android:id="@+id/playerView" android:layout_width="match_parent" android:layout_height="wrap_content" [...] app:use_controller="false" />
app:use_controller="false"
を設定して、デフォルトのUIコントローラーを非表示にしています。
PlayerViewHolder.kt
class PlayerViewHolder( private val view: View, private val playerManager: PlayerManager, ) : RecyclerView.ViewHolder(view) { private lateinit var mediaData: MediaData fun onBind(mediaData: MediaData) { this.mediaData = mediaData val playerView = view.findViewById<PlayerView>(R.id.playerView) playerView.player = playerManager.getPlayer(mediaData) } fun onRecycled() { playerManager.releasePlayer(mediaData) val playerView = view.findViewById<PlayerView>(R.id.playerView) playerView.player = null } }
bind
時にPlayerManagerからPlayerリソースを取得し、PlayerViewにセットします。
そうすることで、見切れているPlayerも再生準備が開始されている状態となります。
recycled
時には不要となったリソースを開放します。
PlayerManager.kt
[...] fun getPlayer(mediaData: MediaData): SimpleExoPlayer = cache[mediaData] ?: createPlayer() .also { it.prepareMedia(mediaData) cache[mediaData] = it } fun releasePlayer(mediaData: MediaData) { val player = cache.remove(mediaData) player?.release() }
Carousel
list_item_carousel.xml
<androidx.recyclerview.widget.RecyclerView android:id="@+id/carousel" android:layout_width="match_parent" android:layout_height="wrap_content" [...] android:orientation="horizontal" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
PlayerCarouselViewHolder.kt
class PlayerCarouselViewHolder( view: View, private val mediaList: List<MediaData>, private val lifecycleOwner: LifecycleOwner, ) : RecyclerView.ViewHolder(view), LifecycleObserver { private val playerManager: PlayerManager = PlayerManager(view.context) private var isViewAttached = false set(value) { field = value onVisibilityChanged() } private var isVisibleParent = false set(value) { field = value onVisibilityChanged() } fun onBind() { lifecycleOwner.lifecycle.addObserver(this) } @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun onResume() { isVisibleParent = true } @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun onPause() { isVisibleParent = false } fun onViewAttached() { isViewAttached = true } fun onViewDetached() { isViewAttached = false } private fun onVisibilityChanged() { [...] } [...] }
CarousellViewHolderでandroidx.lifecycle.LifecycleObserver
を実装し、アクティビティのライフサイクルに応じて動画を再生/停止できるようにします。
加えて、CarouselViewのAttach状態も監視します。
CarouselViewが表示されたらPlayerManager.onCurrentMediaChanged()
を実行し、CurrentMediaData(表示中の動画のMediaData)の保存と、Playerの再生を開始します。
CarouselViewが非表示になったときはPlayerManager.releaseAllPlayer()
を実行し、全リソースを開放します。
PlayerCarouselViewHolder.kt
private var currentPosition = 0 private val adapter = PlayerCarouselAdapter( playerManager, mediaList, ) [...] private fun onVisibilityChanged() { val isVisibleToUser = isViewAttached && isVisibleParent when { isVisibleToUser -> { playerManager.onCurrentMediaChanged(mediaList[currentPosition]) adapter.notifyDataSetChanged() } else -> { playerManager.releaseAllPlayer() } } }
PlayerManager.kt
[...] fun onCurrentMediaChanged(mediaData: MediaData) { this.currentMedia = mediaData val player = getPlayer(mediaData) player.play() } fun releaseAllPlayer() { cache.values.forEach { it.release() } cache.clear() }
CarouselViewの再表示時(バックグラウンドからの復帰、またはリストスクロール時による)は、Adapter.notifyDataSetChanged()
が実行されることで、PlayerViewに新しいPlayerが再設定されます。
このとき、処理の実行順が
notifyDataSetChanged
↓
PlayerViewHolder.onRecycled()
↓
PlayerViewHolder.onBind()
となり、Playerが再生状態にならないため、PlayerManager.getPlayer()
でもCurrentMediaPlayerを再生できるようにします。
PlayerManager.kt
fun getPlayer(mediaData: MediaData): SimpleExoPlayer = cache[mediaData] ?: createPlayer() [...] .also { if (currentMedia == mediaData && !it.isPlaying) { it.play() } }
ScrolledListener
PlayerViewがCarouselの中心位置で静止するようにするため、CarouselViewにSnapHelperを登録します。
PlayerCarouselViewHolder.kt
[...] init { val carousel = view.findViewById<RecyclerView>(R.id.carousel) PagerSnapHelper().attachToRecyclerView(carousel) carousel.adapter = adapter }
加えて、CarouselViewにOnScrollListenerを設定し、スクロール位置の変更を検知できるようにします。
PlayerCarouselViewHolder.kt
private val onScrolledListener = object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { when (newState) { RecyclerView.SCROLL_STATE_IDLE -> { val layoutManager = recyclerView.layoutManager as LinearLayoutManager val position = layoutManager.findFirstVisibleItemPosition() .takeIf { it >= 0 } ?: return if (position != currentPosition) { currentPosition = position playerManager.onCurrentMediaChanged(mediaList[position]) } } } } } init { [...] carousel.addOnScrollListener(onScrolledListener) }
スクロール位置の変更時にPlayerManager.onCurrentMediaChanged()
を実行します。
PlayerManager.onCurrentMediaChanged()
ではCurrentMediaData以外のPlayerを静止するようにします。
PlayerManager.kt
fun onCurrentMediaChanged(mediaData: MediaData) { [...] cache.filter { it.key != mediaData } .forEach { val player = it.value player.seekTo(0, 0) player.pause() } }
完成イメージ
動いた〜🎉
サンプルコードの全容は↓で公開しているので合わせてご確認ください。
https://github.com/yokomii/exoplayer-in-carousel-demo
終わりに
いかがだったでしょうか。
ExoPlayerはリソース管理が難しいですが、適切な管理方法を覚えてガンガン活用していきたいですね。
ロコガイドではAndroidエンジニアを随時募集していますので、興味があれば気軽にお問い合わせください↓↓
Androidアプリケーション開発エンジニア/MAU800万人・国内最大級の買い物情報サービス
明日のアドベントカレンダーは我らがCTOの再々登場です。
ラストを締めくくるにふさわしい最高の記事にご期待ください🎅