ロコガイド テックブログ

「地域のくらしを、かしこく、たのしく」する、株式会社ロコガイドの社員がいろいろな記事を書いています。

「地域のくらしを、かしこく、たのしく」する、株式会社ロコガイドの社員がいろいろな記事を書いています。

ExoPlayer in Carouselを実現するための手引き

f:id:yokomii:20201221153641p:plain

メリークリスマスイブ🎄🎅
ロコガイドでAndroidエンジニアをしております、横山(yokomii)です。
この記事はロコガイド Advent Calendar 2020の24日目です。

今回は、Androidの動画再生用 OSSライブラリである「ExoPlayer」を用いて
カルーセル(RecyclerView in RecyclerView)上に動画プレイヤーを複数並べて表示するサンプルコードとTipsを紹介します。
Google Play ストアにも同様のレイアウトが存在しますが、似た実装のサンプルコードが他に見当たらなかったので知見共有です🙏

本実装は、昨日(23日目)の小椋さんの記事でも紹介がありました、スポットライト動画の機能追加に伴い開発しました。
ご興味のある方はトクバイアプリをダウンロードしてご確認ください!

ExoPlayer × カルーセル

主な用件は以下の通りです。

f:id:yokomii:20201221153639p:plain
イメージ

  • カルーセル上に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()
        }
}

完成イメージ

f:id:yokomii:20201221153637g:plain
イメージgif
 

動いた〜🎉

サンプルコードの全容は↓で公開しているので合わせてご確認ください。
https://github.com/yokomii/exoplayer-in-carousel-demo

終わりに

いかがだったでしょうか。
ExoPlayerはリソース管理が難しいですが、適切な管理方法を覚えてガンガン活用していきたいですね。

ロコガイドではAndroidエンジニアを随時募集していますので、興味があれば気軽にお問い合わせください↓↓
Androidアプリケーション開発エンジニア/MAU800万人・国内最大級の買い物情報サービス


明日のアドベントカレンダーは我らがCTOの再々登場です。
ラストを締めくくるにふさわしい最高の記事にご期待ください🎅