Edited at

もうちょっと詳しくなるKotlin Android ExtensionsでのView Binding


はじめに

こんにちは。この記事は、Android #2 Advent Calendar 2018 の10日目の記事です。

みなさんは、Kotlin Android ExtensionsのView Binding機能、使ったことがあるでしょうか。この記事では、代表的な使い方から始まり、どのような仕組みで動いているかや、ActivityFragment 以外から使う方法、Data BindingButter Knifeと比べての特徴など、Kotlin Android Extensionsについてもう少し詳しくなるための色々を紹介します。


Kotlin Android Extensionsとは

Kotlin Android Extensionsは、KotlinでのAndroid開発体験の向上を目的に提供されているKotlinコンパイラプラグインです。2018年12月現在、以下の2つの機能を持っています。


  • View Binding


  • Parcelable 実装の自動生成

この記事では、1つ目のView Bindingについて説明します。2つ目の Parcelable について気になる方は、Kotlin Android ExtensionsのParcelableSupportを使うなどを参照してください。


Kotlin Android Extensionsのセットアップ

Kotlin Android Extensionsは、Gradleスクリプトにおいて kotlin-android-extensions プラグインを適用すると使うことができます。Android Studioで、Kotlinを有効にしてアプリプロジェクトを作成すると、実はデフォルトで有効になっています。


app/build.gradle

apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions' // <- これ

上記したView Bindingや Parcelable サポートの機能を使わないのであれば、不要なプラグインなので、逆に削除したほうがいいでしょう。


View Bindingで何ができるのか

View Binding機能は、findViewById() を使わずにActivityやFragmentのプロパティとしてViewのインスタンスを取得できる機能です。例えば、次のようなViewがレイアウトXMLにあった場合に


activity_main.xml

<TextView

android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

ActivityやFragmentに、View IDと同じ text_view という名前のプロパティが生成され、findViewById() を使わなくてもViewのインスタンスにアクセスすることができるようになります。ここで、text_viewTextView 型のプロパティになっているので、キャストせずに TextView として使うことができます。


MainActivity.kt

import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// val text_view: TextView というプロパティが存在するかのように使える
text_view.text = "Hello World!"
}
}



どのような仕組みになっているのか

魔法のようなKotlin Android Extensionsですが、仕組みはいたって簡単です。MainActivity のバイトコードをデコンパイルすると、すぐにわかります。


MainActivity.decompiled.java

public final class MainActivity extends AppCompatActivity {

private HashMap _$_findViewCache;

protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(2131296284);
TextView var10000 = (TextView)this._$_findCachedViewById(id.text_view);
Intrinsics.checkExpressionValueIsNotNull(var10000, "text_view");
var10000.setText((CharSequence)"Hello World!");
}

public View _$_findCachedViewById(int var1) {
if (this._$_findViewCache == null) {
this._$_findViewCache = new HashMap();
}

View var2 = (View)this._$_findViewCache.get(var1);
if (var2 == null) {
var2 = this.findViewById(var1);
this._$_findViewCache.put(var1, var2);
}

return var2;
}

public void _$_clearFindViewByIdCache() {
if (this._$_findViewCache != null) {
this._$_findViewCache.clear();
}

}
}


自動生成されている _$_findCachedViewById() というメソッドが、Viewのインスタンスを取得している処理です。_$_findViewCache フィールドに持っている HashMap をキャッシュとして使いつつ、findViewById() しているだけ、ということがわかると思います。


MainActivity.decompiled.java

   private HashMap _$_findViewCache;

public View _$_findCachedViewById(int var1) {
if (this._$_findViewCache == null) {
this._$_findViewCache = new HashMap();
}

View var2 = (View)this._$_findViewCache.get(var1);
if (var2 == null) {
var2 = this.findViewById(var1);
this._$_findViewCache.put(var1, var2);
}

return var2;
}


onCreate() 内で text_view プロパティにアクセスしていた箇所は、_$_findCachedViewById(id.text_view) を呼び出して TextView にキャストする処理に変換されています。


MainActivity.decompiled.java

      TextView var10000 = (TextView)this._$_findCachedViewById(id.text_view);


つまり、Kotlin Android Extensionsは、View ID名でのプロパティアクセスを、findViewById() + HashMap によるキャッシュ + キャストという処理に自動変換してくれる仕組みです。


キャッシュに関するプラグインオプション

上記の例では、HashMap がViewのキャッシュに使われていますが、プラグインのオプションを変更して、SparseArray を使うように指定したり、キャッシュをさせないようにすることも可能です。


app/build.gradle

androidExtensions {

defaultCacheImplementation = "SPARSE_ARRAY" // HASH_MAP (default), SPARSE_ARRAY, or NONE
}

また、Kotlin Android ExtensionsをExperimentalモードにすると、@ContainerOptions アノテーションを使って、クラスごとに設定を変えることもできます。


MainActivity.kt

@ContainerOptions(cache = CacheImplementation.SPARSE_ARRAY)

class MainActivity : AppCompatActivity() {


どこでView Bindingの生成プロパティが使えるのか

自動生成されるプロパティは、以下のクラスとその派生クラスで利用することができます (プラグインのソースコードにリストが定義されています)。


