見出し画像

RecycledViewPoolを使ってみた

こんにちは、KTANです。ナビタイムジャパンでAndroidアプリ開発を担当しています。
先月、リニューアルしたAndroid版『NAVITIME』アプリをリリースしました。
自分もこの開発プロジェクトに携わり、そこで様々な技術を積極的に採用し、パフォーマンスの改善に努めました。その中の1つで、RecyclerViewのパフォーマンスを改善できるRecycledViewPoolについてまとめています。おそらくほとんどのAndroidアプリでRecyclerViewは使われていると思いますので、参考になりましたら幸いです。

RecyclerView

Android開発者にはお馴染みだと思いますが、他プラットフォーム開発者の方に向けて、まずはRecyclerViewについて簡単に説明したいと思います。

仕組み

RecyclerViewはAndroidでリストを効率的に表示するためのViewです。例えばスポット検索結果などで100件のスポットをアプリで表示することになった場合、そのまま100個のViewを作成するとメモリを圧迫してしまいます。そこでRecyclerViewは、画面外にスクロールアウトされたViewをスクロールインするViewに使い回すことで、Viewを作る量を画面上の表示されている数まで減らし、パフォーマンス悪化させることなく何件でもリスト表示できます。

参照:RecyclerViewの基礎 Codelab

問題点

しかし、このViewを使い回すことができるのは、1つのRecyclerViewのリストの中でだけです。リストが複数並んでいる場合、異なるリストの間でViewを使い回すことができません。

リストが複数並んでいるパターン

ただ、このリストが複数並んでいるパターンなんて、普通のAndroidアプリ開発だとそんなに多くないのでは?と皆さんは思うかもしれません。
ですが、ナビタイムジャパンのアプリに必ずある、とある画面で、このパターンが出てきます。

それは…


リニューアルした『NAVITIME』アプリのスクリーンショット

そう、ルート検索結果画面です。

一見、リストではないように見えますが、『NAVITIME』アプリではルート検索結果を、地点情報や移動情報のViewを並べてRecyclerViewのリストとして実装しています。『NAVITIME』アプリではユーザーにとって最適なルートを選んでもらうため、1つだけではなく多くのルート候補を検索結果として表示する必要があります。そのため、ルート候補の数だけリストが複数並ぶことになり、そのリスト間でViewを使い回すことができれば、さらなるパフォーマンスの改善ができるようになります。

RecycledViewPool

そこで登場するのが、このRecycledViewPoolです。RecycledViewPoolはRecyclerViewにセットできる共有のViewPoolです。同じViewPoolがセットされたRecyclerViewは同じViewを参照できます。これによって異なるリスト間でViewの使い回しが可能となります。

RecycledViewPoolのイメージ

実装方法

実装はとても簡単で、下記の3つのステップだけで、パフォーマンスの改善が可能になります。

① 共有するRecycledViewPoolインスタンスを作成

val recycledViewPool = RecyclerView.RecycledViewPool()

② 共有したいRecyclerViewに作成したインスタンスをセット

binding.recycler.apply {
   setRecycledViewPool(recycledViewPool)

③ LinearLayoutManagerのrecycleChildrenOnDetachフラグをONに変更

    layoutManager = LinearLayoutManager().apply {
       recycleChildrenOnDetach = true
   }
}

効果測定

実際にRecycledViewPoolを導入したことによって、どの程度パフォーマンスが改善されたか確認するため、Viewの作成されたタイミングでログを出力して、Viewの作成回数をカウントしてみます。(具体的な方法についてはこちらの記事を参考にしています。)

東京から高尾山までのルート検索結果画面で、全てのルート候補のリストを最後までスクロールしてViewの作成された回数をカウントしたところ、下記のような結果になりました。

検証結果

見ての通り、RecycledViewPoolによって、作成するViewの数が約74%削減できていることがわかります。
RecycledViewPoolなしでも100を超えないなら問題ないのではと思うかもしれませんが、iPhoneと異なりAndroidは端末の種類が非常に多く性能に差があるため、どのような端末でもカクつかずスムーズに表示させるには、このパフォーマンス改善は重要と言えます。

注意点

最後に、実際にリニューアルした『NAVITIME』アプリでRecycledViewPoolを導入した際に、自分がつまづいたポイントも紹介しておきます。

