Android Data Binding + MVVMパターンのサンプルを書いてみた

notifyPropertyChanged とか、どこかで見たことのある機能が満載の Android Data Binding ですが、登場以来あまり追えてなかったのでやっとサンプルをつくってみました。

といっても

で作ったストップウォッチアプリを Data Binding 化しただけです。

前回 との違いを図に示します。

rxjava mvvm stopwatch 03

  • View-ViewModel で全面的に使用していた rx.Observable<T> の代わりに、ObservableField<T> を使用。
  • View側で「オレオレDataBinding」を実装していた箇所を、Android の Data Binding に置き換え。つまりバインディングの定義はレイアウトxmlへ記述。
  • Model は相変わらず rx.Observable<T> のまま。なので ViewModel で rx.Observable<T>ObservableField<T> へ変換。
  • メソッドとのバインドに Command を使用していたが、Android Data Binding の Binding Events に置き換え。
  • ListView とデータ群のバインディングの方法が分からなかったので、カスタムBindingで対応。(listItem のバインディングじゃなくて、リストの件数の増減を反映させるやつ。)
  • ArrayAdapter 使ってたんだけどこいつは Binding に対応していない?ので Adapter を自作。

MainActivity のバインディングの定義

activity_main.xml はこんな感じ。

@{ }MainViewModel に用意した ObservableField<T> または、イベントハンドラとバインドしてます。

<!--activity_main.xml-->
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable name="viewModel"
            type="com.amay077.stopwatchapp.viewmodel.MainViewModel"/>
    </data>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin"
        android:orientation="vertical"
        android:paddingRight="@dimen/activity_horizontal_margin"
        android:paddingTop="@dimen/activity_vertical_margin"
        android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity">

        <TextView android:id="@+id/textTime"
            tools:text="00:00.000"
            android:text="@{viewModel.formattedTime}"
            android:textSize="50sp"
            android:gravity="center_horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <Button
            android:id="@+id/buttonStartStop"
            android:text="@{viewModel.runButtonTitle}"
            android:onClick="@{viewModel.onClickStartOrStop}"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
        <Button
            android:id="@+id/buttonLap"
            android:text="Lap"
            android:enabled="@{viewModel.isRunning}"
            android:onClick="@{viewModel.onClickLap}"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
        <Switch
            android:id="@+id/switchVisibleMillis"
            android:checked="@{viewModel.isVisibleMillis}"
            android:onClick="@{viewModel.onClickToggleVisibleMillis}"
            android:text="小数点以下を表示"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <ListView
            android:id="@+id/listLaps"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:formattedLaps="@{viewModel}" />
    </LinearLayout>
</layout>

ListView で app:formattedLaps="@{viewModel}" としているところだけが特殊で、これは MainActivity.java に定義したカスタムSetter を呼び出します。

MainActivity.java はこんな感じ。

//MainActivity.java
public class MainActivity extends AppCompatActivity {

    private /* final */  MainViewModel _viewModel;
    private CompositeSubscription _subscriptions = new CompositeSubscription();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        final ActivityMainBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_main);

        _viewModel = new MainViewModel(this.getApplicationContext());
        binding.setViewModel(_viewModel);

        // ■ViewModel からの Message の受信(省略)
    }

    /**
     * ListView と ViewModel のカスタムバインディング
     *
     * TODO 本当は viewModel.formattedLaps とバインドしたい
     */
    @BindingAdapter("formattedLaps")
    public static void setFormattedLaps(ListView listView, final MainViewModel viewModel) {
        final LapAdapter adapter = new LapAdapter(listView.getContext());
        listView.setAdapter(adapter);
        
        // formattedLaps が変化した時に呼ばれるイベントで、Adapterを洗い替え。
        viewModel.formattedLaps.addOnPropertyChangedCallback(new android.databinding.Observable.OnPropertyChangedCallback() {
            @Override
            public void onPropertyChanged(android.databinding.Observable sender, int propertyId) {
                adapter.clear();
                adapter.addAll(viewModel.formattedLaps.get());
            }
        });

        // バインド時に値を更新
        adapter.clear();
        adapter.addAll(viewModel.formattedLaps.get());
    }

    @Override
    protected void onDestroy() {
        _viewModel.unsubscribe();
        super.onDestroy();
    }
}

オレオレBindingがごっそり消えてスッキリ。 setFormattedLaps がカスタムSetterで、この中で MainViewModel.formatterLaps を監視し、値が変わったら Adapter を総入れ替えしてます。が、これが正しいやり方かわからない。 extensions/baseAdapters/src/main/java/android/databinding/adapters にはそれらしいのがないでござるよ。。。

ViewModel 側

この辺みてください。大したことはやってないです。(急に雑になったw)

ObservableUtil.toObservableField とか、もうどっかの誰かがやってそうだし、事実上標準の何かが出てきそうな気がすごくします。

おまけ

Messenger を RxJava ベースにした

らしいので、自作してた MessengerRxJava ベースにしてみました。 ViewModel→Viewの通知 にしか使ってないので、あまり rx.Observable<T> にする旨味はなかったですね。あ、ofType って便利ですね。

まとめ

今回作ったアプリの全ソースは

です。

.NETアプリケーション開発では、ViewModel を View にバインドすることが殆どなので、典型的な例としてやってみました。

レイアウトに直接バインドを定義できるので、コードビハインド(Javaのソース)はスッキリしますが、個人的にはあまり好きではありません。 コードビハインドに(textTime.SetBinding(v => v.Text, viewModel.Time) みたく)書いた方が、定義情報がまとまっていて管理しやすい、デバッグしやすいと思うからです。(同じ理由で、xmlに直接記述する Expression Language も好きではありません。) が、今のところ、Android Data Binding では、レイアウトXMLでしかバインディングを定義できないようですね。

ともあれ、AndroidBinding とか Butter Knife はこれで駆逐されていく(前者はすでに息してなさそうですが)と思うので、新しいアプリ開発では積極的に使っていこうかなと思います。

参考