Androidアプリエンジニアの藤樫です。
OpenWorkのAndroidアプリではRecyclerView
にEpoxyを利用しています。1つのRecyclerView
で異なるViewHolderを簡単に扱えたり、Data Bindingを定義したViewHolderレイアウトXMLからBinding用モデルクラスを自動生成してくれたり、便利なライブラリです。
Epoxyで表示データセットを更新する際にはDiffUtil
での差分更新がサポートされていて、生成されたモデルに割り振ったidがデータ更新前後で比較されて同一データかどうか判定されます。このidを割り振る際にはNumber
型やCharSequence
型を組み合わせて渡せるのですが、内部的にはどれもハッシュ関数を通してLong
に変換されます。
idをどう割り振るかは結構悩みどころです。というのも、同一RecyclerView
内でidが重複してしまうとEpoxyは例外を吐くからです。ViewHolderに渡すエンティティが一意のidを持っている場合はそれを使えそうですが、エンティティとViewHolderの種類が複数ある場合、エンティティ横断的に一意のidを保証しているケースは稀でしょう。例えば、Company
とJob
というエンティティがあったとして、Company.id
とJob.id
に重複が無いことは保証できません。
画面仕様によって、ViewHolderの種類が単一 or 複数、ユーザー操作によるデータ更新の有無、更新時のアニメーションの有無などが変わってきますが、本記事ではそれらのケースでidをどう割り振ればうまくいくか考えてみます。なお、Epoxyの基本的な使い方には触れません。
TL;DR
- Epoxyモデルの種類が多くなると
Number
型のidだけだと辛いのでCharSequence
型を組み合わせて使う。 - 都度
CharSequence
を考えてハードコードするのではなく、モデルごとに勝手に生成されて参照可能な値を使うと楽。例えば完全修飾クラス名など。
バージョンなど
- Epoxy 3.8.0 with Data Binding
- Kotlin/kapt
単一のエンティティ -> 単一のViewHolder、データ更新無し
以下のようなCompany
エンティティを、
data class Company( val id: Long, val name: String )
以下のViewHolderにData Bindingでリスト表示する場合、
- view_holder_company_view.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="company" type="Company" /> </data> <FrameLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#FFD180"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp" android:gravity="center" android:textStyle="bold" android:text="@{company.name}" /> <View android:layout_width="match_parent" android:layout_height="0.5dp" android:layout_gravity="bottom" android:background="@android:color/darker_gray" /> </FrameLayout> </layout>
Epoxyのモデルの自動生成のファイル名プレフィックスがview_holder
だとすると、view_holder_company_view.xml
からはcompanyView{}
という拡張関数が自動生成されます。EpoxyController
は以下のように書けます。
class EpoxyModel1TypeController : TypedEpoxyController<List<Company>>() { override fun buildModels(data: List<Company>?) { data?.forEach { company -> companyView { id(modelCountBuiltSoFar) company(company) } } } }
ここでidに使えそうなのは以下です。
modelCountBuiltSoFar
(javaのメソッド名getModelCountBuiltSoFar()
)data?.forEachIndexed{}
を利用したリストのインデックスCompany.id
modelCountBuiltSoFar
は、このControllerがその時点で追加済みのEpoxyモデルの数で、companyView{}
(内部的にはEpoxyModel.addTo(EpoxyController)
)を呼ぶ度にカウントアップしていきます。上記どれでも問題は起こらないと思われます。
2種類のエンティティ -> 2種類のViewHolder、データ更新無し
前述のCompany
に加えて、Job
というエンティティを定義します。
data class Job( val id: Long, val title: String, val location: String )
このJob
を、以下のViewHolderにBindします。
- view_holder_job_view.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="job" type="Job" /> </data> <FrameLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@android:color/white"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="16dp"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:textStyle="bold" android:text="@{job.title}" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="end" android:text="@{job.location}" /> </LinearLayout> <View android:layout_width="match_parent" android:layout_height="0.5dp" android:layout_gravity="bottom" android:background="@android:color/darker_gray" /> </FrameLayout> </layout>
このViewHolderからはjobView{}
という拡張関数が自動生成されます。先ほどのCompany
のリストの下にJob
のリストを表示するような画面だと、EpoxyController
は以下のように書けます。
class EpoxyModel2TypesController : Typed2EpoxyController<List<Company>, List<Job>>() { override fun buildModels(companyList: List<Company>?, jobList: List<Job>?) { companyList?.forEach { company -> companyView { id(modelCountBuiltSoFar) company(company) } } jobList?.forEach { job -> jobView { id(modelCountBuiltSoFar) job(job) } } } }
ここでidに使えそうなのはmodelCountBuiltSoFar
くらいでしょうか。Company.id
とJob.id
や、リストのインデックスはそのままでは重複して使えませんが、例えばjobView{}
のid()
に渡す値の符号を反転すれば使えそうです。しかし、エンティティが3種類になると行き詰まってしまいます。
2種類のエンティティ -> 2種類のViewHolder、データ更新あり(アコーディオンリスト)
前述のCompany
にjobList
プロパティを追加して、view_holder_company_view.xml
をタップするとview_holder_job_view.xml
のリストがアコーディオンで展開/収納されるような画面を考えます。
data class Company( val id: Long, val name: String, val jobList: List<Job>? )
view_holder_company_view.xml
のルートViewGroupにはOnClickListener
をセットします。
<data> <variable name="company" type="Company" /> <variable name="listener" type="android.view.View.OnClickListener" /> </data> <FrameLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#FFD180" android:onClick="@{listener}"> ...
また、展開状態を管理するためにExpandableCompany
というクラスを用意します。
data class ExpandableCompany( val company: Company ) { var expanded: Boolean = false fun toggle() { expanded = !expanded } }
EpoxyController
はExpandableCompany.expanded == true
の場合のみjobView
を追加するようにして、クリックリスナーでクリックされたモデルに対応するExpandableCompany
のリスト要素をtoggle()
してデータを更新すると、アコーディオンリストが実装できます。
class EpoxyModelExpandableController : TypedEpoxyController<List<ExpandableCompany>>() { override fun buildModels(data: List<ExpandableCompany>?) { data?.forEach { expandable -> companyView { id(modelCountBuiltSoFar) company(expandable.company) listener { model, _, _, _ -> data.find { it.company == model.company() }?.toggle() setData(data) } } if (expandable.expanded) { expandable.company.jobList?.forEach { job -> jobView { id(modelCountBuiltSoFar) job(job) } } } } } }
ここでmodelCountBuiltSoFar
をidに利用すると、idの重複は起こらないため例外は発生しませんが、アニメーションが不自然になります。アコーディオンリストは、展開する親要素の直後の親要素が下に移動するアニメーションが自然です。今回の例の場合、親要素であるcompanyView{}
のidは展開状態に関わらず同じものをキープする必要がありますが、modelCountBuiltSoFar
を利用するとそうなってくれません。
ではそれぞれのリストのインデックスを使うとどうでしょうか。例えば以下のようにid(Number... ids)
というシグネチャを利用します。
data?.forEachIndexed { i, expandable -> companyView { id(i) ... } } ... if (expandable.expanded) { expandable.company.jobList?.forEachIndexed { j, job -> jobView { id(i, j) ... }
これだとcompanyView{}
とjobView{}
のid体系をそれぞれ独立させられそうに見えます。しかし実はこれはidが重複して例外が発生してしまいます。原因はid(Number... ids)
のコードを読むとわかるのですが、
public EpoxyModel<T> id(@Nullable Number... ids) { long result = 0; if (ids != null) { for (@Nullable Number id : ids) { result = 31 * result + hashLong64Bit(id == null ? 0 : id.hashCode()); } } return id(result); }
可変パラメータのNumber
のそれぞれのhashCode()
をハッシュ関数に通して、31倍しながら足し上げていくのですが、Int.hashCode()
はInt値そのものであり、id(0, i) == id(i)
になってしまうためです。
2種類のエンティティだけであれば前述の符号反転が使えそうです。また、Company.id
とJob.id
に0が存在しない場合、上記のインデックスように組み合わせて使えるかもしれません。
さらにヘッダーやフッターがある場合
アコーディオンリストの上にRecyclerView
の要素として1つヘッダーを用意する場合や、アコーディオンを展開した末尾にそれぞれ「もっと見る」的なフッターを用意する場合、さらに複雑になります。それらのEpoxyモデルはドメインのエンティティとは直接結びつかないので、Company.id
のようなidが使えません。
- view_holder_header.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <FrameLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#B2DFDB"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp" android:gravity="center" android:textStyle="bold" android:text="@string/header_text" /> <View android:layout_width="match_parent" android:layout_height="0.5dp" android:layout_gravity="bottom" android:background="@android:color/darker_gray" /> </FrameLayout> </layout>
- view_holder_show_more.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <FrameLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@android:color/white"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp" android:gravity="center" android:textStyle="bold" android:text="@string/show_more" /> <View android:layout_width="match_parent" android:layout_height="0.5dp" android:layout_gravity="bottom" android:background="@android:color/darker_gray" /> </FrameLayout> </layout>
ここまで来ると、Number
型だけでバリエーションを持たせるのは複雑になりやすく、また実際のデータパターンを網羅的に検証するのが困難になり、リリース後に予期せぬクラッシュが発生してしまうリスクが高くなります。
CharSequence
を利用したid
idにはCharSequence
型も利用できます。Epoxyのコードを読むと、CharSequence.charAt()
を利用して文字数分ハッシュ処理をしながら足し上げていて、Number.hashCode()
のように0に注意する必要がありません。Epoxyモデルごとに一意の文字列を定義して、それをforEachIndexed{}
のインデックスと組み合わせるのはどうでしょう。Epoxyモデルは自動生成なので、一意の文字列はクラスごとに手動で定義するよりは、勝手に生成されて参照できる値が好ましいです。まず思いつくのは完全修飾クラス名です。
data?.forEachIndexed { i, expandable -> companyView { id(javaClass.name, i.toLong()) ... } } ... if (expandable.expanded) { expandable.company.jobList?.forEachIndexed { j, job -> jobView { id(javaClass.name, i.toString(), j.toString()) ... }
companyView{}
の中のthis
は、CompanyViewBindingModelBuilder
という自動生成されるインターフェースを実装したクラスです。ではどのクラスがそのインターフェースを実装しているかというと、CompanyViewBindingModel_
というこれまた自動生成されたクラスです。jobView{}
はまた別のBindingModelが生成されるので、javaClass.name
はそれぞれ別のCharSequence
となり、BindingModelごと(ViewHolderのXMLごと)に独立したid体系を実現できます。
このアプローチはR8やProguardで難読化されても有効です。完全修飾クラス名は一意なことが保証されているからです。
Kotlinの拡張関数で便利に使う
BindingModel_
というサフィックスがついた自動生成されるクラスはDataBindingEpoxyModel
というクラスを継承しています。よって、以下のような拡張関数を用意すればいちいちjavaClass
や.toString()
や.toLong()
を書かなくてもよくなります。
fun EpoxyController.setClassNameToId(any: Any) = (any as DataBindingEpoxyModel).id(any.javaClass.name) fun EpoxyController.setClassNameToId(any: Any, i: Int) = (any as DataBindingEpoxyModel).id(any.javaClass.name, i.toLong()) fun EpoxyController.setClassNameToId(any: Any, i: Int, j: Int) = (any as DataBindingEpoxyModel).id(any.javaClass.name, i.toString(), j.toString())
headerView { setClassNameToId(this) } data?.forEachIndexed { i, expandable -> companyView { setClassNameToId(this, i) ... } } ... if (expandable.expanded) { expandable.company.jobList?.forEachIndexed { j, job -> jobView { setClassNameToId(this, i, j) ... } } showMoreView { setClassNameToId(this, i) } } ...
(any as DataBindingEpoxyModel).id()
はEpoxyの内部実装が変わってHogeHogeBindingModel_
がHogeHogeBindingModelBuilder
を実装しなくなった場合例外が発生します。ここは変更にいち早く気づけるようにあえてas?
を使わないのがよさそうです。
まとめ
- 一度
RecyclerView
に表示したデータがユーザー操作などにより更新されない場合、idはエンティティやViewHolderの種類の数に関わらずmodelCountBuiltSoFar
を利用しても問題なさそう。 - アコーディオンリストなどデータ更新アニメーションを考慮する場合、
Number
型だけでidを管理すると行き詰まることがあるためCharSequence
型を組み合わせる。 - idに使用する
CharSequence
型について、文字列定数をハードコードで都度書くよりは、完全修飾クラス名などを利用すると便利。また、Kotlinの拡張関数を定義するとjavaClass.name
を毎回書かなくてもよい。 RecyclerView
ごとにユーザーによるデータ更新の有無を考えてmodelCountBuiltSoFar
を使うかどうか判断するよりは、全てCharSequence
型を利用する方法に統一してもよさそう。
以上、Epoxyのidのちょっと細かい話でした。