DataBindingで詰んだ話 〜2wayバインディング地獄〜

※ChatGPTを使用して記事を作成しています。

「XMLで完結する、魔法のような双方向バインディング」

初めてDataBindingの@=構文を使ったとき、私はそう思いました。

でも、次第にそれは “バグを内包した魔法” だと気づいていきます。

値が反映されない、ループする、意図しない更新が走る──

「これは自分の実装が悪いんじゃない、DataBindingが悪いんだ」と何度心で叫んだことか。

今回は、DataBindingの2wayバインディングで私が陥った 典型的な落とし穴とその回避法 を赤裸々に語ります。

きっかけは「設定画面」の実装だった

ある日、あるAndroidアプリに「ユーザー設定画面」を追加することになりました。

内容は、トグルスイッチで通知のON/OFFを切り替えるというシンプルなもの。

そこで私が書いたコードがこちら:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="viewModel"
            type="com.example.settings.SettingsViewModel" />
    </data>

    <Switch
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="通知を受け取る"
        android:checked="@={viewModel.isNotificationEnabled}" />
</layout>

ViewModel 側では以下のように LiveData を宣言していました。

class SettingsViewModel : ViewModel() {
    val isNotificationEnabled = MutableLiveData<Boolean>()
}

これで、ユーザーがスイッチを切り替えると LiveData が更新され、LiveData が更新されれば UI に反映される──まさに双方向の理想的な設計に見えました。

最初は。

発生した問題1:「値が反映されない?」

バインディングは正しく書いたはずなのに、スイッチを切り替えても ViewModel の値が変わらない

調べてみると、MutableLiveData の値が null のままだと、初回バインディング時に UI の状態が正しく反映されないことがある、という落とし穴が。

val isNotificationEnabled = MutableLiveData(false) // デフォルト値を入れるのが正解

このように初期値を必ず指定しておかないと、2way バインディングの双方向が成立しないことがありました。

発生した問題2:「謎のループ挙動」

さらに、ViewModel 側で設定値を保存しようと次のようなコードを追加したとき、謎のループに陥るようになりました。

val isNotificationEnabled = MutableLiveData<Boolean>().apply {
    observeForever {
        // 設定保存処理(擬似)
        prefs.edit().putBoolean("notification_enabled", it).apply()
    }
}

このコード、何がいけなかったのか?

答えは、UIから変更が入るたびに保存処理が走り、それがまた値を変更し、無限ループになるという構造でした。
LiveData#setValue()observeForeverprefs#edit()setValue() → …の繰り返しです。

当時はデバッグログが「保存処理を繰り返しています」で埋まり、
「DataBindingが暴走してる!」と錯乱状態に陥りました。

発生した問題3:「値が戻る」「反映されない」

別のケースでは、「ユーザーが変更した直後に、元の値に戻る」ような奇怪な動作も発生しました。

調べていくと、原因はこうでした:

  • LiveData の更新が遅延して UI に即時反映されない
  • バインディング側で過去の値を参照してしまう
  • 結果的にUIと状態が一致しない「ズレ」が発生

特に、EditTextとの双方向バインディングでこれが顕著になります。

<EditText
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@={viewModel.username}" />

このような実装で、ViewModel の username が更新されない or 空文字列に巻き戻るなどの不具合が報告されました。

解決策:DataBindingに頼りすぎない設計へ

最終的にたどり着いたのは、「双方向バインディングを極力使わない」という設計ポリシーでした。

以下のように、一方向データバインディング + TextWatchersetOnCheckedChangeListener を使うことで、
明示的な更新の流れを保つようにしました。

XML(一方向バインディング)

<Switch
    android:checked="@{viewModel.isNotificationEnabled}" />

Fragment(イベントリスナー)

binding.switchNotification.setOnCheckedChangeListener { _, isChecked ->
    viewModel.onNotificationToggled(isChecked)
}

ViewModel

val isNotificationEnabled = MutableLiveData(false)

fun onNotificationToggled(isChecked: Boolean) {
    isNotificationEnabled.value = isChecked
    prefs.edit().putBoolean("notification_enabled", isChecked).apply()
}

コード量は増えますが、「何がいつ、どこで更新されたのか」が一目でわかる設計の方が、長期的に見てバグを減らせると痛感しました。

今だから言える教訓

  1. DataBinding の @= による双方向バインディングは強力だが、信頼しすぎてはいけない
  2. LiveData には常に初期値を入れるようにする
  3. ループ構造をつくらないよう、値の変更に副作用を持たせない設計を意識
  4. 「便利」は「見えないバグ」の温床になる。明示的なコードは防波堤

まとめ:魔法に頼るな、構造を設計せよ

2wayバインディングは、魔法のように便利な機能です。

しかし、その魔法は内部で何が起きているかを理解して初めて、安全に使えるもの

今では、DataBindingの使用は必要最小限に留め、Jetpack ComposeやViewBindingへ移行しています。

「便利そうだから使ってみた」

そんな理由で導入した技術が、のちのち自分の首を絞める──

この経験を通じて、私は設計の原則に立ち返ることの大切さを学びました。

タイトルとURLをコピーしました