Android

Airbnb Epoxyで入力フォームを作ってみる

Airbnb Epoxyについてはいろいろな記事があるので詳しく説明を省かせていただきますが、RecyclerViewを使いやすくしてくれるライブラリです。
今回はEpoxyを使ってRecyclerViewで入力フォームを作ってみたいと思います。

環境

  • Epoxy 3.4.2
  • Koin 2.0.0-rc-2
  • Android Navigation Architecture Component 2.1.0-alpha02
  • Android Architecture Components(ViewModel, LiveData)

関連がありそうなものだけ。
普段DIライブラリはDagger2を使っているのですが、Koinは使ったことがなかったので今回使ってみました。

簡単な仕様

今回は友人を登録するための連絡帳?の入力画面を作ります。
入力項目は以下の通りです。

  • 姓(かな)
  • 名(かな)
  • 性別(男・女・不明) RadioButton
  • 住所
  • 拒否 Switch
  • 好き Switch
  • メモ

EditText以外にも、よく使うと思われるSwitchも使います。

画面は以下のようになります。

EpoxyでEditTextを扱う場合の注意点

残念ながらEpoxyのサンプルでEditTextを使った画面を見つけることができませんでした。
GitHubのIssuesを検索するといくつか参考になりそうなものがあります。
特に参考になりそうなのは以下のIssueです。
How do I use Epoxy to create forms?
このIssueの中で以下のように言われています(多分)

  • EditTextにTextWatcherを登録する
  • モデルの反映はTextWatcherで行う
  • 毎入力ごとにモデルに反映していたらリビルドが多発するのでrequestDelayedModelBuildを使うことをお勧めする

このようにする理由はRecyclerViewの破棄・再生成が行われると入力内容が消えてしまうので文字入力が行われるとモデルへの反映が必要になるからだと思います。
ただ文字通り、一文字ごとにリビルドしていると頻繁すぎるのでrequestDelayedModelBuildを使って遅延させるということだと思います。

Epoxyで双方向モデルの作り方

単方向とか双方向という言い方が正しいかわからないのですが、ここではユーザー入力を受け付けるモデルを双方向モデルと書きます。

まずモデルの作り方なのですが、いくつかやりかたがあります。

  • カスタムビューで生成する(@ModelViewアノテーションを使う)
  • DataBindingから生成する
  • ViewHolderで作る(詳細不明)

おそらく双方向の場合@ModelViewアノテーションを使ったカスタムビューで生成する方法が手堅いかと思われます。
理由はビューがバインド・アンバインドされるときにリスナーの追加・削除を行う必要があるからです。
もちろん、コントローラーでモデルを組み立てるときにonBind, onUnbindでできるのですが毎回書くのは面倒で、且つ特にonUnbindを忘れやすいと思います。
というわけで、今回はカスタムビューを使って自動生成したモデルを使っていきます。

EditTextを含むアイテムの作り方

上記の画面ででてきた項目のようなEditTextが含まれるアイテムは以下のようにして作りました。

バインドされたときに@AfterPropsSetアノテーションをつけた関数が実行されます。
アンバインドされたときに@OnViewRecycledアノテーションをつけた関数が実行されます。
ここで注意なのですが、OnViewRecycledは必ず実行されるわけではないので、例えばですけど必ず実行しなければならない処理などは書かないほうがよいのではないかと思います。
またアンバインドはRecyclerViewのonViewRecycledが発生すると呼び出されます。
EditTextの入力内容の反映はTextWatcherで行っており、上記のIssueで提示されている処理を行っております。

Switchを含むアイテムの作り方

上記の画面で出てきた拒否項目のようなSwitchが含まれるアイテムは以下のようにして作りました。

基本的にはEditTextのときと変わりません。
Epoxy3.4.0からSwitchやCheckBoxで多様すると思われるOnModelCheckedChangeListenerが追加されています。
CallbackPropアノテーション等でOnCheckedChangeListenerを指定するとコードジェネレート時にOnModelCheckedChangeListenerになります。
OnModelClickListenerのSwitch(等)版です。
OnModelCheckedChangeListenerが使われるとコールバックにSwitchの値が渡されます。

コントローラーの作り方

続いてコントローラーは以下のようになります。

setData内が猛烈にこれじゃない感があるのには目をつむっていただいてbuildModels内で項目ごとにモデルを作っています。

各項目用のヘッダー、入力項目、Switch等もそれぞれ一つのモデルを再利用しています。
この中で性別(Gender)RadioButtonだけは専用用途ですが、もしほかの画面で性別がある場合は再利用可能かと思います。
個人的にはレイアウトXMLにScrollViewで項目を書いていくより断然見やすいと思います。
レイアウトXMLは長くなれば長くなるほど可読性が悪くなるので、UIがレイアウトXMLに書かれていて振る舞いがコントローラーに書かれているのは、書いていてしっくり感がありました。

