【Android】Android の Data Binding でできること(基本編)

2018/12/01 追記

  • シンタックスハイライトを適用しました。
  • こんな場末の技術ブログを見るぐらいなら公式のドキュメントをちゃんと読んだほうがいいと思います。
    • 記事公開当時は大した情報が記載されていませんでしたが、今はかなり充実しています。

前書き

最近(と言っても 1 ~ 2 ヶ月前の話ですが)、訳あって携帯を機種変更し、Android 5.1.1 の端末を手に入れました。

いい機会なので開発環境も一新し、Eclipse + ADT とか言うファッキンな環境を捨てて Android Studio + Gradle + Kotlin に切り替えました。

IntelliJ の Lombok プラグイン@ExtensionMethodに対応していれば Kotlin は恐らく触りもしなかったんですが、Scala をちょっと触っていたこともあってか、中々快適に開発できています。普段の業務もこれで書きたいぐらいです。

Kotlin の話はいずれするかもしれないし、しないかもしれませんが、今回は Android Studio 1.3 からサポートされ始めたData Bindingの話をします。

Data Binding とは?

レイアウト用の XML/res/layout/内のやつ)にデータを定義することで、そのデータのプロパティに直接アクセスできるようになりました。

以下のコードを見ればなんとなくわかると思います。(コードはドキュメントのサンプルそのままです。)

public class User {
   private final String firstName;
   private final String lastName;

   public User(String firstName, String lastName) {
       this.firstName = firstName;
       this.lastName = lastName;
   }
   public String getFirstName() {
       return this.firstName;
   }
   public String getLastName() {
       return this.lastName;
   }
}
<!--?xml version="1.0" encoding="utf-8"?-->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
       <variable name="user" type="com.example.User"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <TextView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.firstName}"/>
       <TextView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.lastName}"/>
   </LinearLayout>
</layout>

なんか別の言語で GUI のアプリを作るときも似たようなやつを必死に書いた気がするんですが、きっと気のせいでしょう。と言うか、XAML よりもシンプル(当社比)でいい感じです。Java だとわざわざ getter を作らなきゃいけないからそこで帳消しですよ。

実際にデータをセットする時はこんな感じに呼び出します。

main_activity.xmlであれば、MainActivityBindingと言うクラスが自動で生成されます。

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity);
       User user = new User("Test", "User");
       binding.setUser(user);
    }
}

プラグインの導入

Android Plugin for Gradle の1.5.0-alpha1からプラグインが統合されたため、基本的には module レベルのbuild.gradledataBinding { enabled = true }を追加するだけで OK です。

android {
    // ...
    dataBinding {
        enabled = true
    }
}

何らかの事情で古いものを使っている人は別途プラグインを入れる必要があります。まずは root のbuild.gradle内のdependenciesに DataBinder を追加します。

最新版がどれかわからない、って人はここにあるので適当に見繕ってきてください。

buildscript {
    repositories {
        jcenter()
        mavenCentral()
    }
    dependencies {
        // ...
        classpath "com.android.databinding:dataBinder:1.0-rc4"
    }
}

次に module レベルでのbuild.gradleに設定を入れていきます。と言っても、apply plugin: 'com.android.databinding'を突っ込むだけです。

apply plugin: 'com.android.databinding'

android {
    // ...

データオブジェクト

プロパティの定義

Java のフィールドをプロパティとして呼び出すためには、以下の二条件のうちどちらか一つを満たしていれば OK です。

  • public なフィールドである
  • getter が定義されている

具体的にはこんな感じですね。

public class User {
   public final String firstName;
   public final String lastName;
   public User(String firstName, String lastName) {
       this.firstName = firstName;
       this.lastName = lastName;
   }
}
public class User {
   private final String firstName;
   private final String lastName;

   public User(String firstName, String lastName) {
       this.firstName = firstName;
       this.lastName = lastName;
   }
   public String getFirstName() {
       return this.firstName;
   }
   public String getLastName() {
       return this.lastName;
   }
}

データ変更の検知

上記の例だとデータを変更してもViewの表示は変わりません。

@Override
protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity);
   User user = new User("Test", "User");
   binding.setUser(user);
   // firstNameの値を変更してもTextViewの表示は変わらない
   user.setFirstName("Hoge")
}

データが変わったら自動でViewの表示も変わって欲しい人もいると思います。そんな人のためにいくつかの手段が用意されています。

