自分は長い間Java/Springを使ったサーバーサイドの開発をしておりましたが最近Androidアプリ開発を行っております。
わからないことだらけですので間違ったことを書いている恐れもあります。
先日開発中のアプリでAndroid Architecture Components(以降AAC)の導入の検証を行いました。
一から作成するアプリではAACベースで設計されることを検討されると思いますが、既存のアプリの場合どのようにして組み込むか実際に検証したところ、事前作業も含めて恐ろしく対応工数が必要になりました。
もちろん、しっかりと設計・実装されたアプリではこのような問題は起きないと思いますのであまり参考にならないかもしれません。
既存アプリの構成と課題
アプリ自体はMVVMで設計され実装されております。
主に以下のようなライブラリ・フレームワーク等を使用しております。
また全コードがKotlinで書かれています。
- RxJava2
- Dagger2
- DataBinding
- RxProperty
ただし完全にはレイヤーを分離できておりません。アプリ内のViewModelがどうしてもViewを必要にする場面があり、その部分はViewに依存しております。
具体的にはToastの表示、パーミッションの要求、ビュー遷移等です。
このあたり他のアプリではどのように分離しているのか個人的にはすごい知りたいです。
またViewModelにはRxProperty(ReadOnlyRxProperty)が公開されておりDataBindingを使ってビューに表示しています。
AACの導入検討
AACにはいくつものコンポーネントがありますが、今回導入を検討したのは以下のコンポーネントです。
- Lifecycle
- ViewModel
- LiveData
現在Model層は基本的にはメソッド呼び出しで基本的にはRxJavaのObserbable/Flowableを返しております。
Model層のRxJavaの依存度が高いこと、Model層をLiveDataにするのは非常に大きな改修になること等でModel層へのLiveDataの導入は見送りました。またその過程でRoomも見送りました。
LiveDataはViewModelで公開してDataBindingでビューにバインドする方針にしました。
AAC導入の事前調査
導入するコンポーネントを固めたところで、既存のアプリにAACを導入した場合どのような課題があるのか調査しました。
AACのViewModelの調査を勧めていたところ、AACのViewModelはActivity(Fragment)より生存期間が長くなるためActivity等のビュー要素の参照は持ってならないということがわかりました。
この制限は至極当たり前なのですが、現状すべてのアプリ内ViewModelがActivity(Fragment)の参照を持っていたため、これは非常に大きな課題になりました。(補足すると直接持っていないのですが間接的に持っていました。)
それに加えて、一部のアプリ内ViewModeがDrawableやColorを返しており、参照としては持っていないのですがこれも修正することにしました。
またDagger2も導入はしてありましたが一部のクラスのみインジェクト対象にしておりアプリ内のViewModelは対象外になっていたので、これも対応が必要だということになりました。
まとめると、自分たちのアプリにAACを導入するために必要な事前作業として大きく以下の課題があることがわかりました。
この対応はしっかりとレイヤーの責務がなされているアプリでは必要にならないと思います。
ですが、どうしても納期やその他諸々の関係で当時はそうせざるを得ないこともあり、そういう場合は参考になるかもしれません。
- Dagger2の設定を見直しアプリ内ViewModelの生成をDIで行う
- アプリ内ViewModelからDrwableやColorを無くす
- アプリ内ViewModelからActivity(Fragment)の参照を無くす
AAC導入前に対応が必要な課題の解消
繰り返しになりますが、この対応はあくまで自分たちのアプリの実装により発生した作業で多くのアプリには当てはまらないかもしれません。
Dagger2をより適切に
自分の場合Springの経験はあったのでDIがどういうものかはわかるのですが、Dagger2の経験がなかったのでいろいろつまづきました。
このアプリでもDagger2は導入してあったもののあんまり使われておりませんでした。
(Dagger2の話自体は脱線気味になるので割愛します。)
Dagger2のAndroid Supportを使ってActivity(Fragment)にViewModelをインジェクトできるようにしました。(これはAAC導入時にViewModelProviderに置き換わります。)
この作業の目的はあくまでDagger2を使ってより適切にDI管理を行うというなので、このクラスは本来こうあるべきという部分の修正は行いませんでした。これをやりだすと終わらない・・・。
またこの修正をしているときにエラーが発生したことはわかったのですが、アノテーションの設定をしたあとどこでエラーが発生しているのかわからないことがありました。
これはDataBindingなど他のコードジェネレート系のライブラリを使っていると発生しやすいのかもしれませんがコンパイル時にエラーが発生しDataBindingのエラーが大量に出力され本来のDagger2のエラーがカットされているのが原因でした。
こちらを参考にbuild.gradle
を変更しました。
Android: Data Bindingを使っていると本当のエラーログが出ない話 + 対処法
Dagger2は導入してあり導入自体の敷居はなかったので、自分がDagger2を使いこなせていないという問題以外はありませんでした。
しかしDagger2の経験がないチームに導入するのは、敷居が高いなーと少し思いました。
ViewModelの最適化
アプリ内のViewModelを最適化しました。
ここでいうViewModelというのはAACのViewModelのことではなくMVVMアーキテクチャのViewModel層にあたります。
ViewModelからDrwableなどの要素を無くすことを目標にしました。
この作業をやる前のViewModelは以下のような実装がありました。
極端ですが例です。
val ratingIcon = RxProperty(entity.map { raitingToIcon(it.rating) }) // Drwable
val ratingText = RxProperty(entity.map { raitingToText(it.rating) }) // String
val ratingTextColor = RxProperty(entity.map { raitingToColor(it.rating) }) // Color
という具合にratingをビューに表示するときにアイコン、テキスト、テキスト色をViewModel内で変換しビューで表示しています。
チーム内でViewModelの役割を再確認しViewModelはなるべく情報(Drwableやテキスト)ではなくオブジェクト(Rating)を返しビュー側でその状態に応じた情報を表示する方針にしました。
自分たちはこれをDataBindingのBindingAdapterを使うことで対応しました。
例えば以下のようになります。
@BindingAdapter(value = ["android:text"])
fun TextView.setRatingText(rating: Rating?) {
rating ?: return
text = raitingToText(rating)
}
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@{vm.rating.value}" /> <!-- BindingAdapterでtextの値が設定される -->
各ViewModelに同様の変換処理が書いてあったのですが、それらをまとめることができました。
また一つの値を表示するために表示対象に合わせて変換しており上記の場合3つのRxPropertyが必要でしたが、これを一つにまとめることができました。
val rating = RxProperty(entity.map { it.rating })
細かいところではViewの表示制御でBooleanでVisible
かGone
を切り替えるようなところはDataBindingで対応しました。
やりたいことは大体データ バインディング ライブラリに書いてありました。
また上記のように単純な用途であればEnum程度で対応できるのですが、複雑になった場合はsealed class
を使って対応しました。
sealed class
は非常に便利なので乱用はよくないかもしれませんが、それでもやりたいことをかなりシンプルに実現してくれました。
例えば進捗を表すテキストを表示したいときですが以下のように表すことができます。
sealed class ProgressStatus {
object NotStarted: ProgressStatus()
data class InProgress(val per: Int): ProgressStatus()
object Completed: ProgressStatus()
}
fun TextView.setProgressText(progress: ProgressStatus?) {
progress ?: return
text = when (progress) {
is ProgressStatus.NotStarted -> {
"process not started."
}
is ProgressStatus.InProgress -> {
"process in progress ${progress.per}%"
}
is ProgressStatus.Completed -> {
"process completed."
}
}
}
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@{vm.progress.value}" />
BindingAdapterは非常に便利なのですが一括置換や使用箇所の検索などは少しやりにくくなったかなーと思いました。
アプリ内ViewModelからActivityの参照を無くす
MVVMのViewModelはViewのことを極力意識しないことが望ましいと思わます。
ですが現実はなかなか難しく各チームでみなさん本当に悩まれていろいろな答えを出していると思います。
自分たちのアプリで特に悩ましかったのがToast(SnackBar)の表示や画面遷移でした。
言い方が正しいかわかりませんが画面遷移を含む画面操作系の処理はNavigationController
に集約されていて、このNavigationController
の生成時にActivityを必要としていました。
以下はイメージです。
class MainViewModel(private val controller: NavigationController) {
fun showConfilm() {
controller.showConfilmDialog("", listener)
}
}
class MainActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val controller = NavigationController(this)
val vm = MainViewModel(controller) // or Inject
}
}
この課題をどのように解決するか本当に悩みました。
自分たちがとった解決策はViewModelで画面イベント用のLiveDataを利用するという方法でした。
AACを導入するための事前作業をしているのにAACのLiveDataを利用するというのは変な話ですが、最初はRxJavaのObservable/Flowableを使ってActivity/Fragmentで受けようとしていたのですが、この仕組にLiveDataを使うことにしました。
LiveDataの特性で画面の表示時にLiveDataの最後の値が流れてきます。Rx的にいうとBehaviorSubject
でしょうか。
この特性はViewに最新の値を表示できるという点では非常に良い機能なのですが、今回のような画面イベント通知では困ったことになります。
例えば、LiveDataを使いダイアログを表示して閉じた後その画面にとどまった状態でRecent Apps等でバックグラウンド(onPause, onStop)にしてもう一度アプリを開きフォアグラウンド(onStart, onResume)にすると、このLiveDataの最後の値が流れてきてダイアログが表示されてしまいます。
一度値が通知された場合は新しい値がセットされるまで再度通知してほしくない動きを実現するSingleLiveEvent
を使いました。
このSingleLiveEvent
はGoogleのgooglesamples/android-architecture内のtodoapp
内にあります。
SingleLiveEvent.java
サンプルですが以下のようになります。
// ダイアログ
sealed class DialogType {
data class SimpleDialog(val title: String, val click: () -> Unit): DialogType()
data class ConfirmDialog(
val title: String,
val positive: () -> Unit,
val negative: () -> Unit
): DialogType()
}
class MainViewModel {
val event = SingleLiveEvent<DialogType>()
fun showConfilm() {
event.value = DialogType.SimpleDialog(
title = "title",
onClick = {}
)
}
}
class MainActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val vm = MainViewModel() // or Inject
vm.event.observe(this, Observer {
it ?: return@Observer
when (it) {
is DialogType.SimpleDialog -> {
// ダイアログ表示処理
}
is DialogType.ConfirmDialog -> {
// ダイアログ表示処理
}
}
})
}
}
もちろん、このやり方は正しいとは限らず、もっとよい方法があると思います。
AAC自体がまだ比較的新しいコンポーネントなので今後導入事例が増えて、よりよいやり方があれば改善していきたいと思っております。
事前作業のまとめ
AAC導入の事前作業というより、MVVMのViewModelレイヤーの最適化作業という感じがします。
ですが、この作業を行ったことによりアプリ内のViewModelがスリムになりAndroidのView関係コンポーネントの依存が減ったことは非常に大きな改善になりました。
AACの導入
AAC導入前の事前作業がかなり長くなってしまいましたが、いよいよAAC導入です。
といっても、AAC自体の導入は大きな作業はありません。
おそらく大体のアプリがサポートライブラリを使っていると思いますが、このサポートライブラリの中にAACがすでに組み込まれていてAppCompatActivity
やFragment(サポートライブラリ)
はAACのLifecycleOwnerを実装しております。
ViewModel
MVVMのViewModel層のクラスがAACのViewModelに該当する場合が多いと思います。対象のクラスにAACのViewModel
またはAndroidViewModel
を継承させます。またLifecycleObserverを実装させることでActivity(Fragment)のライフサイクルイベントを拾えるようになります。
ViewModelの生成(再利用)管理は自分で行わないのでViewModelProviders
を使って行います。
vm = ViewModelProviders.of(this).get(MainViewModel::class.java)
管理するViewModelが他のオブジェクトに依存しない場合はそれでよいのですが、通常は大体のViewModelでビジネスロジック(UseCase)などを参照すると思いますので、その場合はViewModelProvider.Factory
を実装したクラスを作ってViewModelProviders#of
の第二パラメーターに渡す必要があります。
これが結構面倒なのでDroidKaigi2018にDagger2を使った素晴らしい解決策があるので参考にさせていただきます。
ViewModelFactory.kt
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
vm = ViewModelProviders.of(this, viewModelFactory).get(MainViewModel::class.java)
lifecycle.addObserver(vm) // Lifecycleイベントを通知する
}
LiveData
今までRxProperty
を使っていたのですが、これをLiveDataに移行しました。
個人的にはという前置きをさせていただきますが、RxJavaを使っている場合RxPropertyを使うと恐ろしいほど自然に書けるので無理にLiveDataに置き換える必要なないのではないかと思います。
特にObservable/Flowableの値をLiveDataにするときに可読性が落ちているように感じたときがありました。
LiveDataがどうというより、モデル層はRxJavaだったのでRxPropertyと親和性が高いということがあります。
しかし今回はプロジェクト自体が、なるべく標準ライブラリに寄せていこうという方針もあって最終的にはLiveDataに移行しました。
RxPropertyに慣れているとLiveDataは慣れるのに時間がかかりました。
特にObservable/Flowableを設定したいMutableLiveDataの書き方が難しいなと思っています。
今は一度LiveDataに変換したあとMediatorLiveDataで繋いでいますが、もっとよいやり方がないか考えております。
fun <T> MediatorLiveData<T>.fromLiveData(source: LiveData<T>) = apply { addSource(source, { value = it }) }
// MainThread以外から更新がある場合はpostValueを使う
val inputName = MediatorLiveData<String>().fromLiveData(name)
Lifecycle
ViewModelにLifecycleObserverを実装して、ライフイベントのアノテーションを設定していきました。
また自分でLifecycleOwnerも作れるので、例えば特定の条件のときに本来発生させたいライフサイクルイベントを発生させないということもできます。
個人的にはアプリケーションのライフサイクルを監視できてフォアグラウンドとバックグラウンドのイベントをProcessLifecycleOwner
で検知できるようになったのは非常に便利でした。
結びに
AAC導入よりも導入前の事前作業が長くなってしまいました。
アプリの特性にもよると思いますが、導入前の作業が大変でしたが、導入自体は比較的スムーズに行えた印象があります。
といっても現時点では単純に導入しただけで、この後本格的なQAフェーズに入っていきます。もしかしたら、すごい影響というか問題が発生する可能性もあると思います。
今回導入して感じたのはもし導入するのであれば早ければ早いほどよいということです。
遅くなると対応が必要な箇所・テスト期間などが増えることが考えられます。
またAACの各コンポーネントは分離して導入できるのでViewModelが難しそうならLiveDataだけみたいなことができます。
ソースコードの統一性はなくなってしまいますが新規機能の部分にまず導入してみて展開していくということもできると思います。
今年ももう終わりですが2019年こそはViewModelのテストを書き・・・・たい。
おまけ
参考情報
DroidKaigi2018のソースコードが非常に参考になりました。
AACで迷ったらDroidKaigi2018のソースコードを読むと解決策があるかもしれません。
特にBindingAdapterと拡張関数とResult型は参考にさせていただきました。
ForegroundとBackground
もしアプリのForeground・Backgroundになった時に処理をする必要があるならProcessLifecycleOwner
を使うことができます。
class App: Application(), LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onForeground() {
// Foregroundになったときの処理
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun onBackground() {
// Backgroundになったときの処理
}
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
}