  • Activity


    • android.app.Activity

    • android.support.v4.app.FragmentActivity

    • androidx.fragment.app.FragmentActivity



  • Fragment


    • android.app.Fragment

    • android.support.v4.app.Fragment

    • androidx.fragment.app.Fragment



  • Dialog


    • android.app.Dialog



  • View


    • ndroid.view.View



  • LayoutContainer



    • kotlinx.android.extensions.LayoutContainer (後述)



Activity, Fragment, View, LayoutContainerのKotlin派生クラスであれば、プロパティアクセス時に、前節で説明した HashMap のキャッシュの仕組みが使われます。Dialogや、Javaで実装されたクラスの場合は、キャッシュの仕組みは差し込まれず、プロパティにアクセスすると、単に findViewById() が呼び出されます。

Kotlin Android Extensionsを使うようなアプリプロジェクトでは、ActivityやFragmentは基本的にKotlinで実装した派生クラスだと思いますので、ActivityやFragmentのプロパティとしてアクセスする分には、全てキャッシュがきくと考えて問題ありません。

一方、Viewは、Kotlinで実装したカスタムViewであればキャッシュの仕組みが差し込まれるのですが、AndoridプラットフォームやAndroidXライブラリ (サポートライブラリ) のViewの場合は、コンパイル対象でなかったりJava実装だったりするので、キャッシュがききません。

findViewById() は時間のかかる処理なので、複数回アクセスする可能性のあるViewに対してはキャッシュがきく方法でインスタンスを取得するか、一度取得したインスタンスを自分で保持しておくことが望ましいです。


プロパティが生成されるパッケージ

Viewにアクセスするためのプロパティは、次のパッケージに生成されます:


  • Activity, Fragment, Dialog, LayoutContainer: kotlinx.android.synthetic.<ソースセット名>.<レイアウト名>

  • View: kotlinx.android.synthetic.<ソースセット名>.<レイアウト名>.view

生成されたプロパティはAndroid Studioの補完の候補となり、補完を使うとインポート文も自動的に挿入されるので、基本的にはインポートについて意識を配る必要はありません。ただ、複数のレイアウトXMLで同じView IDを使っている場合、補完時に間違った候補を選択して、意図と異なるインポート文を挿入してしまう可能性があるので、インポートしているパッケージがあっているか注意する必要があります。


RecyclerView (ViewHolder) でのKotlin Android Extensionsの活用方法

Activity, Fragment, Dialog, View以外のクラスでも、LayoutContainer を実装したクラスであれば、Android Kotlin Extensionsが生成するプロパティをキャッシュつきで利用することができます。これは特に、RecyclerView.ViewHolder を実装するときに役にたちます。

例えば、各アイテムが、次のように ImageViewTextView を持つ RecyclerView を実装することを考えてみましょう。


list_item.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="?attr/listPreferredItemHeight"
android:orientation="horizontal">

<ImageView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp" />

<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp" />

</LinearLayout>


Kotlin Android Extensionsを使わない場合、ViewHolderAdapter.onBindViewHolder() の実装は次のようになるでしょうか。


MyViewHolder.kt

class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

val icon: ImageView = itemView.findViewById(R.id.icon)
val title: TextView = itemView.findViewById(R.id.title)
}


MyAdapter.kt

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {

val item = ...
holder.icon.setImageResource(item.icon)
holder.title.text = item.title
}


ダメな例

まずはダメな例です。次のように、MyViewHolder のプロパティを削除して、代わりに、Kotlin Android Extensionsが ViewHolder.itemView 対して生成するプロパティを使うことを考えてみます。


MyViewHolder.kt

class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)



MyAdapter.kt

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {

val item = ...
holder.itemView.icon.setImageResource(item.icon)
holder.itemView.title.text = item.title
}

一見すると、Kotlin Android Extensionsの機能で MyViewHolder の定義がスッキリしており、よさそうに見えます。ただ、前節で説明したように、View に対して生成されるプロパティを使うとキャッシュが利用されず、プロパティにアクセスするたびに findViewById() が呼び出されてしまいます。これでは、ViewHolder というクラスが用意されている意味がありません。


惜しい例

毎回、findViewById() が呼び出されてしまうのがダメなのであれば、次のように、ViewHolderの生成時にViewのインスタンスを取得して、保持しておけばよさそうです。


MyViewHolder.kt

class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

val icon = itemView.icon
val title = itemView.title
}


MyAdapter.kt

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {

val item = ...
holder.icon.setImageResource(item.icon)
holder.title.text = item.title
}

これなら、各Viewに対して findViewById() は一度ずつしか呼ばれておらず、問題なさそうです。Kotlin Android Extensionsを使わない場合の例と比較すると、MyViewHolder の実装において、findViewById() 呼び出しが itemView.xxx に差し替わっている点が差分であり、コードを少しスッキリできたでしょうか。ただ、MyViewHolder にViewのプロパティを列挙しなければならないことは変わっておらず、ActivityやFragmentが享受しているメリットと比べると、少し残念な感じです。


LayoutContainerを使う例

