DataBindingライブラリの内部構造を知る
|今回はDataBindingの紹介です。DataBindingはGoogle I/O 2015で発表されたレイアウトのバインディングライブラリです。ライブラリはXMLで記述されたレイアウトファイルとソースコードをブリッジする役割があります。Androidアプリ開発では、お世話になっているひとも多いかもしれませんね。
開発中に遭遇した事例が興味深かったのでブログで触れておきます。本記事は不具合をフックにDataBindingの内部構造の紹介を目的に書いています(あまり言及している資料もなかったので)。
TL;DR
だれかいたら教えてほしいんですがAndroid 4.2(16~18、JB)系の端末だけDatabinding(を経由したイベントリスナー全般)が動かないで死ぬケースがあるんですがexecutePendingBindingsすると解決する件について同様の経験のある方いませんか!
— mhidaka@1日目 東せ13a (@mhidaka) August 14, 2017
executePendingBindingsメソッドが便利という話です。
バインディングできなかった事例
遭遇した事例はレアケースです。Android 4.2のときにのみ起きる不具合を発見して、すぐに調査をはじめました。
画面ではユーザー入力をVerifyして入力ミスがあればフィードバックを返す、というロジックを持っていましたが不具合内容は、ユーザーが文字を入力しようとするとアプリが強制終了するというものです。
■不具合発生時のログ(アプリが強制終了したとき)
Fatal Exception: java.lang.NullPointerException at android.widget.TextView.sendBeforeTextChanged(TextView.java:7359) at android.widget.TextView.access$1000(TextView.java:224) at android.widget.TextView$ChangeWatcher.beforeTextChanged(TextView.java:9042) at android.text.SpannableStringBuilder.sendBeforeTextChanged(SpannableStringBuilder.java:954) at android.text.SpannableStringBuilder.replace(SpannableStringBuilder.java:464) at android.text.SpannableStringBuilder.replace(SpannableStringBuilder.java:435) at android.text.SpannableStringBuilder.replace(SpannableStringBuilder.java:30) at android.view.inputmethod.BaseInputConnection.replaceText(BaseInputConnection.java:672) at android.view.inputmethod.BaseInputConnection.setComposingText(BaseInputConnection.java:435) at com.android.internal.view.IInputConnectionWrapper.executeMessage(IInputConnectionWrapper.java:333) at com.android.internal.view.IInputConnectionWrapper$MyHandler.handleMessage(IInputConnectionWrapper.java:77) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loop(Looper.java:137)
ログを確認したところTextViewのなか(フレームワークのなか)でNullPointerExepctionが発生しています。バインディング部分を確認した所、イベントリスナーがうまくバインドされておらず意図しない動作になっていました。具体的にはTextWatcherを使って文字の変化があれば入力を検証するという仕組みの部分です
<EditText android:id="@+id/text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_weight="1" app:addTextChangedListener="@{viewModel.editTextWatcher}" />
DataBinding経由で、app:addTextChangedListenerにメソッドを登録しています。
あまり見ないDataBindingの使い方ですが、特におかしな点はありません。
ここでDataBindingを使わないかたちで書き直してもよいのですが、なぜ発生したのかという原因を理解することはできません。
この不具合の原因を突き止めるために、今回はDataBindingの実現手法に目を向けてみます。
DataBindingの動作原理
次の概念図はDataBindingの処理をおおまかに示したものです。
DataBindingの使い方としてはonCreateメソッドでsetViewModelするというのが一般的です。
(余談ですが、Androidの世界にはたくさんのViewModelがあります。なのでViewModelといってもMVVMのVMというわけではないです)
■src/MainActivity.java
@Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = DataBindingUtil.setContentView(this, R.layout.activity_main); viewModel = new TextValidationViewModel(); binding.setViewModel(viewModel); ...省略... }
DataBinding内部は、いくつかのタイミングにわけて必要な処理をしています。コンパイル時にDataBindingはバインドを目的としてコードを自動生成しています。
最初に自動生成したコードをみてみましょう。
■DataBindingの自動生成コードの例
public MainActivityBinding(android.databinding.DataBindingComponent bindingComponent, View root) { super(bindingComponent, root, 1); final Object[] bindings = mapBindings(bindingComponent, root, 4, sIncludes, sViewsWithIds); this.text = (android.widget.TextView) bindings[2]; this.mboundView0 = (android.widget.LinearLayout) bindings[0]; this.mboundView0.setTag(null); this.mboundView1 = (android.widget.LinearLayout) bindings[1]; this.mboundView1.setTag(null); this.submit = (android.widget.Button) bindings[3]; setRootTag(root); // listeners invalidateAll(); }
Activityとレイアウト(View)で必要なバインディングは生成時に完了していますが、イベントリスナーに関してはまだ行われていないようです。イベントリスナーはどこで処理しているのでしょうか?
さらに見てみると、DataBinding内部では非同期にバインドが行われていることがわかります。これはパフォーマンスを優先した結果ですが、スレッドハンドラー等をつかって実装されています。詳細はライブラリのViewDataBinding.javaを読むとよいでしょう。
■main/java/android/databinding/ViewDataBinding.java#437
protected void requestRebind() { synchronized (this) { if (mPendingRebind) { return; } mPendingRebind = true; } if (USE_CHOREOGRAPHER) { mChoreographer.postFrameCallback(mFrameCallback); } else { mUIThreadHandler.post(mRebindRunnable); } }
HandlerまたはChoreographerを通じてタスクを実行するコードが確認できます(実行環境のAndroidバージョンごとに手法が異なります)。今回紹介したrebindメソッドはDataBindingの内部処理にのみ使われています。公開APIで探してみるとexecutePendingBindingsメソッドがつながっています。
RecyclerViewでDataBindingを使うにはexecutePendingBindingsメソッドも同時に呼ぶといい、
というTipsを見たことはないでしょうか(参考)。
RecyclerViewではスクロール時などコンテンツ生成から反応まで猶予がない状況が考えられます。このようなときにバインドを即時反映できるexecutePendingBindingsメソッドは重要になるわけです。
機種依存の不具合に対応する
今回のような特定のバージョンでのみ発生する不具合では、何らかの原因でChroeographerへのキューイングがうまくいっていないケースとして類推できます。この場合はexecutePendingBindingsメソッドの呼び出しで十分対応できそうです。
■src/MainActivity.java
@Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = DataBindingUtil.setContentView(this, R.layout.activity_main); viewModel = new TextValidationViewModel(); binding.setViewModel(viewModel); binding.executePendingBindings(); ...省略... }
executePendingBindingsメソッドを追加するとイベントリスナーのバインディングも遅延することなく直ちに実行されます。
おわりに
記事を書くにあたって、検証コードを作って試してみましたがapp:addTextChangedListenerは正しく動作してしまいました(同じ構成を再現したのにAndroid 4.2端末でちゃんと動く!)。開発中に遭遇した不具合は何らかの理由でキューが実行できていませんでした。レイアウトの複雑さ(実際には記事で書いているより複雑なレイアウトでした)や機種ごとの挙動の違いなど複数の要因が重なった結果だと捉えています。
DataBindingを静的なバインドに使っている人(便利なfindViewByIdとして使ってる場合)は意識しなくてもよさそうですが、イベントリスナーを使っている人はexecutePendingBindingsメソッドに注意を払っておくとよいでしょう。
一般的にライブラリを使うときにすべてを理解してから使い出す、というのは難しいことです。しかし意図しない挙動や不具合などをきっかけに、より理解を深める事は可能です。
使いこなせていない場合は、プロジェクトから削除してもいいですし、よりシンプルな利用方法に留めるというのも良い考え方です。積極的に使い、最大限メリットを享受することがプラスに働く場合はどんどん使っていくことになります。
どのようなケースでもライブラリへの深い理解は、プロジェクトの状況に応じた選択を手助けしてくれます。
フィードバック
たしかに無いですね。実は内部のコードを変えたせいで2.3以降はDataBindingのコード生成にバグあるんですけどね…3.0beta1で一応直ってますが
— すたぜろ (@STAR_ZERO) August 18, 2017
DataBindingはAndroid plugin for Gradleのバージョンに連動してアップデートがあります。可能であれば開発環境を更新するといいかもしれません。
すたぜろさんよりIssue Trackerの情報も寄せられました。ありがとうございます!