まずはObservableFieldsを使用する例です。

public class User {
   private final ObservableField

<string> firstName = new ObservableField<string>();
   private final ObservableField<string> lastName = new ObservableField<string>();

   public User(String firstName, String lastName) {
       this.firstName.set(firstName);
       this.lastName.set(lastName);
   }
   public ObservableField<string> getFirstName() {
       return this.firstName;
   }
   public ObservableField<string> getLastName() {
       return this.lastName;
   }

}
@Override
protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity);
   User user = new User("Test", "User");
   binding.setUser(user);
   // firstNameの値を変更するとTextViewの表示も変わる
   user.getFirstName.set("Hoge");
}

うーん、このダサさ。ちなみにプリミティブ型のためにObservableBooleanとかObservableIntなんてのがそれぞれも用意されてます。一秒でも早く Java が絶滅してくれないかなと思ったことでしょう。

他にもObservable なMapListの実装方法なんかもありますが、「XML実体参照を書かないといけないの、流石にダサすぎでは?」ぐらいしか言うことないので、必要なら読んどいてください。

もうちょっとスマートな方法としてBaseObservableを継承する方法があります。

public class User extends BaseObservable {
   private final String firstName;
   private final String lastName;

   public User(String firstName, String lastName) {
       this.firstName = firstName;
       this.lastName = lastName;
   }

   @Bindable // 変更を通知したいプロパティには@Bindbleをつける
   public String getFirstName() {
       return this.firstName;
   }

   @Bindable
   public String getLastName() {
       return this.lastName;
   }

   public void setFirstName(String firstName) {
       this.firstName = firstName;
       // @Bindableがついているプロパティは
       // BR.プロパティ名と言うフィールドが自動で生成される
       notifyPropertyChanged(BR.firstName);
   }
   public void setLastName(String lastName) {
       this.lastName = lastName;
       notifyPropertyChanged(BR.lastName);
   }
}
@Override
protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity);
   User user = new User("Test", "User");
   binding.setUser(user);
   // firstNameの値を変更するとTextViewの表示も変わる
   user.setFirstName("Hoge");
}

implementsじゃなくてextendsなのがキツいところですね。

本当はBaseObservableではなくObservableを実装すればいいみたいなんですが、当然通知の仕組みは自力で実装する必要があります。馬鹿にしていらっしゃるのかな?(敬語)

でもまぁ、ソースを読む限り大した実装ではないので、必要なら自力で実装しちゃってもいいかもです。

また、どうしても setter に追記が必要なので、lombok を使っている人からすると二倍しんどいです。勘弁して欲しいですね。

イベントのバインド

android:onClickのように何らかの Listener を設定できる場所にはイベントをバインドすることができます。

public class User {
   public final String firstName;
   public final String lastName;
   public User(String firstName, String lastName) {
       this.firstName = firstName;
       this.lastName = lastName;
   }

   public void showFullName(View v) {
       Toast.makeText(v.getContext(), this.firstName + " " + this.lastName, Toast.LENGTH_SHORT).show();
   }
}
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
       <variable name="user" type="com.example.User"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <TextView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.firstName}"/>
       <TextView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.lastName}"/>
       <Button android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="show fullname"
           android:onclick=@{user.showFullName} />
   </LinearLayout>
</layout>

また、本来なら定義されていないはずのandroid:onLongClickと言う attr にイベントをバインドすることもできます。理由は後述。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
       <variable name="user" type="com.example.User"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <TextView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.firstName}"/>
       <TextView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.lastName}"/>
       <Button android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="show fullname"
           android:onLongClick=@{user.showFullName} /> <!-- android:onLongClick -->
   </LinearLayout>
</layout>

@BindingMethod@BindingAdapter

独自のイベントを設定するには@BindingMethod@BindingAdapterと言う黒魔術アノテーションを駆使する必要があります。

と言うか、先ほどの例で挙げたandroid:onclickandroid:onLongClick@BindingMethodでやってるだけです。この 2 つのアノテーションは既存のViewだろうがなんだろうが attr を拡張することができます。

公式ドキュメントを読むと@BindingMethodsetter をリネームしたい時@BindingAdapter独自の setter を定義したい時に使うといいよと紹介されています。

つまり、目的のViewが既に対応するようなメソッドを持っている時は@BindingMethodを、完全に自分で定義(拡張)したい時は@BindingAdapterを使ってね、ぐらいのイメージでしょうか。

