OpenWork Engineer Blog

OpenWork を運営するエンジニアによるテックブログです。

Epoxy + Kotlin + Data Bindingでidの割り振り方について

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を保証しているケースは稀でしょう。例えば、CompanyJobというエンティティがあったとして、Company.idJob.idに重複が無いことは保証できません。

画面仕様によって、ViewHolderの種類が単一 or 複数、ユーザー操作によるデータ更新の有無、更新時のアニメーションの有無などが変わってきますが、本記事ではそれらのケースでidをどう割り振ればうまくいくか考えてみます。なお、Epoxyの基本的な使い方には触れません。

TL;DR

  • Epoxyモデルの種類が多くなるとNumber型のidだけだと辛いのでCharSequence型を組み合わせて使う。
  • 都度CharSequenceを考えてハードコードするのではなく、モデルごとに勝手に生成されて参照可能な値を使うと楽。例えば完全修飾クラス名など。

バージョンなど

単一のエンティティ -> 単一のViewHolder、データ更新無し

f:id:PEEE:20191112142018p:plain:w360

以下のような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、データ更新無し

f:id:PEEE:20191112142716p:plain:w360

前述の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.idJob.idや、リストのインデックスはそのままでは重複して使えませんが、例えばjobView{}id()に渡す値の符号を反転すれば使えそうです。しかし、エンティティが3種類になると行き詰まってしまいます。

2種類のエンティティ -> 2種類のViewHolder、データ更新あり(アコーディオンリスト)

f:id:PEEE:20191112142737g:plain

前述のCompanyjobListプロパティを追加して、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
    }
}

EpoxyControllerExpandableCompany.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を利用するとそうなってくれません。

f:id:PEEE:20191112142740g:plain

ではそれぞれのリストのインデックスを使うとどうでしょうか。例えば以下のように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.idJob.idに0が存在しない場合、上記のインデックスように組み合わせて使えるかもしれません。

さらにヘッダーやフッターがある場合

f:id:PEEE:20191112142743g:plain

アコーディオンリストの上に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のちょっと細かい話でした。