【Android】RecyclerView in RecyclerViewの罠【Epoxy】


はじめに

RecyclerView、便利だよね。
単純なリストだったらサンプル見れば誰でも簡単に実装できるし、Epoxyなんて使った日にはもうめちゃくちゃわかりやすく書けちゃう。

そんなRecyclerViewだけど、こないだRecyclerViewの中にRecyclerViewを入れる実装をした結果、二つの不具合に遭遇した。
多分次やった時も同じ轍を踏みそうな気がするので、忘れないよう備忘録がわりに記事を残しとく。
ちなみにEpoxy使う。

RecyclerViewのおさらい

簡単に今回関係するところだけかいつまんでおさらい。
そもそも、RecyclerViewはViewのオブジェクトを再利用して表示している。

画面外に出たViewHoldeのオブジェクトは中のデータを入れ替えて再利用される。
これにより、リストに表示するアイテムが100個とか1000個あっても、ViewHolderのオブジェクト自体は10個とかそこらで済む。
とっても優秀。

今回作りたいもの

YoutubeとかTwitterの"他○件の返信を表示"ボタンを作りたい。
完成形のイメージはこんな感じ。

「他○件の返信」ってところをタップすると、小さなリストが開閉する。
これを実装するにあたって、大きく2つの不具合が出た。ハマった。

不具合1:開けていないリストが開いている。

開閉の実装が完了した喜びもつかの間、リストを動かしてみると、1のリストを開いたのに9のリストが開いていることがわかった。
これじゃあユーザーも混乱してしまう。

原因

ViewHolderに開閉のフラグを持たせているのが原因だった。
開閉をしていたコードはこんな感じ。

private var isShownTestItem2List = false

override fun bind(holder: ViewHolder) {
    holder.binding.apply {
        this.onClickShowTest2 = View.OnClickListener {
            this@TestListItem1View.isShownTestItem2List?.let {
                isShownTestItem2List = it.not()
                this.recyclerView.visibility = if (it.not()) {
                    View.VISIBLE
                } else {
                    View.GONE
                }
            }
        }
    }
}

EpoxyViewHolderの中にフラグを持たせて、そいつを切り替えてVISIBLE/GONEの判定をしている。
これでは、RecyclerViewがViewHolderを再利用した時に、以前使っていたViewHolderの情報を使ってリストを表示してしまう。
結果、1を開いてView.VISIBLEになったフラグを参照してしまい、9のリストも表示されてしまう。

解決策

Viewの情報はFragmentのメンバ変数にして管理することにした。

class TestFragment : Fragment() {
    private var commentListItems: List<Pair<TestListItem1, Boolean>>? = null
    //省略
}

こんな感じで、ViewHolderから切り離してしまえば、再利用の影響は受けなくなる。
上のコードはPairを使っているけど、ジェネリクスが増えると鬱陶しいので、僕はListItemとBooleanを保持するクラスを新しく作った。
どっちでもいいと思う。

不具合2:押してすぐに開かない。一度下に行って戻ると開かれている。

Fragmentにデータを持たせたことで、EpoxyControllerのrequestModelBuildを呼び出さなければならなくなってしまった。
Epoxyには、中のデータが変わった時にrequestModelBuildを呼び出してあげないといけないっていう決まりがあったはず。

がしかし、ちゃんと呼び出しているにも関わらず開閉が行われない。
一度下にスクロールして戻ってくると開かれている。謎。

原因

idが変わっていないことが原因だった。
Epoxyは設定しているidが変更されているかどうかで更新の挙動が変わるらしい。
idが変わっていないと、ViewHolderのデータを更新する。
idが変わっていると、ViewHolderそのものを作り直す。

今回はデータの更新はかかっていたようだが、ViewHolderが更新されないことで見た目に変化が現れないのが問題となっていた。

解決策

idにフラグを含めることで、開閉が行われた時にidが変更されるようにした。
具体的にはこんな感じ。

override fun buildModels() {
    data?.forEach { item1 ->
        testListItem1View(item1) {
            id("TestListItem1_${item1.id}_isShown_${item1.isShown}")
        }
        addDivider()
    }
}

こうすれば、requestModelBuild()呼び出し時にViewHolderが作り直されて、リストも表示されるようになる。
ただ、確実にコード汚くなったし、そもそも作り直しが行われる時チラついてめちゃくちゃ鬱陶しい。。。

結局どうしたか

RecyclerViewを1階層浅くして、一つのEpoxyControllerだけで表示できるようにした

class TestController(
    private val onClickShowReplies: ((CommentId) -> Unit)?
) : TestController<List<TestListItem1>?>() {

    override fun buildModels(data: List<TestListItem1>?) {
        data?.forEach { testListItem ->
           testListItemView(testListItem) {
                id("id")
                onClickShowReplies { onClickShowReplies?.invoke(it) }
            }
            if (testListItem1.isShown) {
               testListItem1.testListItem2.forEach { testListItem2 ->
                    testListItem2 {
                        id("id")
                    }
                }
            }
        }
    }
}

ところどころ名前とかテキトーだけど、だいたいこんな感じ。
RecyclerView in RecyclerViewからフラットになったことで、複雑なこと一切なくなって期待通り動くようになった。

まとめ

  • ViewHolderは再利用されるので、状態を保持させておくと他のアイテムに影響してしまう。FragmentかViewModelで管理しよう。
  • Epoxyはidが変更されているかどうかで挙動が変わる。見た目を更新したい場合はidが変わるように設定してあげる必要がある。
  • そもそもRecyclerView(縦) in RecyclerView(縦)はやめたほうがいい。どうにかしてフラットにできないか考えた方がいい。

DroidKaigi2019のパフォーマンス改善のセッションでも言っていたけど、無駄な階層があるとそれだけ描画のコストも上がるし、他の人が不要なbackground設定してオーバードローされる確率も上がっちゃう。
普段からなるべくレイアウトの階層を浅くするよう心がけることが大事かもしれないね。

おわり。