Tabelog Tech Blog

食べログの開発者による技術ブログです

Android 版食べログアプリに ViewBinding を導入した話

この記事は 食べログアドベントカレンダー2022 の3日目の記事です🎅🎄


はじめまして。
食べログシステム本部 アプリ開発部の和田と申します。

私は、Android 版食べログアプリの保守を主に担当しています。 今回は、Android 版食べログアプリに ViewBinding を導入した話をします。

なお、Android 版食べログアプリと言っても、例えば、以下のように様々なプロダクトがあります(他にもあります)。

こちらの記事は、Android 版の食べログアプリのことを述べています。

なぜ Android 版食べログアプリに ViewBinding を導入することになったのか?

ViewBinding を導入する前、食べログアプリは Kotlin Android Extensions の synthetics を利用して、レイアウトファイルの View をコード上の変数のように参照できるようにしていました。

https://developers-jp.googleblog.com/2020/11/the-future-of-kotlin-android-extensions.html

しかし、ViewBinding が公開されたため、Kotlin Android Extensions の synthetics が非推奨になり、この参照処理を ViewBinding に移行しなければならなくなりました。これが、食べログアプリに ViewBinding を導入することになった理由です。

開発環境

ViewBinding を導入した時の開発環境を記載しておきます。

  • Android Studio Arctic Fox Patch 4
  • Android Gradle Plugin 7.0.4
  • Kotlin 1.5.31
  • compileSdkVersion 31

ViewBinding とは?

ViewBinding については、Google の公式サイトをご確認頂くことが一番良いのですが、簡単に説明しておきます。

https://developer.android.com/topic/libraries/view-binding?hl=ja

ViewBinding を有効にすると、アプリをビルドする際、レイアウトファイルを解析して、レイアウトファイルごとに ViewBinding クラスが自動生成されます。 ViewBinding クラスは、レイアウトファイルの構成要素となる View について、型情報(TextView 型や Button 型など)を NonNull 型で保持し、さらに id をキャメルケースに変換したフィールド名で、その View にアクセスできるよう設計されています。

private val binding = ... // ViewBinding 取得用のコード/画面か再利用可能なパーツかで取得方法が異なる
...
fun hoge() {
    binding.submitButton.setOnClickListener { // レイアウトファイルに submit_button という id が割り振られている
        // submit_button という id が割り振られた View をクリックした時の処理
    }
}

ViewBinding を導入することで、View の参照について、例えば、以下のメリットを享受することができます。

  • 存在しない id を指定するリスクをゼロにできる
  • 原則的に型のキャストが不要
  • Google 公式の手法
  • Java/Kotlin どちらの環境でも動作する

Android 版食べログアプリに ViewBinding を導入する際、顕在化した問題点

現在の Android 版食べログアプリは、2016年 9月 7日から Google Play に公開されています。現時点において、6年以上も稼働実績があります。
6年前というと、MVP や MVVM と言ったアーキテクチャが Android 開発界隈に広まりつつあったものの、 まだ Fat Activity によるアプリ開発が散見される時代でした。食べログアプリも例外ではなく、当時のコードは Fat Activity になっていました。

その後の食べログエンジニアの活躍があって、最近ではかなり Fat Activity の問題が解消されましたが、まだレガシーコードが残っているため、せっかく Google の公式サイトに、ViewBinding を導入するためのサンプルコードが例示されているのに、サンプルコードの通りに ViewBinding を導入できない問題が多々発生しました。

代表的な問題は以下の通りです。

  • Kotlin Android Extensions の synthetics だけでなく、findViewById や ButterKnife の利用など、様々な方法で View を参照していた
  • 食べログアプリ独自のカスタムビュー(カスタムレイアウト含む)が多々定義されており、複数のクラスで一つのカスタムビューを操作している処理があった
  • ButterKnife やカスタムビューによるボイラープレートをまとめるため、抽象クラスが大量に定義されている

このような問題を解消するために採用した方法や、ViewBinding が正常に動作しなかった時に確認した方が良いポイントを、後学のため、ノウハウ集として、こちらの記事として残すことにしました。問題点と解決策を以降の章で、順次紹介してゆきます。