そこで、LayoutContainer の登場です。LayouContainer は、Kotlin Android ExtensionsをExperimentalモードにすると利用することができます。Experimentalとついていますが、一年以上前からExperimentalなのと、非常に単純なインターフェースなので、この後、破壊的な変更が入る可能性は低いと考えています。


app/build.gradle

androidExtensions {

experimental = true
}

LayoutContainer を使うと、Viewのプロパティを明示的に列挙する必要がなくなり、MyViewHolder の実装を次のように簡略化することができます。


MyViewHolder.kt

class MyViewHolder(override val containerView: View) : RecyclerView.ViewHolder(containerView), LayoutContainer


先述したように、LayoutContainer を実装したクラスでは、Kotlin Android Extensionsが生成するプロパティ (キャッシュつき) が利用可能です。そのため、MyViewHolder にViewのプロパティを列挙しなくても、icontitle といったプロパティを使うことができます。


MyAdapter.kt

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {

val item = ...
holder.icon.setImageResource(item.icon)
holder.title.text = item.title
}

なお、この例で、MyViewHolder のコンストラクタの引数が少し変わっていることに気が付いたでしょうか? LayoutContainer は次のような定義を持っており、検索対象としたいViewを含む親Viewを containerView プロパティから返す必要があります。生成プロパティにアクセスされると、containerView に対して findViewById() が呼び出され、指定されたView IDを持つViewを探索する仕組みになっています。


LayoutContainer.kt

public interface LayoutContainer {

public val containerView: View?
}

MyViewHolder では、コンストラクタでこのプロパティを実装しており、コンストラクタに渡されたView (= itemView) を containerView として使うようにしています。ViewHolder ではほぼ100%、このパターンでの実装になると思います。


Kotlin Android Extensionsの特徴と使い所

以上が、Kotlin Android ExtensionsのView Binding機能の説明です。最後に、機能が重複しているData BindingButter Knifeと比較しての、Kotlin Android Extensionsの特徴や使い所をまとめます。


Viewのインスタンス取得に特化している

ここまで見てきたように、Kotlin Android ExtensionsのView Binding機能は、あくまで findViewById() を簡単にする仕組みです。Data BindingのようにViewのプロパティのバインドはできませんし、Butter Knifeにあるような OnClickListener などのリスナーのバインドもできません。Viewのインスタンス取得に特化していると言えます。


追加コードが不要

LayoutContainer が必要なケースは別ですが、ActivityやFragmentから使うケースであれば、追加コードは一切必要なく、これまで通り Activity.setContentView()Fragment.onCreateView() をしただけで、XMLに定義されているViewが全てIDでアクセスできるようになります。Data Bindingのようにレイアウト展開時に ViewDataBinding の派生クラスを生成したり、Butter KnifeのようにViewを保持するプロパティを自分で定義したりする必要がなく、手軽です。


コンパイラプラグインによる開発者体験

Kotlin Android ExtensionsはKotlinコンパイラプラグインであり、IntelliJプラグイン、Gradleプラグイン、コード生成器がセットで提供されているソリューションです。以下のように、Android Studioにおける開発者体験がとてもいいです。


  • レイアウトXMLを変更すると、アプリを再ビルドしなくても、すぐに変更後のXMLの内容でKotlinコードの補完がきくようになる
      Dec-09-2018 23-45-37.gif

  • レイアウトXMLにおいて、View IDの名前をリファクタ機能で変更すると、同名の生成プロパティを使っていたKotlinコードもあわせて変更される
      Dec-09-2018 23-52-33.gif

  • Kotlinコードの生成プロパティを使っている箇所から、レイアウトXMLにジャンプできる
      Dec-09-2018 23-50-29.gif

Data Bindingは、レイアウトXMLからJavaクラスを生成し、生成されたクラスをアプリコードから使う形になるためか、上記のことはいずれもできません。機能の規模が異なるツールなので単純に比較はできませんが、開発者体験という意味では、Kotlin Android Extensionsのほうが気持ちいいです。


使い所


  • Kotlin Android Extensionsは小さく優れたツールです。Butter KnifeやData BindingをViewのインスタンス取得のためだけに用いているなら、Kotlin Android Extensionsに移行するのは良い選択肢になると思います

  • Data Bindingの機能が必要な場合は、Data Bindingを使わざるをえません。その場合、Viewインスタンス取得もData Bindingでことが足りるので、Kotlin Android Extensionsを使う必要はなさそうです

  • 小規模なサンプルアプリなど、単純なUIでコードのシンプルさを優先したい場合や、短期間で素早くアプリを開発する必要がある場合は、Kotlin Android Extensionsが役にたつと思います


参考: Jakeさんの意見

Butter Knifeの作者でもあるJake Whartonさんは、Kotlin Android Extensionsは言語機能を乱用している、Data Bindingのコード生成が広い点で優れている、という意見のようです。Data Bindingのアプローチのほうが基本的に優れている、というのは分かりますが、"no gain" は若干言い過ぎな気がします...

https://twitter.com/JakeWharton/status/1038085579622305793


They're super weird. Abuse of the language for no gain. Databinding's codegen is vastly superior but sadly it's tied to databinding.