こうだ
※Developer options の Don't keep activities を有効にしています
SavedStateHandle
AndroidのライフサイクルではActivityインスタンスやFragmentインスタンスはいつ破棄されるかわかりません。
これらが持つViewModelインスタンスも同様です。
破棄され、再生成されるとそれまでのメモリー上のデータは破棄されます。これはよろしくありません。
これを避けるためにデータの保存/復元を行う必要があり、そのヘルパークラスであるSavedStateHandleを使うと楽に実装できるというのは以前書かせていただきました。
ViewModelのデータを保存/復元するにはSavedStateHandleを使う
Activityが再生成されると最初から読み込みし直し
上記のケースは、EditTextの入力値のような、データがViewModel内で完結するものでした。
では、データがリポジトリー層から取得される場合はどうすればいいのでしょうか?
例を示します。
class User(...): Parcelable() {
...
}
class UserRepository {
fun getUser(): LiveData<User> {
...
}
}
class MainViewModel(
private val userRepository: UserRepository
) {
val user: LiveData<User> = ? // userRepository.getUser()したいが…
}
上記のコードは UserRepository
の戻り値がLiveDataになっています。
SavedStateHandleは初期値にLiveDataを取ることはできませんから、SavedStateHandleは使えません。
「何もしない」のも1つの答えです。
通常、リポジトリー層自体が永続化の役割を持っているのですから、あえてViewModelで保存/復元を行わなくてもいいというものです。
class MainViewModel(
private val userRepository: UserRepository
) {
val user: LiveData<User> = userRepository.getUser() // 何も考えずに代入しとけばいいんだよ!
}
しかし、 LiveDataにデータがemitされるのに時間がかかる場合はどうでしょうか?
class UserRepository {
fun getUser(): LiveData<User> {
liveData(GlocalContext.coroutineContext + Dispatchers.IO) {
// サーバーとの通信やデータ整形などのとても時間のかかる処理
val user: User = veryHeavyProcess()
emit(user)
}
}
}
このような場合でも、最終的にはデータは表示されます。しかし再度読み込みが行われるため表示が完了するまで時間はかかります。ユーザーからしたらストレスになるでしょう。
// ---------- このへんから追記&修正 ----------
これを解決するためにはどこかでデータをキャッシュしておく必要があります。
リポジトリー層でキャッシュしておくこともできるでしょう。
実際、今回の例もリポジトリー層でキャッシュした方がいい雰囲気のコードです(※後で気づいたが例示として悪かった)。
しかし、リポジトリー層で取得したエンティティクラスを画面表示(プレゼンテーション)用にこねくり回している場合、こねくりまわしたデータは既にリポジトリー層の手から離れているためキャッシュ処理はViewModel層が受け持つよりほかありません。
// ---------- このへんまで ----------
したがって、 「画面にはgetUserを使いつつViewModelでgetUserを監視しキャッシュする。ViewModel再生成時はキャッシュした値を初期値として使い、遅延して読み込まれたgetUserの値を使う」 ということをする必要があります。
CachedLiveDataを作ったぞ
上記を解決するためのクラス CachedLiveData
および CachedLiveData
を簡単に生成できるSavedStateHandleの拡張関数を作りました。
CachedLiveData with SavedStateHandle extension
以下のように使います。
class MainViewModel(
private val handle: SavedStateHandle,
private val userRepository: UserRepository
) {
val user: LiveData<User> = handle.getCachedLiveData<User>("user", userRepository.getUser()) // これだけ
}
第1引数には SavedStateHandle#getLiveData
と同じくキー名を指定します。
第2引数には ソースとして使用したいLiveDataを指定します。
これだけで「画面にはgetUserを使いつつViewModelでgetUserを監視しキャッシュする。ViewModel再生成時はキャッシュした値を初期値として使い、遅延して読み込まれたgetUserの値を使う」ことをしてくれます。
結果は冒頭に貼った動画のとおりです。
Simple LiveDataが何も使わず単純に userRepository.getUser
を使った結果、 CachedLiveDataが CachedLiveData
を使った結果です。
両者画面表示が完了したところから動画は始まっています。
ホームボタンを押してランチャー画面を表示させ、アプリアイコンをタップしてアプリを再表示させたところ、Simple LiveDataの方はデータの再読み込みのため表示されるまでに時間がかかるのに対し、CachedLiveDataの方はデータが最初から表示されています。
これはCachedLiveDataによりデータがキャッシュされていたということです。
仕組み
CachedLiveData with SavedStateHandle extension
を見ていただければわかるとは思いますが、念のため仕組みの解説をしておきます。
fun <T : Any> SavedStateHandle.getCachedLiveData(key: String, source: LiveData<T>): LiveData<T> {
val cache = getLiveData<T>(key)
return CachedLiveData(source, cache)
}
private class CachedLiveData<T>(
source: LiveData<T>,
private val cache: MutableLiveData<T>
) : MediatorLiveData<T>() {
...
private var isCacheInSource = true
...
init {
addSource(source) {
if (isCacheInSource) {
removeSource(cache)
isCacheInSource = false
...
}
value = it
cache.value = it
}
addSource(cache) {
value = it
}
}
...
}
引数のsource
に userRepository.getUser
が、cache
には handle.getLiveData("user")
が代入されます。
CachedLiveData
は基本的には source
の中身が反映され続けます。
しかし source
の中身が最初に反映されるまでの間は cache
の中身が反映されます。
source
の中身が反映されるようになると cache
の中身を反映する処理は停止します。そして、source
の中身を cache
の中身として反映する動作を始めます。
上記のほか、CachedLiveDataの観測状態と cache
の観測状態を一致させる処理をつけたら完成です。
おわりに
良きViewModelライフを!