なお、前述の問題に対して、恒久的な対応をするためには、食べログアプリの抜本的な設計の見直しが必要になります。さすがに設計の抜本的な見直しは一朝一夕には完結しないため、問題点に対する解決策はそれ以外の方法を記載させていただきました。

【問題1】ViewBinding クラスが生成されていない/ViewBinding を参照(import)できない

ViewBinding を有効にした上で、アプリのビルドを実行すると、原則的にすべてのレイアウトファイルに対応する ViewBinding クラスが自動生成されます。しかし、Android Studio でコーディング中、ViewBinding クラスが見当たらない、または import 可能なクラスの候補にならないことがあります。

よくある原因:typo

ViewBinding クラスは、レイアウトファイルを拡張子を除いてアッパーキャメルケースに変換し、末尾に Binding という文字列を付加したクラス名として生成されます。 例えば、hoge_fuga_activity.xml というレイアウトファイルの場合、HogeFugaActivityBinding という ViewBinding クラスが生成されます。 この例の場合、Android Studio でコーディング中、「HogeFuga」まで入力しても、自動生成された ViewBinding クラスが入力候補として表示されないことが多く、HogeFugaActivityBinding というテキストをすべて手入力しなければならないことがあります。 この時、typo してしまうことがあります。

typo の解決策

ViewBinding クラスを import する場合、極力クラス名を手入力せず、以下の順番に沿ってコーディングします。

  1. レイアウトファイル名をコピーする
  2. ViewBinding を定義したい箇所にペーストする
  3. 拡張子(.xml)を削除し、代わりに Binding というテキストを挿入する
  4. アンダースコアとその右横の小文字を削除し、代わりに削除した文字の大文字を挿入する
  5. 最初の一文字目を削除し、代わりに削除した文字の大文字を挿入する
  6. ViewBinding クラスが入力候補として表示されるので、ViewBinding クラスを import する

例外的な原因:tools:viewBindingIgnore="true" を指定したレイアウト

レイアウトファイルのルート要素に tools:viewBindingIgnore="true"(名前空間 tools も定義します)を指定している場合、そのレイアウトファイルに対応した ViewBinding クラスは自動生成されません。 ViewBinding クラスを参照できない場合、tools:viewBindingIgnore="true" が設定されていないか確認します。

その他の原因:そもそも ViewBinding が有効になっていない

Android Studio のバージョンが古すぎる、ViewBinding を有効にする設定が build.gradle に記述されていない、ビルドを実行していない、など、そもそも ViewBinding が有効に動作する環境になっていなければ、ViewBinding を参照できません。 そのような場合、前述の公式サイトを確認して、ViewBinding を動作させる条件を満たしているか確認します。

【問題2】ViewBinding 経由で View を取得できない

ViewBinding クラスを参照することはできても、レイアウトファイルに指定しているはずの View に id 名でアクセスできないことがあります。

よくある原因と解決策 その1

レイアウトファイル上の取得したい View に id が割り振られていないことが原因です。 例えば、見た目(パディング、マージン等)を整形するためだけにレイアウトファイルに定義されている View には id が割り振られていないことが多いです。 そのような View は、View に id を割り振ると ViewBinding から取得できるようになります。

よくある原因と解決策 その2

レイアウトファイルの View タグと class 属性を指定している場合も ViewBinding オブジェクトが id を認識してくれません。 ViewBinding は View タグを認識できないため、クラス名のタグに書き換える必要があります。

<!-- ViewBinding が認識しない例 -->
<View
    android:id="@+id/fuga_fuga"
    class="com.fuga.hoge.view.CustomTextView"
    />

<!-- ViewBinding が認識する例 -->
<com.fuga.hoge.view.CustomTextView
    android:id="@+id/fuga_fuga"
    />

【問題3】include タグに指定したレイアウトファイルの View を ViewBinding 経由で取得できない

レイアウトファイルは、include タグを利用することで、他のレイアウトファイルを include することができます。include したレイアウトファイルの View を、ViewBinding から取得できないことがあります。

主な原因と解決策

include タグに id を指定していないことが原因です。include タグに id を割り振ることで、include したレイアウトファイルの View を取得することができます。