注意点

実はこのフォーム自分の理解不足等により不安定な場合があります。
製作中もクラッシュが多発して修正しましたが、漏れている場合があります。
特にOnModelCheckedChangeListenerでクラッシュが発生しました。
試行錯誤しながらだったので使い方が間違っている可能性がありますが、今後使っていく中で修正していきます。

リスナーは忘れずに解除する

特にOnModelCheckedChangeListenerでのクラッシュが多かったのですが、その殆どはOnViewRecycled時にリスナーを解除することで改善しました。
今回はRecyclerViewを拡張したEpoxyRecyclerViewを使用しています。
EpoxyRecyclerViewを使うとRecyclerViewによく設定する項目が予め設定されていたりEpoxyControllerと接続しやすくなります。
さらに、RecyclerViewがViewHolder(ここでは便宜上アイテムと呼びます)の再利用を行っているのですが、EpoxyRecyclerViewを使うとデフォルトでは再利用するためのキャッシュ領域(プール)の生存期間がActivityの終了(onDetachedFromWindowでActivityが終了している場合クリアしている)までとなります。
つまり同じActivity内でフラグメントを切り替えて再度表示されるときにアイテムが再利用されます。
このときに問題が発生します。
アイテム内のスイッチも最後の状態のままになっております。
再利用されるアイテムが同じ項目にバインドされるとは限らないため、リスナーを解除していないとアイテムが再利用されるときスイッチの状態とモデルの値がことなりonChangeイベントが発生してしまいクラッシュ(IllegalStateExceptionが発生する)します。
おそらく同じ項目にバインドされた場合はモデルの値とスイッチの状態は一致するのでonChangeイベントは発生しないと思われます。
この問題は同じスイッチが含まれるモデルが2つ以上存在する場合に発生すると思われます。
ちなみに以下のような例外です。

[code lang=text]
java.lang.IllegalStateException: Could not find RecyclerView holder for clicked view
[/code]

OnViewRecycled前に同じアイテムが表示された場合

ここで一つ疑問が発生します。
OnViewRecycledは必ず呼び出されるわけではないので、OnViewRecycledが呼び出される前に同じアイテムが表示されてしまったらどうなるんだということです。

ここまでくるとEpoxyの話ではなくなってRecyclerViewの話になるのですが、結論から言うと新しいアイテムが生成されるので問題ないです。
RecylcerViewはViewHolderを再利用するためのキャッシュ領域を持っています。
自信がないのですが、おそらくonViewRecycledのタイミングでキャッシュ領域に格納されます。
(実際はdispatchViewRecycled内で行われていると思われる・・・)
onViewRecycledの前ということはキャッシュ領域にないので、新しいアイテムが生成されます。

2秒後にOnViewRecycledが実行される

EpoxyRecyclerViewを使っている場合なのですが、同一ActivityでEpoxyRecyclerViewが含まれるFragmentがdetatchされた場合、デフォルトでは2秒後にonViewRecycledが呼び出されます。
これはEpoxyRecyclerView内でRecyclerView#swapAdapterを使ってAdapterを一時退避しているのですが、その処理が2秒遅延されているためです。
もし遅延をやめたい場合はsetDelayMsWhenRemovingAdapterOnDetach0をセットすればよさそうです。
2秒遅延させているのは何か理由があるのでしょうが、そこまではわかりませんでした。

まとめ

EpoxyというよりはRecyclerViewの知識不足によりいろいろ詰まるところはありましたが、新しい書き方を感じることができました。
DataBindingを使った場合レイアウトXMLにどうしてもロジックが入り込んで見通しが悪くなることがあります。
しかしEpoxyを使うことでレイアウトXMLには表示に関することしか書かなくなり、制御に関わる部分はEpoxyのコントローラー、もしくはEpoxyのモデルに集約されていて書き心地は良かったです。
アイテムが最小限になるので1アイテム1機能になり場合によっては再利用もしやすくなるのではないかと思います。
特にアイコンとラベルしか変わらないような設定画面みたいな長い一覧などが作りやすくなると思います。

その一方でRecyclerViewの知識がないと不思議な現象に出くわしたときの調査が難しいとも感じました。
EpoxyがRecylerViewを使いこなしているという印象を受けました。
少しRecyclerViewの理解も深まりました。

今回作った入力フォームはサンプルの領域を出ていないと思いますが、今後もう少し使ってみて知見を得たいと思います。
またソースコードはGitHubにアップロードしてあります。
beeete2/android-epoxy-example

-Android

© 2024 ビー鉄のブログ Powered by AFFINGER5