📖

【Android】はじめてのDataBinding

2021/03/28に公開

前回の記事:【Android】分かった気になれる!アーキテクチャ・MVVM概説 では
アーキテクチャの概要をコードを記述せずに概念のみ説明してみました。
今後は各ライブラリを実際にコードを書いて導入してみることで
アーキテクチャと各種ライブラリを理解し、身につけていきたいと思っています。

このアーキテクチャシリーズ実践編第一回目として
今回は、Googleが提供するJetPackに含まれるDataBindingライブラリを
実践しながら簡単に説明していきたいと思います。

だらだらと書くのも楽しくないので、
今回は私が愛して止まないアメリカンフットボールのルール・魅力を
間に挟みながらDataBindingを説明していきたいと思います。

気になったワードがあれば、ぜひ調べてみてくださいね

はじめに

本記事では、DataBindingとLiveDataを用いて添付のGIF画像のような挙動を実装しようと思います。

この画面の特徴は以下の通りです。

  • Buttonタップ時
    • EditTextに入力されたテキストがTextViewに表示される
  • EditTextに何も入力されていないとき
    • Buttonは押せない
    • テキストは「Ready」
  • EditTextにテキストが入力されているとき
    • Buttonは押せる
    • テキストは「Set!!!」

本記事では、Buttonに関するDataBindingに注目していきます。

実装したいこと
data_binding.gif

これに似た画面には以下のようなものが挙げられます。

  • ユーザーIDとパスワードが入力されている場合のみボタンを押せるようなログイン画面
  • 利用規約にチェックされている場合のみ次に進めるような画面

簡単な環境

AndroidXを導入したプロジェクトで実装を行いました。
これは、プロジェクトを作成するときにUse androidx.* artifactsにチェックを入れるか
Refactor > Migrate to AndroidX...することで対応できると思います。

AndroidXについてはこちらを参照:
AndroidX の概要 : Google公式

今回の実装ではそれほど影響はないと思われますが
ハマってしまうかもしれないので。
(おおよそデフォルトになっていると思いますが、念のため)

元のコード

今回はDataBindingの使い方を分かりやすくするために
ごく簡単なレイアウトを用いて説明したいと思います。

MainActivityは、FrameLayoutを載せただけのシンプルなレイアウトです。
そのFrameLayoutにFootballFragmentと言うFragmentを表示することにします。


ちなみに、アメフトはAmerican Footballの略です。
Footballといえば、
多くの国ではSoccerのことを指しますが
アメリカではAmerican Footballのことを指すのが一般的です。
日本開催で大いに盛り上がったラグビーも実は
正式名称Rugby Footballなので、Footballの一種です。

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

        supportFragmentManager.beginTransaction()
            .replace(R.id.frameLayout, FootballFragment())
            .commit()
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout
        android:id="@+id/frameLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

FootballFragmentは以下のコードのように
LinearLayoutにTextView・EditText・Buttonを配置したシンプルなレイアウトです。
ところどころ見た目を改善する処理は記述していますが。

fragment_football.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="32dp">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ここに出力されます" />

    <EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:layout_marginBottom="32dp"
        android:hint="アメフトのルールや魅力は?"
        android:textAlignment="center" />

        <Button
            android:id="@+id/button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            tools:text="Ready" />
</LinearLayout>

textをstringで定義していないなど細かいことは置いといて
DataBindingだけに集中してもらえればと思います。

DataBindingの導入

さて、いよいよDataBindingを導入します。
以下の項目の順に実装していきます。

  • appレベルのbuild.gradle
  • fragment_football.xml

build.gradle(Module:app)に関して

appレベルのbuild.gradleにて、
以下のコードを記述しDataBindingを有効にします。

build.gradle(app)
android {
    // 略
    dataBinding {
        enabled true
    }
}

さらに、Kotlinで開発しているのであれば下図で示されるようなサジェストが出るので
トップレベルにapply plugin: 'kotlin-kapt' を追加した上で
Sync Nowで同期し対応しておきます。
<img width="684" alt="スクリーンショット 2019-11-04 18.51.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/296819/2f36eb3c-0bae-8b36-9610-0a0c58cf6b62.png">