<!-- include 先のレイアウトファイル bar.xml 抜粋 -->
<TextView
    android:id="@+id/bar_text"
    />

<!-- include 元のレイアウトファイル foo.xml 抜粋 -->
<include
    android:id="@+id/include_bar"
    layout="@layout/bar" />
// include 先の View を取得するコード抜粋
val textView = binding.includeBar.barText    // include タグの id 経由で include 先の View を取得できる

なお、tools:viewBindingIgnore="true" を指定したレイアウトファイルを include した場合、include タグに id を設定しようがしまいが、include 先の View にはアクセスできません。 また、include 先のレイアウトファイルの再利用性が乏しければ、include をやめて、include 元のファイルに View の情報を直接記述しても良いと思います。

【問題4】ViewBinding のために View に新しく id を割り振ったら、ViewBinding 以外の View 制御用ライブラリの動作がおかしくなった

ViewBinding を導入する場合、前述の include タグの問題を始め、今まで id を設定していなかった View に対して、新たに id を設定することが多くなります。すると、なぜか ViewBinding 以外の View 制御用ライブラリが誤作動することがあります。原因は、ライブラリごとにまちまちです。

例えば、ViewBinding を動作させるために XML ファイルの include タグに id を新しく割り振ったら、その XML ファイルを ButterKnife が読み込んだ際、include タグによって読み込まれた要素にアクセスできなくなった、という不具合が発生しました。

解決策

様々な解決策が考えられるので、例えば以下のような方法で解決します。 ただし、この後紹介する 継承関係の問題 にも係ることが多いため、必要に応じてそちらも確認した方が良いです。

  • 誤作動したライブラリを利用しないよう View の制御を抜本的に見直す
    • できれば、View の参照処理を ViewBinding に統一した方が良い
  • 誤作動するライブラリと ViewBinding で別々のレイアウトファイルを読み込む
    • 誤作動するライブラリには View に id を割り振らないレイアウト、ViewBinding には View に id を割り振ったレイアウトファイルを読み込ませる/レイアウトは、id の有無以外差分がない

【問題5】継承関係が複雑な View や Layout に ViewBinding を導入しずらい

Google の公式サイトには、Android の様々な構成要素(Activity や Fragment)に対して ViewBinding を導入するサンプルコードが紹介されています。このサンプルコードには、View や Layout のサブクラスで ViewBinding オブジェクトを生成、保持、解放する方法が紹介されています。しかし、実際の View のプロダクトコードは、複雑な継承関係になっていることがあり、サンプルコードの通り、サブクラスに ViewBinding を導入しずらいことがあります。

【問題5−1】サブクラスでレイアウトファイルを指定して、スーパークラスでレイアウトを inflate しているパターン

スーパークラスに inflate するレイアウトファイルの ID を指定するメソッド/プロパティを定義し、サブクラスがそのメソッド/プロパティをオーバーライドします。そして、スーパークラス側で、オーバーライドされたメソッド/プロパティを呼び出して、レイアウトを inflate する、という処理がよくあります。スーパークラスで inflate されたレイアウトは、サブクラス(またはスーパークラス)だけで利用されることもあれば、スーパークラスとサブクラス両方で利用されることもあります。 このようなクラス関係において、常にサブクラス側で ViewBinding オブジェクトを生成、保持しようとすると、複雑な関係になることがあります。

【問題5−1】の解決方法

ViewBinding オブジェクトをサブクラス、スーパークラス、どちらで利用したいか、または両方で使いたいのかによって、解決策が変わってきます。また、根本的な話になりますが、スーパークラスがたいしたことをやっていなければ、余計なスーパークラスを削除して、継承関係の階層を少なくしてしまうことも良い解決策になります。

パターン1:サブクラスだけで ViewBinding を使いたい場合

特に難しいことはなく、サブクラスで ViewBinding を生成、保持して利用すれば良いです。

パターン2:サブクラスとスーパークラス両方で ViewBinding を使いたい場合

