LoginSignup
20
20

More than 1 year has passed since last update.

UIの状態管理方法とViewModelイベントの実装例

Last updated at Posted at 2022-08-18

はじめに

2021年12月に公式のアーキテクチャガイドが大幅に更新されました。

この記事では、UI層の大まかな解説と、ViewModelイベントについて、LiveDataとFlowの2パターンでの実装例を示しながら解説を行います。

UI層の大まかな解説

公式のアーキテクチャガイドをおさらいしてみると、 UI層、Domain層、Data層の3つに分かれており、それぞれ一方向に依存していることが分かります。

この一方向に依存しているというのが、後から重要になってくるので覚えておいてください。
mad-arch-overview.png

さらに、UI層はViewを表示するUI elemntsと状態管理を行う State holdersに分かれていることが分かります。
mad-arch-overview-ui.png

このStateholderについて少し掘り下げて解説を行います。

State holdersとは

State holdersは状態変数を管理するレイヤーのことであり、状態変数をState holdersに集めることで、状態管理をスッキリと書くことができるようになります。

State holdersの例
data class UiState(
    // APIからの返り値を格納
    val data: APIResult?,
    // eventを格納
    val events: List<Event> = emptyList(),
    // loadingを格納
    val proceeding: Boolean
)

sealed interface Event {
    object Success : Event
    data class Error(val message: String) : Event
    object NextPage: Event
}

このように状態変数を1つにまとめることで、以下のように 1つの変数のみで状態管理を行うことができるようになります。