@BindingMethodの使用例

せっかくだから先ほどの例がどのように定義されているのか実際のソースを見てみましょう。

と言っても関係あるとこだけしか抜き出しませんが。長いしね。

@BindingMethods({
        @BindingMethod(type = View.class, attribute = "android:onClick", method = "setOnClickListener"),
        @BindingMethod(type = View.class, attribute = "android:onLongClick", method = "setOnLongClickListener"),
})
public class ViewBindingAdapter {
  ...

上記の記述により、Viewを継承しているクラスのandroid:onClickに何らかのメソッドがバインドされた場合、View.setOnClickListenerへ処理を移譲することができます。中々クールですね。

ちなみに@BindingMethods@Target({ElementType.TYPE})で定義されていますが、多分、本当はどこでもよかったんでしょうね…。

@BindingAdapterの使用例

例えば、ImageViewに対してGlideをつかって画像をセットしたい時とかあると思います。

(公式ドキュメントはなぜかPicassoを例にしていますが、あえて競合他社のライブラリを例にした理由は謎です。)

面倒なので Model は超単純にしましょう。

public class Model {
    public final String imageUrl;
    public Model(String imageUrl) {
        this.imageUrl = imageUrl;
    }
}

こんな形でバインドできたらありがたいです。名前空間androidじゃなくてres-autoなので注意しましょう。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto">
   <data>
       <variable name="m" type="com.example.Model"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <ImageView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:imageUrl="@{m.imageUrl}" />
   </LinearLayout>
</layout>

わざわざImageViewを継承して独自のattrを作らずともBindingAdapterを使うことで簡単に実現できます。

public final class ImageViewExtensions {
    @BindingAdapter({"bind:imageUrl"})
    public static void loadImage(ImageView v, String imageUrl) {
        Glide.with(v.getContext()).load(imageUrl).into(v);
    }
}

「画像を読み込めたらそれを表示してほしいけど、失敗したらdrawableの画像を表示したい」なんて要件も当然あると思います。

そんな時は@BindingAdapterに複数の引数を渡せば OK です。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto">
   <data>
       <variable name="m" type="com.example.Model"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <ImageView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:imageUrl="@{m.imageUrl}"
           app:error="@{@drawable/error}" />
   </LinearLayout>
</layout>
public final class ImageViewExtensions {
    @BindingAdapter({"bind:imageUrl", "bind:error"})
    public static void loadImage(ImageView v, String imageUrl, Drawable error) {
        Glide.with(v.getContext()).load(imageUrl).error(error).into(v);
    }
}

ただし、上記の場合だと必ずapp:imageUrlapp:errorの両者を指定する必要があります。

app:errorを指定しないこともあるかも…という場合は、@BindingAdapterを指定するメソッドを 2 つ用意するしかありません。(ダサい)

public final class ImageViewExtensions {
    @BindingAdapter({"bind:imageUrl"})
    public static void loadImage(ImageView v, String imageUrl) {
        Glide.with(v.getContext()).load(imageUrl).into(v);
    }
    @BindingAdapter({"bind:imageUrl", "bind:error"})
    public static void loadImage(ImageView v, String imageUrl, Drawable error) {
        Glide.with(v.getContext()).load(imageUrl).error(error).into(v);
    }
}

レイアウト XML の細かい文法

前述した内容だけでもある程度使えますが、レイアウト XML でもっと色んなことができるようになってるのでガンガン活用しましょう。

演算子

ちょっとした計算や bool 値の判定なんかもレイアウト XML 内でできるようになりました。

具体的に何ができるかは公式ドキュメントを読んでください。5 分もあれば読めますよ。

以下、基本的なコード例です。公式のやつそのまんまですが。

android:text="@{String.valueOf(index + 1)}"
android:visibility="@{age < 13 ? android.view.View.GONE : android.view.View.VISIBLE}"
android:transitionName='@{"image_" + id}'

View#VISIBLEView#GONEの切り替えなんかは相当楽になりますね。

また、(pure な)Java にはまだないNull 合体演算子がサポートされています。

android:text="@{user.displayName ?? user.lastName}"
<!-- android:text="@{user.displayName != null ? user.displayName : user.lastName}" と同義 -->

Import

<data>内に Import 文を書くことが可能です。

で?と思うかもしれませんが、Android SDKフレームワーク内のあれこれを使うときとかに便利です。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
       <import type="android.view.View"/>
       <variable name="user" type="com.example.User"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <TextView android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.firstName}"
           android:visibility="@{user.isAdult ? View.VISIBLE : View.GONE}"/>
   </LinearLayout>
