Android
RecyclerView

RecyclerViewのViewPoolを共有してInflate回数を劇的に減らす

DroidKaigi 2018、おつかれさまでした。
今年で2回目の参加でしたが、やはり日本のAndroidエンジニアのお祭りという感じでとても楽しかったです。

この記事では、DroidKaigi 2018公式アプリに出したRecycledViewPoolの共有PRについて書いています。

きっかけ

今回のDroidKaigi、@thagikura さんの講演「Deep dive into LayoutManager for RecyclerView」の中でRecyclerViewのViewPoolが共有可能だということを知りました。

そして共有可能ということは、ViewPagerの各ページで表示するViewがほぼ同じような画面で共有するといろいろ効率化できるのでは?と思ったので調べ始めました。

とりあえずRecyclerViewのドキュメントを見てみるとありました。

ここには次のように書かれています

Recycled view pools allow multiple RecyclerViews to share a common pool of scrap views. This can be useful if you have multiple RecyclerViews with adapters that use the same view types, for example if you have several data sets with the same kinds of item views displayed by a ViewPager.

まさに、前述のような状況で使うことが想定されている機能のようです。

最初は、弊社アプリで試してみようと思ったのですがある問題(後述します)があり断念せざるを得ませんでした。
しかし、目の前にその検証にうってつけの構造のアプリ(DroidKaigi 2018公式アプリ)があることに気が付き、せっかくなのでコントリビュートしてみようということで検証&PRさせて頂きました。

実装

ViewPoolを共有するための実装自体は非常に簡単で、共有するRecycledViewPoolインスタンスをRecyclerView のsetRecycledViewPoolで渡してあげるだけです。
しかし、これだけだと思ったような効果は得られませんでした。
もう少し調べてみると、LinearLayoutManagerGridLayoutManagersetRecycleChildrenOnDetachというメソッドがあることがわかりました。

Set whether LayoutManager will recycle its children when it is detached from RecyclerView.

If you are using a RecyclerView.RecycledViewPool, it might be a good idea to set this flag to true so that views will be available to other RecyclerViews immediately.

Note that, setting this flag will result in a performance drop if RecyclerView is restored.

どうやら、このフラグをセットしていないと破棄されたRecyclerViewのリストアイテムのViewがすぐに他のRecyclerViewで利用可能にならないようです。

このフラグも合わせてセットすると次のような実装になります。

binding.sessionsRecycler.apply {
    // 省略
    recycledViewPool = sharedRecycledViewPool
    (layoutManager as LinearLayoutManager).recycleChildrenOnDetach = true
}

conference-app-2018/RoomSessionsFragment.kt

検証

AllSessionsFragment.kt, RoomSessionsFragment.kt, ScheduleSessionsFragment.kt の3つのFragmentで共有Poolを使うように実装した上で、GroupAdapterを以下のようなinflateの回数を数えるものに差し替えて実験します。

class InflateCounterGroupAdapter : GroupAdapter<ViewHolder>() {

    companion object {
        private val count: AtomicInteger = AtomicInteger(0)
    }

    override fun onCreateViewHolder(parent: ViewGroup, layoutResId: Int): ViewHolder {
        Timber.d("Inflate view holder ${count.incrementAndGet()}")
        return super.onCreateViewHolder(parent, layoutResId)
    }
}

ViewPoolの共有化前後で以下の操作を行い、inflateが何回行われたのかカウントしました。

  1. アプリを起動(RoomモードのAllタブが開かれる)
  2. Room1タブからRoom7タブまで左から順にタップしてページを切り替える
  3. ActionバーのメニュータップでScheduleモードに切替(検証当時 Day2/14:00タブが開かれる)
  4. Day2/14:00タブからAllタブまで右から順にタップしてページを切り替える

結果

Pool共有なし Pool共有あり
inflate回数 95 31

なんとinflateの回数が約1/3に!! :tada:
DroidKaigiアプリのような、ほぼ同じ種類のViewを表示するRecyclerViewに対してPool共有を適用するとかなりの効果があることが分かりました。
(当然、RecyclerViewで表示するViewの種類数などによって効果の程度は変わると思います)

注意点

Poolを共有するRecyclerViewのAdapterでViewTypeを一致させる

弊社アプリにすぐに適用できなかった理由がこれになります。
RecycledViewPoolから利用可能なViewHolderを取得する際、AdapterがgetItemViewTypeで返すViewTypeがキーとなります。
そのため、Poolを共有するRecyclerView間で別のViewに同じViewTypeを割り当てていると、期待と異なるViewが返却されてぶっ壊れます。

弊社で利用していたRecyclerView.Adapterの拡張ライブラリでは、内部で動的にViewTypeを割り当てていたため泣く泣く断念しました。
DroidKaigiアプリで利用しているGroupieはViewのlayout idをViewTypeとして使っているので、この点では安心して使うことができます。

RecycledViewPoolの保持、共有方法に気をつける

RecycledViewPool自体はContextを持っていませんが、内部に「Viewを保持する」=「ActivityのContextを保持する」ことになります。
したがって、共有可能なのは基本的に同じActivity上のViewだけ(異なるActivity間で共有しようとしたときにどうなるのかは未検証)。
また、ActivityよりもRecycledViewPoolの生存期間が長くなるとメモリリークの可能性があります。
間違ってもシングルトンで保持して共有なんてことはしないようにしましょう。

DroidKaigiアプリに最初に出したPRではViewModel上に保持してしまいましたがこれも絶対にやめましょう。
ref: https://developer.android.com/topic/libraries/architecture/viewmodel.html

Caution: A ViewModel must never reference a view, Lifecycle, or any class that may hold a reference to the activity context.

今回出したPR

おわり

今年は仕事&プライベートが異常に立て込んでいて、DroidKaigi前にアプリへのコントリビュートを一切出来なかったのですが、土壇場で駆け込みPRを出すことが出来ました。

アプリリーダーの @takahirom さんが言っているとおり、Pool共有が有効なアプリは非常に多そうなうえ、効果の割に実装は簡単なのでぜひ試していただければと思います。

おわり!