これで、DataBindingの導入は終わりです。簡単ですね。
アメフトで言えば、キックオフリターンが終了しリターンチームが攻撃を開始する頃でしょうか。

さて次は、レイアウトファイルをDataBindingで使える形に変換していきます。

レイアウトファイルに関して

DataBindingを有効化した後
レイアウトファイルを、DataBindingを使用できる状態にします。

元のレイアウトファイル

fragment_football.xml
<LinearLayout xmlns:android="http://schemas.androidd.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">
    <!--略-->
</LinearLayout>

レイアウトファイルにおいて
親のView(ここではLinearLayout)にカーソルを当て
option + Enterを押すと下図のようなダイアログが表示されます。

<img width="524" alt="convert_to_databinding.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/296819/9bd611be-8196-422f-ad8a-0c3dfe6b43e5.png">

このConvert to data binding layoutを選択すると
レイアウトがDataBinding用に変換されます。

DataBinding用に変換したレイアウトファイル

fragment_football.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>

    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:orientation="vertical">
        <!--略-->
    </LinearLayout>
</layout>

元のレイアウトファイルと変換後のレイアウトファイルの比較をまとめると以下のようになります。

元のレイアウトファイル 変換後のレイアウトファイル
親View LinearLayout layout
dataタグ なし layoutとLinearLayoutの間

これでDataBindingの基本的な導入は完了しました。

本当に完了でしょうか?
いいえ、まだ残っています。アメフトで言えば1Q残り2分ですね。
最後にビルドを実行し、DataBinding用を自動生成して完了です。

これでひとまず、1Qは終了です。
CMです。

(アメフトの試合は 1Q: 15分で4Qまで、計60分で行われます。
が、ゲーム中時間は止まるため実際は3,4時間かかります。長丁場なんですね。)

レイアウトファイルとコードの結びつけ

次は実際に、レイアウトファイルとコードを紐づけていきます。
まず、レイアウトファイルに関して。

レイアウトファイルに関して

先ほどのレイアウトファイルにおいて
dataタグ内にDataBindingしたい変数を定義していきます。

name: 変数名
type: 型
となっており、typeにはStringやIntなどはもちろん
独自で作成したクラスも入れることができます。
その場合は階層からクラス名を指定する必要があります。:
e.g. type="com.hoge.QB", type="com.hoge.Audible", ...

fragment_football.xml
    <data>

        <variable
            name="buttonText"
            type="String" />
    </data>

このように定義された変数は、以下の役割を担うことができるようになります。

  • 外部ファイルから対象の値を取得
  • 外部ファイルに対象の値を渡す
  • レイアウトファイルから対象の値を取得
  • レイアウトファイルに対象の値を渡す

つまり、別ファイルとこのレイアウトファイルの両方からアクセスできるようになります。
通常は、外部ファイルから取得した値をレイアウトファイルに単一方向に渡すことが多いです。
レイアウトファイルから外部ファイルに値を渡す場合のDataBindingは双方向DataBindingと呼ばれ
処理が異なったり、追加で処理が必要になったりします。
ちなみに今回は、双方向DataBindingは行いません。

次に、定義した変数を代入します。
@{name}のように記述するだけでDataBindingを実装できます。
例えば、android:text="@{buttonText}"と言った具合です。

fragment_football.xml
        <Button
            android:id="@+id/button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{buttonText}"
            tools:text="Ready" />

これで、buttonandroid:textbuttonTextが結びつきました。

つまり、buttonandroid:textの値の設定を
buttonTextを通すことで別ファイルのコード上でも設定できるようになりました。

ここで、こんな疑問を持った方はいるのではないでしょうか?
そんなことしなくても、適当なKotlinファイル内でbutton.text = "Ready, Set Hut!!!"のように記述すれば値をセットできるのではないか、と。

その通りです。
こんなことしなくても別ファイルからコード上で値をセットすることは可能です。
しかし、こうすることで格段と値の管理が簡単になる場合があるのです。

