DataBinding on Anko


はじめに

ホットペッパービューティーでAndroid開発を担当している@sakuna63です。
この記事ではAnkoというライブラリの上でデータバインディング機構を実現する方法について考えてみたいと思います。

Anko

AnkoはKotlinで作られたJetBrains製のAndroidライブラリです。Commons, Layouts, SQLite, Coroutinesの4つのコンポーネントから構成されており、それぞれがKotlinの言語機能を活用した拡張関数やモジュールを提供しています。

このうちLayoutsはレイアウトを構築するためのDSLを提供するコンポーネントです。以下のようにして、レイアウトを構築することができます。

frameLayout {
    button {
        text = "Greeting"
        onClick {
            toast(edit.text)
        }
    }.lparams { gravity = Gravity.CENTER }
}

AndroidのレイアウトXMLを書いたことがある人なら、何をしているのかなんとなく理解できると思います。特徴的なのは、レイアウト定義の中でクリックリスナを設定している点です。DSL自体がKotlinなので、このような記述が可能となっています。同様に、DSLの独自拡張もKotlinの言語機能(拡張関数, 中置関数 etc..)を使って容易に行うことができます。

今回はこのAnko LayoutsのDSLを拡張することで、データバインディング機構を実現してみようと思います。

実装

データバインディングに必要な要素

データバインディング機構を実現するのに必要な要素は以下の3つだと考えています。

  1. ViewModelのプロパティ変更監視
  2. Viewのプロパティの変更監視
  3. ViewModelのプロパティと、Viewのプロパティのマッピング

今回は3.をAnkoのDSLを拡張して実装してみようと思います。 1.の実現にはData Binding LibraryObservableをそのまま流用します。2.はViewが提供しているリスナを利用し、提供されていないものについては考えません。

プロパティのマッピング

先程のサンプルにもありましたが、Ankoでは基本的に=を使ってViewのプロパティへ値を代入します。

editText {
    hint = "This is Hint"
}

ちなみにhint =JavaのsetHintを呼び出しているだけであり、Ankoの提供している機能ではありません。

Data Binding Libraryに習って、マッピングも hint = viewModel.hint のように記述したいところです。試しに以下のような拡張プロパティを実装してみます。

var TextView.hint: ObservableField<CharSequence>
    @Deprecated(AnkoInternals.NO_GETTER, level = DeprecationLevel.ERROR) get() = AnkoInternals.noGetter()
    set(value) {
        hint = value.get()
        value.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
            override fun onPropertyChanged(p0: Observable?, p1: Int) {
                hint = value.get()
            }
        })
    }

しかし、これを使うことはできません。なぜかというと hint = viewModel.hintと記述した場合、TextView#setHintの呼び出しと推論され、型のミスマッチとして扱われるためです。また、仮に可能だったとしても、Viewのプロパティ一つ一つに対してこのような実装を行うのは厳しいものがあります。

前者については自然さを捨てて違う構文を提供すれば解決できそうです、後者は関数リファレンスを活用することでパターン化できそうな気配があります。

結論として、以下のような関数を実装することで解決しました。DSLとしての自然さは損なわれてしまいますが、汎用的に記述できるため個人的には満足しています。

editText {
    bind(::setHint, viewModel.hint)
}

fun <T : Any, U : T> bind(setter: (T) -> Unit, field: ObservableField<U>) {
    setter(field.get())
    field.addOnPropertyChangedCallback { setter(it) }
}

双方向バインディング

双方向バインディングについては汎用的に記述することはできませんでした。そもそもViewの状態を監視するにはViewが提供しているListenerを利用するしかなく、それぞれでインターフェースは異なります。リフレクションを使えば実現できるかもしれませんがそれは避けたいところです。これについてはData Binding Libraryも同じ悩みを抱えています

とはいえ双方向バインディングが必要となるケースは単方向バインディングに比べれば圧倒的に少ないはずです。なので双方向バインディングは一つ一つ実装することにしました。例えば、TextView#textへの双方向バインディングを以下のように実装しています。

editText {
    textBind = viewModel.text
}

fun TextView.bindText(field: ObservableField<String>) {
    bind(::setText, field)
    textChangedListener {
        afterTextChanged {
            // prevent infinite loop
            if (field.get() == it.toString()) {
                return@afterTextChanged
            }
            field.set(it.toString())
        }
    }
}

サンプルコード

上記手法を模索するにあたり、googlesamples/android-architecturetodo-mvvm-databindingを一部Kotlinize && Anko化してみました。もし興味がありましたら御覧ください。

終わりに

今回はAnkoを使ってデータバインディング機構を実装する方法について紹介しました。
本家Data Binding Libraryに比べると、Kotlinの言語機能に閉じた拡張であり、黒魔術感が少ないなと感じました。Ankoが今後どう発展していくのかが気がかりではありますが、次の機会があれば是非候補として検討したいところです。