ViewBinding 型の戻り値を返すメソッド/プロパティをスーパークラスに定義し、サブクラス側でそのメソッド/プロパティをオーバライドします。

  • ViewBinding を生成するために必要なオブジェクト(inflater 等)をメソッドの引数にしても良い
  • サブクラスでオーバライドメソッド実行中に ViewBinding オブジェクトを生成するが、サブクラスでも ViewBinding を利用する場合は、オブジェクトフィールドに ViewBinding を保持しておく
  • スーパークラスで定義する ViewBinding 型の戻り値として、ViewBinding の総称型を利用すると汎用的なメソッドを定義できる
// パターン2 スーパークラス実装例
import androidx.viewbinding.ViewBinding

abstract class HogeFuga<T: ViewBinding> {
    private lateinit var binding: T
    
    /**
     * ViewBinding を生成して返す
     * 
     * @param inflater レイアウト inflater
     * @return ViewBinding オブジェクト
     */
    abstract fun createViewBinding(inflater: LayoutInflater): T

    /**
     * 適当なコード
     */
    fun onHogeFuga(inflater: LayoutInflater) {
        binding = createViewBinding(inflater)
        
        // 以下略
    }
}

// パターン2 サブクラス実装例
class SubHogeFuga: HogeFuga<BarBazBinding>() {
    private lateinit var binding: BarBazBinding

    override fun createViewBinding(inflater: LayoutInflater): BarBazBinding {
        return BarBazBinding.inflate(...).also {
            binding = it    // 必要に応じてサブクラスでもフィールドに保持する
        }
    }
}
パターン3:原則的にサブクラスで ViewBinding を利用したいが、一部の View 要素だけスーパークラスでも利用したい

例えば、サブクラスのレイアウトファイルのうち、ツールバーだけスーパークラスで制御したい、inflate するルート要素だけスーパークラスが必要としている場合です。

  • スーパークラスに、スーパークラスが必要としている情報をまとめたデータクラスを定義する
    • データクラスを別ファイルにしても良い
  • スーパークラスに、定義したデータクラスを返すメソッド/プロパティを定義する
  • サブクラスは、そのメソッド/プロパティをオーバーライドし、そのメソッド内で、データクラスに必要な情報をセットして、スーパークラスに返す
// パターン3 スーパークラス実装例

// スーパークラスで必要な View 要素だけまとめたデータクラス
data class HogeFugaViewData(
    private val root: View,
    private val toolbar: Toolbar
)

abstract class HogeFuga {
    private lateinit var viewData: HogeFugaViewData

    /**
     * ViewData を生成して返す
     *
     * @param inflater レイアウト inflater
     * @return ViewData オブジェクト
     */
    abstract fun createViewData(inflater: LayoutInflater): HogeFugaViewData

    /**
     * 適当なコード
     */
    fun onHogeFuga(inflater: LayoutInflater) {
        viewData = createViewData(inflater)

        // 以下略
    }
}

// パターン3 サブクラス実装例
class SubHogeFuga: HogeFuga() {
    override fun createViewData(inflater: LayoutInflater): HogeFugaViewData {
        val binding = BarBazBinding.inflate(...)
        return HogeFugaViewData(
            binding.root,
            binding.toolbar
        )
    }
}

【問題5−2】サブクラスに ViewBinding を導入するが、スーパークラスでは他の View 制御用ライブラリを利用しているパターン

スーパークラスが Java でサブクラスが Kotlin、または逆だった場合、Java ファイルと Kotlin ファイルで導入している View 制御用ライブラリが異なることがあります。例えば、Java は ButterKnife、Kotlin は Kotlin Android Extensions を利用しているような場合です。このような環境において、Kotlin Android Extensions の代わりに ViewBinding を導入すると、煩雑なコードになり得ます。

もちろん、まずはスーパークラス、サブクラスともにすべての View 制御用ライブラリを ViewBinding に移行することを考えるべきです。似たような機能を持ったコードが乱立しない分、コードの可読性が向上するからです。

しかし、スーパークラスを継承している既存のサブクラスが何十、何百とあったり、スーパークラス、サブクラス間のやり取りが複雑すぎて解析できなかったりして、すべてのクラスに ViewBinding を導入することが現実的ではないことがありえます。そのような場合、仕方なく ViewBinding と既存の View 制御ライブラリの共存を考えます。

【問題5−2】の解決方法