それはユーザー操作などにより変更された値を
即座にViewに反映させたい場合などです。
変更された値の反映をコード上で実装しようとすると
記述量が増えコードが煩雑になってしまう可能性があります。

それが、後述するLiveDataとともにDataBindingを利用することで
より簡潔にスマートにコードを書けるようになるのです。

その実装は後ほど、後半戦で実装します。

別ファイルのFragmentクラスにおいて

次に、DataBindingを適用したレイアウトファイルをFragmentクラスに結びつけます。
onCreateViewでレイアウトファイルと結びつけます。

DataBindingを実装しない場合

FootballFragment.kt
class FootballFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_football, container, false)
    }
}

DataBindingを実装しない場合、通常であれば上記のように実装すると思われます。
inflaterでレイアウトファイルを指定しinflateすることで
レイアウトファイルとFragmentクラスのコードを結びつけています。

一方、DataBindingを用いた場合の実装例は下記の通りです。

DataBindingを実装する場合

FootballFragment.kt
class FootballFragment : Fragment() {

    private lateinit var binding: FragmentFootballBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentFootballBinding.inflate(inflater, container, false)
        return binding.root
    }
}

上記で示されるFragmentFootballBindingクラスは
ViewDataBindingクラスを継承したクラスで、
実は、レイアウトファイルでDataBindingを実装しビルドした際に作られていました。
それをここではbindingとして定義し、
FragmentFootballBindinginflateメソッドを用いてインスタンス化しています。

そして、onCreateViewの戻り値にbinding.rootを指定することで
FootballFragmentのレイアウトを設定することができます。
詳しくは、こちらを参照:
Generated binding classes : Google公式ガイド

これで、ひとまずレイアウトを設定することができました。

アメフトで言えば、前半残り2分:2 minutes warning
あたりでしょうか。

DataBindingしてみよう!

ここで、bindingを使ってテキストをセットしてみましょう!

FootballFragment.kt
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.buttonText = "Crouch, Bind, Set!!"  // これはラグビー
    }

このようにbinding.buttonTextとすることで
レイアウトファイルで定義したStringクラスのbuttonTextを呼び出し、値をセットできます。
それにより、レイアウトファイルのbuttonにおけるandroid:text
buttonTextの値Crouch, Bind, Set!!がセットされます。
(ただ、この程度の実装では、前述の通りbutton.textを呼び出すだけでいいと思います)

以下の実行結果では、ButtonにCrouch, Bind, Set!のテキストが表示されていることが確認できます。
(Buttonのテキストがデフォルトで大文字なので大文字にはなっていますが)

実行結果
databinding_without_livedata.gif

LiveDataを実装

後半戦キックオフ。

次にお待ちかね(かどうかは分かりませんが)、LiveDataを実装します。
以下の順で実装していきます。

  • ViewModelの作成
  • 各種LiveDataの作成
  • レイアウトファイルにViewModelを導入
  • LiveDataの更新メソッドを作成
  • FragmentクラスにViewModelを導入

ViewModelとは? LiveDataとは? MVVMとは? と言う方はこれらを参照してみてください

ViewModelの作成・LiveDataの作成

早速、FootballViewModelを作成します。
(今回は、ViewModel()を継承しないLiveDataを保持するだけのViewModelを作成します。
ViewModel()を継承する場合はViewModel生成の際に別途処理が必要になります。)

その後、変更を検知したいデータに対して、MutableLiveData、およびLiveDataを作成します。

LiveDataは、値がセットされたときに値がセットされたことを検知できるクラスです。
実際に値をセットされるのはMutableLiveDataで、
LiveDataに直接値をセットされることはありません。

MutableLiveDataは、その名の通り値の変更が可能なLiveDataで、
LiveDataの値を取得するとMutableLiveDataの値が返されるよう実装します。
また、ここでは下記のコードのようにスコープ関数を用いて初期値を設定しています。

下記コードはFootballViewModelの一部を抜粋したものです

FootballViewModel.kt
class FootballViewModel {
    // 略
    private val _buttonText: MutableLiveData<String> =
        MutableLiveData<String>().also { mutableLiveData ->
            mutableLiveData.value = "Ready"
        }
    val buttonText: LiveData<String>
        get() = _buttonText
}