スクロール位置のリセット

上記の実装方法でそのまま導入して動作を確認したところ、ページを切り替えたり、画面遷移をした後に戻ってくると、リストのスクロール位置がリセットされてしまうことに気づきました。
例えば、ルート検索結果のルート候補1を途中までスクロールして見た後に、ルート候補2の方も確認して、ルート候補1に戻ると、途中までスクロールして見ていた位置がリセットされ、ルートの一番上から再度表示されてしまいます。
このままでは、ルート候補1とルート候補2を比較して確認する際などに、毎回手動でスクロールする必要があり非常に不便です。

なぜ発生するのか

原因を調査したところ、LinearLayoutManagerのonDetachedFromWindowメソッドのソースコードに答えがありました。

@Override
public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) {
    super.onDetachedFromWindow(view, recycler);
    if (mRecycleChildrenOnDetach) {
        removeAndRecycleAllViews(recycler);
        recycler.clear();
    }
}

上記のコードから分かる通り、RecycledViewPoolを使うために必要なrecycleChildrenOnDetachフラグがONになっていると、このonDetachedFromWindowメソッドが呼ばれたタイミングでRecyclerViewのリストを全て取り除くという処理をLinearLayoutManagerで行っています。
onDetachedFromWindowは名前の通りリストが画面外になった時に呼ばれるため、上で説明したようなルート候補1→ルート候補2→ルート候補1という操作を行うと、ルート候補2に移動した時点でルート候補1が画面外となりonDetachedFromWindowが呼ばれ、ルート候補1のリストのスクロール位置がリセットされるという流れになります。

なぜこんな処理が入っているかというと、これもパフォーマンス改善のためです。最初に説明しましたが、RecyclerViewは画面外にスクロールしたViewを使い回します。しかし、RecyclerView自体が画面外になった時点で、画面外にスクロールされていないViewも表示する必要のない不要なViewとなり、それも使い回せれば、より効率的にリストを表示することができるようになります。だから、onDetachedFromWindowが呼ばれた際にRecyclerViewのリストを全て取り除いて使い回せるようにしているというわけです。
それによってスクロール位置はリセットされてしまいますが、パフォーマンスを優先してこのような処理になっているのだと思われます。

修正方法

上記の通り、デフォルトではスクロール位置がリセットされてしまうため、スクロール位置をリセットさせたくない場合は、自分でスクロール位置を保持する処理を実装する必要があります。ただ、LinearLayoutManagerにはスクロール位置を保持するためのメソッドonRestoreInstanceStateが用意されてるため、この実装も難しくはありません。

binding.recycler.apply {
   setRecycledViewPool(recycledViewPool)
   layoutManager = object : LinearLayoutManager() {
       override fun onDetachedFromWindow(view, recycler) {
           val state = onSaveInstanceState()
           super.onDetachedFromWindow(view, recycler)
           onRestoreInstanceState(state)
       }
   }.apply {
       recycleChildrenOnDetach = true
   }
}

上記のように、onDetachedFromWindowをオーバーライドしてスクロール位置がリセットされたときにonRestoreInstanceStateを使ってスクロール位置を戻すようにしたLinearLayoutManagerを作成し、それをRecyclerViewにセットするようにすれば、Viewを使い回してパフォーマンス改善しつつ、スクロール位置もリセットされないようになります。

まとめ

今回は、複数のRecyclerViewで共有できるRecycledViewPoolでパフォーマンス改善する方法についてまとめました。
結論としては、非常に実装が簡単で、得られるパフォーマンス改善のメリットも大きいため、リストを複数表示するパターンがアプリ開発において発生した場合は、積極的に導入すべき技術だと思います。
デメリットとしては、スクロール位置を保持するためには自分でスクロール位置を保持する処理を実装をする必要がありますが、その処理の実装も難しくはないため、大きなデメリットではありませんでした。
是非、この記事を読んでいただいたAndroid開発者の方にも導入を検討していただければと思います。

また、冒頭でご紹介した通り、多くのパフォーマンスを改善を行ったリニューアルのAndroid版『NAVITIME』アプリは先月ストアに公開されました。
Androidユーザーの方はお出かけの際に、ぜひ利用してみてください!