Android Architecture ComponentsのLiveData、使ってますか?
MVVMを構築する上でViewModel側でライフサイクルを意識する必要がなく非常に使い勝手が良いのですが、あくまでデータとViewのバインドを実現する仕組みなので一度だけ実行される仕組みには向いていません。
画面遷移やダイアログの表示等、ViewModelから一度だけ発行されるイベントをView(Activity)がどうハンドリングすべきなのか対策を考えてみました。
そもそもなにがどう向いていないか
LiveDataはバインドされたタイミングで値を流します。
AACのViewModelはActivityより生存期間が長いため、Activityが再生成されたタイミングで再バインドされることがあります。
つまり、そのままのLiveDataを一度しか発行しないイベントに対して使用するとActivityの再生成が走ったタイミングで再度発火してしまいます。
class MainActivity : AppCompatActivity() {
private lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_about)
viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
button.setOnClickListener {
viewModel.onClickButton()
}
viewModel.showDialog.observe(this, Observer {
showDialog(it) //←画面回転などでActivityが再生成したタイミングで値が流れてくるので、意図せずダイアログが再度表示されてしまう
})
}
private fun showDialog(value: String) { ... }
}
class MainViewModel(application: Application) : AndroidViewModel(application) {
val showDialog = MutableLiveData<String>() //←イベント発火後もずっと値を持ち続けて、再バインド時に値を流してしまう
fun onClickButton() {
showDialog.value = "Dialog!"
}
}
例えば、上記のコードはボタンをタップしてダイアログを表示して閉じた後に画面回転すると再度ダイアログが表示されてしまうといった弊害が発生します。
対策
LiveDataを拡張してタグの概念をもたせたLiveEventというクラスを作る
一度だけ通知されるLiveDataと聞いて大抵の人が期待するのは、登録したObserverに対してそれぞれ1回ずつ通知されるという点だと思うのでそれを満たすクラスを作成します。
Activityが再生成されても同一のObserverであると判断する材料が必要なので、Observerごとにタグをつける手間はありますがこでれほぼ期待通りの挙動が実現できます。
open class LiveEvent<T> : LiveData<T>() {
private val dispatchedTagSet = mutableSetOf<String>()
@MainThread
@Deprecated(
message = "Multiple observers registered but only one will be notified of changes. set tags for each observer.",
replaceWith = ReplaceWith("observe(owner, \"\", observer)"),
level = DeprecationLevel.HIDDEN
)
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
observe(owner, "", observer)
}
@MainThread
@Deprecated(
message = "Multiple observers registered but only one will be notified of changes. set tags for each observer.",
replaceWith = ReplaceWith("observeForever(\"\", observer)"),
level = DeprecationLevel.HIDDEN
)
override fun observeForever(observer: Observer<in T>) {
super.observeForever(observer)
}
@MainThread
open fun observe(owner: LifecycleOwner, tag: String, observer: Observer<in T>) {
super.observe(owner, Observer<T> {
val internalTag = owner::class.java.name + "#" + tag
if (!dispatchedTagSet.contains(internalTag)) {
dispatchedTagSet.add(internalTag)
observer.onChanged(it)
}
})
}
@MainThread
open fun observeForever(tag: String, observer: Observer<in T>) {
super.observeForever {
if (!dispatchedTagSet.contains(tag)) {
dispatchedTagSet.add(tag)
observer.onChanged(it)
}
}
}
@MainThread
open fun call(t: T?) {
dispatchedTagSet.clear()
value = t
}
}
通常の用意されているLiveData.observe()はobserverごとに1度通知という挙動を実現できず、混乱の元なのでdeprecated扱いにしてしまうのが良いかと思います。(この辺はお好みでですが)
ActivityやFragmentでLiveEventをobserve()する際にタグを個別に付けることでそれぞれ一度だけ通知されます。
class MainActivity : AppCompatActivity() {
private lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_about)
viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
button.setOnClickListener {
viewModel.onClickButton()
}
viewModel.showDialog.observe(this, "showDialog", Observer {
showDialog(it) //observerごとに別のタグをつけること。一つであれば空文字("")でも可
})
viewModel.showDialog.observe(this, "printLog", Observer {
print("dialog showed!") //同じLiveEventに対して複数のObserverを登録してもそれぞれ1度ずつコールされる
})
}
private fun showDialog(value: String) { ... }
}
class MainViewModel(application: Application) : AndroidViewModel(application) {
val showDialog = LiveEvent<String>() //←同一のタグを持つObserverに対して1度しか通知しない
fun onClickButton() {
showDialog.call("Dialog!")
}
}
値のないEventを通知するには
LiveDataを使ってViewへ通知したいけどそもそも渡す値すら無いよ!という場合は上のLiveEventを更に拡張してジェネリクスをUnit(Void)に固定してしまうことでシンプルに使えます。
もちろんLiveEventをそのまま使って毎回ジェネリクスにUnit(Void)をセットしても問題ないです。
class UnitLiveEvent : LiveEvent<Unit>() {
@MainThread
fun call() {
super.call(Unit)
}
@MainThread
@Deprecated(
message = "use call()",
replaceWith = ReplaceWith("call()"),
level = DeprecationLevel.HIDDEN
)
override fun call(t: Unit?) {
super.call(t)
}
}
LiveEventクラスの注意点
LiveDataの仕組みを考えれば当たり前なのですが、最新の値しか流さない点には注意して下さい。
例えばバインドされていない時にLiveEventへイベントを連続で2回流してもバインド時に最新の1回しか通知されません。
言い換えれば通知のキャッシュは一つまでという制限になります。
LiveData自体はRxのようなストリーム処理の仕組みではないのでそこはご注意を。
また余談ですが、メインスレッド以外からも呼べるLiveData.postValue()は短時間で連続でコールされると最新の値のみ流すのですべてのイベントが通知される保証はないとのこと。
なので上記のLiveEventではpostValue()を意図的に出来ないようにしてあります。(setValue()は同期処理なのでたぶん保証されるはず。間違ってたらすみません)
この辺はMVVM用のライブラリをやや強引にEventBusとして使おうとしている弊害かもですね。
参考:https://qiita.com/stsn/items/2bc6429c38dcde732ad8
今回採用しなかった案
このあとは対応策を思案する中で調べて出てきたり思いついたりしたけど個人的にイマイチだなと思って採用しなかった案なので興味がある方だけ見て下さいな。
場合によってはこっちのほうが適してるシーンもあるかもしれないです。
ボツ案1 そもそもLiveDataの用途に合わないのでListenerを使う
まずLiveDataの仕組み自体がMVVM、データとViewの整合性を保つための仕組みなのでEventBus的な使い方をすべきでないのかな?とも思いました。
そうなるとButtonイベントのようにinterfaceによるListenerモデルで通知してあげるのが一般的かなと思います。
interface MainViewModelListener {
fun showDialog(value: String)
}
しかしながらViewModelとの組み合わせで使う場合、LiveDataに劣る点(というか使用上の注意点)もいくつかあります。
- ライフサイクルを自分で管理する必要がある
- listenerの登録、解除を適切なタイミングで行わないとView側のNullPointerExceptionやIllegalStateExceptionでクラッシュすることがある
- Buttonイベントなどと違い、非同期処理を含む場合は常に意識する必要がある
- 通知のキャッシュが出来ないのでViewへ通知できなかった場合イベント自体が無視される
- ライフサイクルが適切に管理されていてもlistenerの解除中にイベントが飛ぶとViewが更新されないという現象が発生しうる
- LiveDataなら値変更時にバインドされていなくてもバインドされた時点で最新の情報が流れてくるので、変なタイミングで値変更してもコールしても適切なタイミングで通知される
ボツ案2 Android Architecture BlueprintsのSingleLiveEventを使う
Architecture Componentsの公式サンプルと言えるAndroid Architecture Blueprints内でSingleLiveEventというLiveDataを拡張したクラスが実装されています。
このクラスを導入することで使う側(ActivityやFragment)のコードを一切変更せず、一度だけ発行されるLiveDataとして振る舞うことが出来ます。
ただしこのクラスには一点問題点があり、コード内にも明記されていますが一つのobserverにしか通知することが出来ません。
複数のObserverに登録された場合、どれか一つにのみ通知が走ると言った具合です。
大抵の場合は1つのLiveDataに1つのObserverをセットすることが多いと思うので必要十分ではあるかもしれないですが、いざ複数のObserverにセットしてみたら動作しないというのは結構なトラップになるはずです。
現状の用途で問題はなくとも潜在的なバグを生みかねないので、出来れば人間が気をつけるのではなく仕組みで弾きたいところです。
ボツ案3 LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)で紹介されているEventクラスを使って通知したい変数をラップする
LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)の「Recommended: Use an Event wrapper」の項目で推奨されているやり方で、LiveDataにセットするジェネリクスパラメータをEventというクラスでラップするという手法が紹介されています。
詳しくは元記事を見てほしいのですが、observer側でそのイベントが発火済みかどうかを判定できる仕組みを導入しています。
ただこの手法にも問題があって、一度発行されたかどうかは判定できますが各Observerで1度ずつ発火すると言った仕組みは実現できません。
登録されているobserverすべてを通して1度目か否かしか判定できないので用途によっては合わないこともあると考えています。
また、変数をEventでラップしたりobserver側で判定式が必要になるためやや煩雑な感じがするのも否めません。
ボツ案4 SingleLiveEvent (LiveData) with multi observersで紹介されているSingleLiveEvent2を使う
SingleLiveEvent (LiveData) with multi observersにて中々にイケてるなーというクラスが紹介されていたのですが、このクラスで気になったのが最初にバインドされたLifecycleOwnerを常に参照し続けるという点です。
コードを読むと短いのですぐわかるのですが、LiveData内に内部Observerを持っていてそれが発火したタイミングで本来のバインドされた各Observerへ通知を行う仕組みになっており、その内部Observerの登録には一番最初に登録されたLifecycleOwnerを使っていました。
これにより懸念される問題としては、FragmentやActivityをまたいでSingleLiveEvent2を使う場合に最初にバインドしたOwnerが死んだり通知できない状態に陥るとすべてのObserverへ通知できない状況になる、または2つ目以降にバインドしたOwnerがViewが更新出来ないような状態(onStop後など)であっても無視されて通知されてしまう可能性があるかなと思います(状況再現が面倒くさかったので試してないですが。。)
一度しか通知しないLiveDataという用途においてはObserverとOwnerはセットで管理してそれぞれ通知されたか判定するのが良いのかなと感じました
ボツ案5 EventBusだけRxJavaを使う
RxJava(RxLifecycle)を使えばある程度ライフサイクルを意識せずに組むことが出来ますがLiveDataと同じようなことも出来、使い分けが煩雑になりそうなのでやめました。
LiveDataを使わずにRxJavaで全部組むというのであれば良い選択だと思います。
その他
他にも代案はいろいろあって、BroadcastReceiver、greenrobot/EventBus、DataBinding、等様々な選択肢があるので、スコープの広さや学習コストを鑑みてその時々に合わせた技術選定をするのがいいと思います。