レイアウトファイルにViewModelを導入

レイアウトファイルのdataタグで定義した変数buttonTextを削除し、vm(ViewModel)を変数に設定します。
また、buttonにて設定していた@{buttonText}@{vm.buttonText}に変更し、
ViewModelを経由してbuttonTextの値を取得するよう変更します。

(少し疲れてきました。頭を整理するため、タイムアウトを取ります。。)

変更前

fragment_football.xml
    <data>

        <variable
            name="buttonText"
            type="String" />
    </data>

    <LinearLayout>
     <!--略--> 
        <Button
            android:id="@+id/button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{buttonText}"
            tools:text="Ready" />
    </LinearLayout>

変更後

fragment_football.xml
    <data>

        <variable
            name="vm"
            type="io.github.itakahiro.databindingfootball.FootballViewModel" />
    </data>

    <LinearLayout>
     <!--略--> 
        <Button
            android:id="@+id/button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{vm.buttonText}"
            tools:text="Ready" />
    </LinearLayout>

LiveDataの更新メソッドを作成

次に、LiveDataの値を更新するメソッドを作成します。

FootballViewModel.kt
    fun updateButton(isBlank: Boolean) {
        _isEnabled.value = !isBlank

        if (!isBlank) {
            _buttonText.value = "Set!!!"
        } else {
            _buttonText.value = "Ready"
        }
    } 

このメソッドでは、buttonの状態を以下の2項目に関して更新しています。

  • buttonText: ボタンのテキスト
  • isEnabled: ボタンを押下できるかどうか(Button.isEnabled)

buttonTextに関して。
_buttonTextの値(間接的にはbuttonTextの値でもある)を
EditTextにテキストが入力されているかどうか(メソッドの引数:isBlank)によって変更しています。

isEnabledに関して。
記載しておりませんでしたが
_isEnabled, isEnabledをViewModel内で定義しています。
これはButtonがenableかどうかと言う情報を持つ
MutableLive、およびDataLiveDataです。


LiveDataのsetterには
setValue(Kotlinでは、.value)とpostValueが用意されています。
setValueはメインスレッド(UIスレッド)のみでしかセットされず、
もし、別スレッドでLiveDataの値がセットされたとしても
検知できないと言うことらしいです。
参照:LiveData : Google公式リファレンス

FragmentクラスにViewModelを導入

さて、3Qも終わりいよいよ4Qです。
時間運びがより一層大事になってきますね。

FootballFragmentFootballViewModelを導入します。

下記コードは、FootballFragmentのコードです。

FootballFragment.kt
class FootballFragment : Fragment() {

    private lateinit var binding: FragmentFootballBinding

    private val viewModel = FootballViewModel()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentFootballBinding.inflate(inflater, container, false)
        binding.vm = viewModel
        binding.lifecycleOwner = this
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel.isEnabled.observe(viewLifecycleOwner, Observer { isEnabled ->
            binding.button.isEnabled = isEnabled
        })

        binding.editText.addTextChangedListener { text ->
            viewModel.updateButton(text.isNullOrBlank())
        }
    }

まず、プロパティにviewModelを定義します
今回作成したViewModelはViewModel()を継承していないため
FootballViewModel()でインスタンス化できます。
(ViewModel()を継承する場合の実装はこちらを参照:
Implement a ViewModel: Google公式ガイド)

FootballFragment.kt
private val viewModel = FootballViewModel()

次に、onCreateView()にてbindingの設定を追加します。
追加したのは下記の部分です。

FootballFragment.kt
        binding.vm = viewModel
        binding.lifecycleOwner = this

まず1行目について。
binding: FragmentFootballBinding のvm
つまりレイアウトファイルにてdataタグ内に設定したvm
インスタンス化したViewModelを代入してあげます。

次に2行目について。
これを追加してlifecycleOwnerを設定しておかないと
LiveDataが値の更新を検知することはできません。
コメントアウトして実行してみると挙動が分かると思います。

-- 後半4Q, 2 minutes warning

最後に、onViewCreatedにて、
ObserverとEditTextのリスナーを設定します。