例えば、以下のような方法で既存ライブラリと ViewBinding を共存させます。

  • スーパークラスによるレイアウトファイルの inflate は ViewBinding ではない方のライブラリで行い、それ以外の処理はすべてサブクラスによる ViewBinding の処理に統一する
  • スーパークラスで ViewBinding でないライブラリでレイアウトファイルを inflate し、スーパークラスで必要な View をスーパークラスの protected なフィールドに保持する/サブクラスではそのフィールドを利用し、サブクラスで固有に必要な View は ViewBinding を利用して取得する/スーパークラスで必要になる View の要素は、サブクラスで ViewBinding 生成後、ViewBinding からセットする

また、スーパークラス、サブクラスのどちらかが非推奨、またはしばらくメンテナンスされていないライブラリを利用している場合、開発工数問わず、ViewBinding へ移行した方が良いです。メンテナンスされていないライブラリは、いつ、何が原因で利用できなくなるかわからないからです。

【問題6】Google の公式サイトに記載されていない View の構成要素に ViewBinding を導入したい

Google の公式サイトには、Activity/Fragment に ViewBinding を導入する方法が紹介されています。 しかし、ViewBinding はカスタムビューやカスタムレイアウト、RecyclerView のアダプタや ViewHolder など、公式サイトに記載されていない View の構成要素にも導入することができます。

カスタムビューやカスタムレイアウトに ViewBinding を導入したい

カスタムビューのコンストラクタで ViewBinding を inflate して attach すれば、ViewBinding の生成元になったレイアウトファイルがカスタムビューに適用されます。

// 実装例
class FooCustomLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
    private val binding = BarViewBinding.inflate(LayoutInflater.from(context), this, true)

    //  以下略
}

RecyclerView の Adapter や ViewHolder に ViewBinding を導入したい

ViewHolder のコンストラクタで、ViewHolder のレイアウトに該当する ViewBinding オブジェクトを受け取り、その rootView をスーパークラスに渡します。

// ViewHolder 実装例
class BarViewHolder(private val binding: BarViewHolderBinding) :
    RecyclerView.ViewHolder(binding.root) {

    // 以下略
}

後は、ViewHolder のレイアウトに該当する ViewBinding オブジェクトを Adapter で inflate して、ViewHolder に渡せば良いです。

// Adapter 側の処理実装例
override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
): RecyclerView.ViewHolder {
    return BarViewHolder(BarViewHolderBinding.inflate(LayoutInflater.from(context), this, false))
}

また、Adapter の onCreateViewHolder は状況によって、return する ViewHolder の型が異なることが多々あります。そうすると、ViewBinding の生成処理にできるだけ汎用性をもたせたくなります。そのような場合、以下の記事を参考にして、ViewBinding 生成用の拡張メソッドを ViewGroup に定義すると良いです。

https://stackoverflow.com/questions/65405721/view-binding-extension-function

// 実装例
inline fun <T : ViewBinding> ViewGroup.viewBinding(
    crossinline bindingInflater: (LayoutInflater, ViewGroup, Boolean) -> T,
    attachToParent: Boolean = false
): T {
    return bindingInflater.invoke(LayoutInflater.from(this.context), this, attachToParent)
}

// 拡張メソッド利用例
override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
): RecyclerView.ViewHolder {
    return when(viewType) {
        BAR_TYPE -> BarViewHolder(parent.viewBinding(BarViewHolderBinding::inflate))
        else -> ErrorViewHolder(parent.viewBinding(ErrorViewHolderBinding::inflate))
    }
}

【問題7】コードから動的に生成している View に対して ViewBinding を利用したい

例えば、ラジオボタンの選択肢が状況に応じて増減する処理を実装する場合、レイアウトファイルにラジオボタンの項目を静的に定義することは難しい(すべての選択肢を静的に定義して、Visible/Gone で制御できるかもしれないですが、要領が悪い)です。このような場合、プログラム上で必要な分だけ View を作成し、動的にレイアウトに追加する処理が実装されることが多いです。動的に生成された View に対して、ViewBinding を適用する方法が分からないことがあります。

解決策

ViewBinding に定義されている bind メソッドの引数に、動的に生成した View を指定すると、戻り値として、動的に生成した View の ViewBinding が返ります。こちらのメソッドを利用します。