</layout>

package は違うけど同名のクラスを使う場合はaliasを設定できます。

<import type="android.view.View"/>
<import type="com.example.real.estate.View"
        alias="Vista"/>

バインディングクラスのパッケージ / 名称変更

バインディングクラスってなんじゃい、って話ですが、例えばmain_activity.xmlであれば、MainActivityBindingと言うクラスが自動で生成されます。このMainActivityBindingバインディングクラスです。

もし root パッケージがcom.example.my.appだとしたら、MainActivityBindingcom.example.my.app.databindingに配置されます。

何らかの理由があってこのデフォルトで生成されるクラスの名前や、生成されるパッケージを変更したい場合はdata要素の attribute としてclassを指定してあげれば OK です。

クラス名を変えるだけならこんな感じです。

<data class="MainActivityViewHolder">
    ...
</data>

パッケージも変更したいのであれば、フルパスで指定してあげましょう。

<data class="com.example.MainActivityViewHolder">
    ...
</data>

root パッケージ直下に置きたい場合は先頭に.をつけてあげれば OK です。

<data class=".MainActivityViewHolder">
    ...
</data>

Include 先へのバインディング

Include したいレイアウトファイルにも要素をバインドできます。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:bind="http://schemas.android.com/apk/res-auto">
   <data>
       <variable name="user" type="com.example.User"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <include layout="@layout/name"
           bind:user="@{user}"/>
       <include layout="@layout/contact"
           bind:user="@{user}"/>
   </LinearLayout>
</layout>

当然と言えば当然ですが、Include 先のレイアウトファイルでもちゃんとバインディングの設定をしておく必要があります。

上記の例で言えば、name.xmlcontact.xml内でuserと言う variable がなければ叱られます。

また、<merge>要素直下の include に対してはバインドできないそうなので気をつけましょうね。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:bind="http://schemas.android.com/apk/res-auto">
   <data>
       <variable name="user" type="com.example.User"/>
   </data>
   <!-- merge直下のincludeなのでこれはダメ -->
   <merge>
       <include layout="@layout/name"
           bind:user="@{user}"/>
       <include layout="@layout/contact"
           bind:user="@{user}"/>
   </merge>
</layout>

暗黙の型変換

これまた黒魔術っぽいんですが…。本来なら適切でない型がバインドされても、@BindingConversionが指定されているメソッドに移譲して型変換することができます。

ちょっと何言ってるかわかんないですよね。例えばこんな感じです。

<View
   android:background="@{isError ? @color/red : @color/white}"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"/>

View.setBackgroundDrawableしか受け付けないんですが、colorのリソース ID(int)を渡したいとします。

当然このままでは怒られるので、ColorDrawableに変換する static メソッドを作り、@BindingConversionを指定します。

@BindingConversion
public static ColorDrawable convertColorToDrawable(int color) {
   return new ColorDrawable(color);
}

これでandroid:backgroundintが渡されたとしてもColorDrawableに変換してくれます。

何も知らないと非常に混乱しそうなので、用量、用法を守ってお使いください…。と言うか、@BindingAdapterと違って適用する attr を制限できないっぽいのでめちゃめちゃ危険です。使わない方がいいかもしれません。

ちなみに、Drawableintのどちらかが返されるみたいなパターンは普通にダメです。

<View
   android:background="@{isError ? @drawable/error : @color/white}"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"/>

とは言え、static メソッドは普通に呼べるのでそっちで回避できます。

<View
   android:background="@{isError ? @drawable/error : ColorExtensions.convertColorToDrawable(@color/white)}"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"/>

…あれ?じゃあほんとにこれいらなくね?

まとめ

久しぶりに濃厚な記事になってしまいました。

基本編と銘打ってはいますが、応用編を書く予定とネタは今のところありません。

自分でも色々いじってみたテスト用プロジェクトをGitHubにおいておきました。それぞれ、こんな感じです。

  1. Data Binding の導入
  2. とりあえず適当にバインディングしてみる
  3. Observable なプロパティとイベントのバインディング
  4. BaseObservableを継承したパターン
  5. @MethodAdapterで遊んでみる

参考

ぶっちゃけ公式ドキュメントが一番わかりやすかったです。