まず、Observerについて。
以下に添付のコードのように、viewModel内のLivedataを指定することで
LiveDataの値が更新された際に検知することができます。
これは、同じ値がセットされたとしても検知されます。

今回の場合、viewModelで定義した
isEnabledというLiveDataの値が更新された時に呼び出され、
Button.isEnabledの値に代入されます。

つまり、データの更新とViewの更新が同期されます

また、このObserverの処理はDataBindingでできますが
実装方法を記載するために実装しています。

FootballFragment.kt
        viewModel.isEnabled.observe(viewLifecycleOwner, Observer { isEnabled ->
            binding.button.isEnabled = isEnabled
        })

次に、EditTextのリスナーを設定します。
以下の添付のリスナーは、editTextのテキストが変更されたとき
つまりテキストを入力・削除したときに呼ばれます。
このときのeditTextにテキストが入力されているかどうかを取得し
viewModelのメソッドに引数として渡します。

前述のこのメソッド内でボタンに関するデータの更新を行います。

FootballFragment.kt
        binding.editText.addTextChangedListener { text ->
            viewModel.updateButton(text.isNullOrBlank())
        }

これで試合終了です。

最後に、全体のコードを添付して終わりにしたいと思います。
記事内では記載していなかった部分もあります。

fragment_football.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="vm"
            type="io.github.itakahiro.databindingfootball.FootballViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:orientation="vertical"
        android:padding="32dp">

        <TextView
            android:id="@+id/textView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{vm.submittedText}"
            tools:text="ここに出力されます" />

        <EditText
            android:id="@+id/editText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="32dp"
            android:layout_marginBottom="32dp"
            android:hint="アメフトのルールや魅力は?" />

        <Button
            android:id="@+id/button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:enabled="@{vm.isEnabled}"
            android:text="@{vm.buttonText}"
            tools:text="Ready" />
    </LinearLayout>
</layout>
FootballFragment.kt
class FootballFragment : Fragment() {

    private lateinit var binding: FragmentFootballBinding

    private val viewModel = FootballViewModel()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentFootballBinding.inflate(inflater, container, false)
        // このようにしても良い
//        binding = DataBindingUtil.inflate(
//            inflater, R.layout.fragment_football, container, false
//        )
        binding.vm = viewModel
        binding.lifecycleOwner = this
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.editText.addTextChangedListener { text ->
            viewModel.updateButton(text.isNullOrBlank())
        }
        binding.button.setOnClickListener {
            val text = binding.editText.text.toString()
            viewModel.submitText(text)
        }
        
        // この部分はDataBindingで実装. 
        // 代わりに、レイアウトファイルのandroid:enabled="@{vm.isEnabled}"の行で実装できる!
//        viewModel.isEnabled.observe(viewLifecycleOwner, Observer { isEnabled ->
//            binding.button.isEnabled = isEnabled
//        })
    }
}
FootballViewModel.kt
class FootballViewModel {
    private val _submittedText = MutableLiveData<String>().also { mutableLiveData ->
        mutableLiveData.value = "ここに、出力されます"
    }
    val submittedText: LiveData<String>
        get() = _submittedText

    private val _isEnabled: MutableLiveData<Boolean> =
        MutableLiveData<Boolean>().also { mutableLiveData ->
            mutableLiveData.value = false
        }
    val isEnabled: LiveData<Boolean>
        get() = _isEnabled

    private val _buttonText: MutableLiveData<String> =
        MutableLiveData<String>().also { mutableLiveData ->
            mutableLiveData.value = "Ready"
        }
    val buttonText: LiveData<String>
        get() = _buttonText


    fun updateButton(isBlank: Boolean) {
        _isEnabled.value = !isBlank

        if (!isBlank) {
            _buttonText.value = "Set!!!"
        } else {
            _buttonText.value = "Ready"
        }
    }

    fun submitText(text: String) {
        _submittedText.value = text
    }
}

まとめ

DataBindingに触れる機会があったので
今回記事にしてまとめてみました。

ご意見ご感想あればコメントいただけると幸いです。

参考にした資料

Discussion