val layoutBinding = ... // Layout を定義した ViewBinding オブジェクト/この Layout に子 View を動的に追加する

// childViewInfo に動的に生成する子 View の情報が入力されている
fun hogehoge(childViewInfoList: List<ChildViewInfo>) {
    childViewInfoList.forEach { childViewInfo ->
        val childView = createChildView(childViewInfo)    // 子 View を生成
        val childViewBinding = ChildViewLayoutBinding.bind(childView)    // childView から ViewBinding を生成
        childViewBinding.fugaTextView.text = "..."
        ...

        // レイアウトに追加
        layoutBinding.rootLayout.addView(childView)
    }
}

ViewBinding.bind メソッドの応用例

動的に追加する View はエンジニアが明示的に生成しないことがあります。例えば、View 制御用のライブラリが View を生成する場合などです。また、RecyclerView のように、View をフレームワーク側で管理するものもあります。 これらのライブラリ/フレームワークは View を生成、管理してくれますが、その View に対応する ViewBinding までは生成してくれません。このような場合、ライブラリ/フレームワーク側から処理の対象となる View を受け取ることができれば、ViewBinding.bind メソッドを利用して ViewBinding を生成することができます。

// このメソッドはフレームワークから呼び出され、引数に処理の対象となる View が渡される
fun fugafugaCallback(view: View) {
    val binding = FooViewBinding.bind(view)
    binding.barTextView.text = "..."
}

【問題8】ダイアログに ViewBinding を導入したらレイアウトが崩れた

DialogFragment 継承クラスにおいて、生成されたダイアログオブジェクトに対して、ViewBinding によるレイアウトファイルを適用すると、ダイアログのレイアウトが崩れることがあります。特に、レイアウトファイルのルート要素に指定した width 属性の値が有効に機能しないことが多いです。

// レイアウトが崩れることがある事例
class FugaDialogFragment : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = super.onCreateDialog(savedInstanceState)
        val binding = ... // ダイアログのレイアウトに対応する ViewBinding オブジェクトを生成する
        dialog.setContentView(binding.root)
        return dialog
    }
}

解決策

原因がわからなかったため、従来のレイアウトファイルの ID を setContentView に指定する方法と、前述の bind メソッドを組み合わせた方法で対応しました。ViewBinding と findViewById による処理が混在しますが、例外的なパターンとしてコメントを記載して対応するようにしました。

// レイアウトが崩れない事例
class FugaDialogFragment : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = super.onCreateDialog(savedInstanceState)
        dialog.setContentView(R.layout.fuga_dialog_layout)

        // レイアウトファイルのルート要素に android:id="@+id/dialog_root" を割り振っておく
        // このように対応しないとレイアウト崩れが発生するため、ViewBinding と findViewById が混在している
        val binding = FugaDialogLayoutBinding.bind(dialog.findViewById(R.id.dialog_root))
        ...
        return dialog
    }
}

【問題9】View を生成するクラスと View のイベントをハンドリングしているクラスが異なっている

そもそものクラス設計が間違っている可能性が高いのですが、View を生成する処理と、その View に対するイベントハンドリングが別々のクラスに定義されていることがあります。

解決策

根本的に設計が間違っている可能性があるため、なぜそのようなクラス設計になっているのか事情を確認しなければなりません。特段深い理由がないようなら、View を生成する処理をイベントハンドリングするクラスに移行(または、イベントハンドリングを View を生成する処理に移行)して、単一のクラスで View の生成からイベントハンドリングの設定まで実施するようにした方が良いです。 如何ともし難い理由があって、クラスが分離されている場合、引数等で binding オブジェクトを渡すことになります。

参考資料

おわりに

いかがだったでしょうか?
Android 版食べログアプリは現時点において、Java/kotlin ファイルだけで 48万行以上ある巨大なアプリです。48万行のコードすべての可読性が高い・・・とは言えないため、食べログアプリの保守対応チームは日々、コードや運用性の改善に努めています。

今回は、ViewBinding の導入についてお話しましたが、他にも様々な改善に取り組んでいますので、今後、私達の改善策を記事にしてゆこうと考えています。

明日は @gggk さんの「検索データを活用したレストラン検索の改善について」です。お楽しみに!