Flow
 private val _uiState = MutableStateFlow(UiState(//初期値))
LiveData
private val _uiState = MutableLiveData(UiState(//初期値))
val uiState: LiveData<UiState>
        get() = _uiState

ViewModelイベント

UIの基本構造を理解したところで、続いて大きく変更のあったViewModelイベントについて解説を行います。

ViewModelイベントとは、ViewModel側からUIの操作を行うことを意味しており、 UIの状態やライフサイクルに沿ってUIを更新する必要があるという注意点があります。

ゆえに、UIの状態を意識せずにコードを書くと以下のようなAntiPatternに遭遇してしまいます。

Anti Pattern

APIからErrorを受け取ったときにダイアログを表示するという例を用いて、ViewModelイベントでのAntiPatternを紹介します。

以下のような流れで実装をしてみたとします。

APIからErrorを受け取る → LiveDataにErrorの変数を格納する → LiveDataを監視して、ダイアログを表示する

実装例
以下のようなSealedClassを用意してErrorの変数を定義し、

UIState
// 結果を返すクラス
sealed class APIResult<out R> {
    // 成功した場合
    data class Success<out T>(val data: T) : APIResult<T>()

    // 失敗した場合
    data class Error(val exception: Throwable) : APIResult<Nothing>()
}

ViewModel側で以下のようなLiveDataにErrorを格納し、

ViewModel
// Errorを格納するLiveData
private val _result = MutableLiveData<APIResult?>(null)
val Result: LiveData<APIResult?>
    get() = _weather

これを監視して、Errorが生じていたときにダイアログを表示するという実装です。

UI
// LiveDataを監視
viewModel.result.observe(this) {
            // エラーが生じていた場合 -> エラー画像を表示
            is APIResult.Error -> {
                // ダイアログを表示する
                showNoticeDialog()
                Log.e("API fetch error", weather.exception.toString())
            }

このような実装は一見問題がないように見えますが、横画面にすると、ダイアログが2回重なって表示されてしまうという問題が生じてしまいます。

(2回タップしてダイアログを閉じている様子)

なぜこんなことが起きたのか

実はこの実装では、横画面になった時にErrorの変数がもう一度参照されてしまうために、ダイアログが再生成されてしまうのです。

詳しい解説をすると、横画面になったときに以下のようなライフサイクルを辿るため、2回ダイアログが表示されてしまうようです。

onSaveInstanceState() → Viewの状態を保存 (ここで以前表示されていたダイアログが保存される)
onDestroy() → Viewを破棄
onCreate() → Viewを生成(LiveDataに格納された変数を参照するため、新しくダイアログが生成される)
→ ダイアログを1個表示
onStart()
onRestoreInstanceState() → 保存した状態を復元(保存されたダイアログが復元される)
→ ダイアログを2個表示してしまった…
onResume()

このような状態を防ぐためには、ダイアログを表示するというViewModelの変数を、ダイアログが表示された後に破棄する(消費する)ような実装が必要になってきます。

イベント管理方法のBetterパターン

イベントを消費する方法について解説を行います。

かつての方法(現在は未推奨)

以前に推奨されていたイベント管理の方法は、このDeveloperブログによると

Eventクラスを用意して

Eventクラス
open class Event<out T>(private val content: T) {
    // イベントが消費されているかどうかフラグ
    var hasBeenHandled = false
        private set // 外から値の変更はできない

    // Eventがラップする値がまだ一度も消費されていない場合にnon-nullな値を返す関数
    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }
}

Event型でViewModelイベントをラップし、

ViewModel側
private val _navigateToDetails = MutableLiveData<Event<String>>()

val navigateToDetails : LiveData<Event<String>>
    get() = _navigateToDetails

UIでgetContentIfNotHandledの関数を行うことでイベントの消費を行うというものでした。

UI
// APIResultが来たときに発火する処理
viewModel.navigateToDetails.observe(this) {
    it.getContentIfNotHandled()?.let { APIResult ->
        handleWeatherUpdate(APIResult)
    }
}

イベントを消費する関数をEvent型の中に定義することで、適切に変数を破棄するという方法をとっています。

問題点

しかし、この方法では UIelements→Stateholders という単方向の依存が成り立たなくなってしまうという問題が生じてしまいます。(少し前までは許容されてたからややこしいよね…)

そこで、以下のような方法が提唱されています。

現時点でのBestパターン

公式の方法をさらにスッキリとまとめた記事がabe kenjiさんのブログ記事で紹介されているため、この記事を参考に現時点でのBestパターンを紹介します。

実装の流れ

以下のような流れでViewModelイベントを扱っていきます。

  1. 状態をSealed interfaceで意義する
  2. UIStateでEventを格納するListを定義する
  3. イベントを消費する関数を定義する
  4. イベントを発行する
  5. View側からイベントを消費する

Flowでの実装例

上の流れに沿って実装例を紹介します。
(Flowのコードはスタぜろさんの記事からの引用です)

1. 状態を定義する

まず、ViewModelイベントで扱う状態を定義します。

状態の定義
sealed interface Event {
    object Success : Event
    data class Error(val message: String) : Event
}

2. UIStateでEventを格納するListを定義する

Listに変数を格納することがポイント

UiStateに定義
data class UiState(
    // APIからの返り値を格納
    val data: APIResult?,
    // イベントを格納(ここ重要)
    val events: List<Event> = emptyList(),
    // ローディング画面を制御
    val proceeding: Boolean
)

3. ViewModelでEventを保持する

UIState内にまとめて1つの変数で保持するというのが定石でしたね(上で解説しました)

イベントを消費する関数を定義する
private val _uiState = MutableStateFlow(UiState(//初期値))
val uiState: StateFlow<UiState> = _uiState

4. イベントを消費する関数をViewModel内に定義する

イベントを消費する関数をViewModel内に定義するというのが単方向の依存においてポイントになります。

イベントを消費する関数を定義する
fun consumeEvent(event: Event) {
    val newEvents = uiState.events.filterNot { it == event }
    uiState = uiState.copy(events = newEvents)
}

4. ViewModelイベントを発行する

copy関数を用いてイベントの発行を行います。

ViewModelイベントを発行する
// APIから正しい返り値があった場合
val newEvents = uiState.events + Event.Success
_uiState.value.copy(events = newEvents, data = result.data)

// Errorが生じた場合
val newEvents = _uiState.value.events + (Event.Error(result.exception.toString()))
_uiState.value.copy(events = newEvents)

5. View側からイベントを消費する

View上でUIを更新した後に消費を行うことがViewModelEventの肝となる部分です。

View側からイベントを消費する
viewModel.uiState.events.firstOrNull()?.let { event ->
    when (event) {
        is Event.Success -> {
            LaunchedEffect(event) {
                snackbarHostState.showSnackbar("Success")
                // イベントを消費
                viewModel.consumeEvent(event)
            }
        }

LiveData

上で紹介した方法をLiveDataに落とし込むと以下のようになる。

ただし、初期値を代入できないためにnull対応がめんどくさくなっているので、StateFlowでの実装をおすすめします。

1. 状態を定義する

状態の定義
// Flowと同様のため省略

2. UIStateでEventを格納するListを定義する

UiStateに定義
// Flowと同様のため省略

3. ViewModel内でEventを保持する

イベントを消費する関数を定義する
private val _uiState = MutableLiveData(UiState(//初期値)
val uiState: LiveData<UiState>
    get() = _uiState

3. イベントを消費する関数を定義する

イベントを消費する関数を定義する
fun consumeEvent(event: Event) {
        val newEvents = _uiState.value?.events?.filterNot { it == event }
        _uiState.value = uiState.value?.copy(events = newEvents ?: emptyList())
}

4. イベントを発行する

イベントを発行する
// APIから正しい返り値があった場合
val newEvents = _uiState.value?.events?.plus(Event.Success)
_uiState.value?.copy(events = newEvents ?: emptyList(), data = result.data)

// Errorが生じた場合
val newEvents = _uiState.value?.events?.plus(Event.Error(result.exception.toString()))
_uiState.value?.copy(events = newEvents ?: emptyList())

5. View側からイベントを消費する

View側からイベントを消費する
viewModel.uiState.observe(this) { uiState ->
if (uiState.events.firstOrNull() != null) {
    when (val event = uiState.events.firstOrNull()) {
        is Event.Success -> {
            // イベントを消費
            viewModel.consumeEvent(event)
        }
        is Event.Error -> {
        ..
}

まとめ

  • UI層はUI elemntsと状態管理を行う State holdersに分かれている。
  • State holdersに状態変数をまとめることで、状態管理をスッキリと行うことができる。
  • ViewModelイベントの取り扱いには注意が必要であり、イベントの消費を行う必要がある。

最後まで読んでいただきありがとうございました。
よければtwitterのフォローよろしくお願いします。
(https://twitter.com/takashiho_2)

参考文献

20
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
20