🏗️

AndroidでViewModelのUiStateを再設計する

に公開

はじめに

最近Androidアプリ開発のViewModelで管理するUiStateの構造をもう少しいい感じにできないかと試行錯誤していました。

アプリの設計や、少しでも現状から保守運用しやすくなるきっかけになれたら嬉しいです。

UiStateについて

UiStateとは、ViewModelでStateを扱うときにサンプルコードでも度々登場するdata classのことを指しています。
https://developer.android.com/topic/architecture/ui-layer/state-production?hl=ja こちらの公式ドキュメントにある例を紹介します。

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

ViewModelで管理したいStateを1つのdata classにまとめています。
ViewModelではUiStateをStateFlowで持ってUIに公開しています。
なにかプロパティを更新したいときは_uiStateに対してupdateを実行してdata class.copyを使用することで実現しています。

UiStateで起きうる課題

実際のアプリでは読み込み中か、エラーがあったか、表示するコンテンツなどをUiStateでまとめると思います。

data class HomeUiState(
    val loading: Boolean,
    val error: Throwable?,
    val contentList: List<String>,
)

1つのdata classのプロパティに画面全体の全ての状態を持たせたとき、UIでのハンドリングが少し複雑になり、保守運用がしにくいことがあるなと感じていました。

さきほどのHomeUiStateを例に考えてみます。
各アプリの仕様に左右されるのであくまで一例ですが、UI側はこんな感じになるかもしれません。

@Composable
private fun HomeScreen(
    contentPadding: PaddingValues,
    uiState: HomeUiState,
) {
    Box(modifier = Modifier.padding(contentPadding)) {
        if (uiState.loading) {
            LoadingIndicator()
            return@Box
        }

        if (uiState.error != null) {
            Text("Error")
            return@Box
        }

        LazyColumn {
            items(uiState.contentList.size) { content ->
                Text(uiState.contentList[content])
            }
        }
    }
}

ローディングとエラーとそれ以外の画面の中身を切り替えています。
これはもしかしたら、すでにLazyColumnが表示されているときにPullToRefreshすると切り替えるのではなく、そのLazyColumn上に重ねてローディングを表示したい要件があるかもしれません。
そのときは以下のようにすれば実現できそうです。

@Composable
private fun HomeScreen(
    contentPadding: PaddingValues,
    uiState: HomeUiState,
) {
    Box(modifier = Modifier.padding(contentPadding)) {
        LazyColumn {
            items(uiState.contentList.size) { content ->
                Text(uiState.contentList[content])
            }
        }

        // LazyColumnの上に重ねて表示されるように移動
        if (uiState.loading) {
            LoadingIndicator()
        }

        if (uiState.error != null) {
            Text("Error") // Snackbarなど
        }
    }
}

これくらいであれば、1つのdata classのプロパティに全ての状態を持たせても問題ないと思います。

ですが、contentListが空のときはEmptyの表示もしたい、ログインのときと非ログインのときでUIを変えたい。contentListはログインのときだけ使用するのような仕様のとき工夫をせずに作ると、UIを切り替えるロジックがUIに寄っていきます。

@Composable
private fun HomeScreen(
    contentPadding: PaddingValues,
    uiState: HomeUiState,
) {
    Box(modifier = Modifier.padding(contentPadding)) {

        if (uiState.isLoggedIn) {
            if (uiState.contentList.isEmpty()) {
                Text("No content")
            } else {
                LazyColumn {
                    items(uiState.contentList.size) { index ->
                        Text(uiState.contentList[index])
                    }
                }
            }
        } else {
            Text("Not logged in")
        }

        if (uiState.loading) {
            LoadingIndicator()
        }

        if (uiState.error != null) {
            Text("Error") // Snackbarなど
        }
    }
}

サンプルなのでUIがシンプルですが、実際は意味のあるひとまとまりでprivateなComposableに切り出したり、行数が長くなったりして見通しが悪くなってきます。
サンプルの段階でもif文のネストが深くなる箇所も出てきて、宣言的UIならではのどこの{}でUIが閉じられているかわかりにくくなってきます。

更に、追加でログインしているユーザーの中でもプレミアムユーザーができてプレミアムユーザーは別のリスト表示をしたくなったり、リストの検索機能を作りたくなったりするとどんどんUIも複雑になってネストも更に深くなることが予想されます。

これらが自分が感じていた1つのdata classのプロパティで全ての状態を持たせたときの課題です。

nowinandroidのUiState

話を少し変えてここでnowinandroidがどうなっているのか確認したいと思います。
nowinandroid/feature/bookmarksを参考に見てみます。

BookmarksViewModelではfeedUiStateをUIに公開しています。
https://github.com/android/nowinandroid/blob/ba1a463498b6ac586708c334395af16ac035eb2b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt#L47-L55

NewsFeedUiStateがどのような構造になっているのか見てみます。
https://github.com/android/nowinandroid/blob/main/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt#L108-L123

data classではなく、sealed interfaceで設計しているようです。
状態がLoadingSuccessで切り替わるようで、Successのときは実際に表示するときに使うであろうfeedを持っています。

UIであるBookmarksScreenも確認してみます。

https://github.com/android/nowinandroid/blob/ba1a463498b6ac586708c334395af16ac035eb2b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt#L133-L146

when式でUIを切り替えています。
Sucessでは空のときのEmpty表示も行っているようです。

この程度なら気にならないですが、仕様によってはSucessの中で更にif文がネストする可能性があります。
また、ローディングのときは一度BookmarksGridからLoadingStateに完全に切り替わるようになっていますが、場合によってはコンテンツは表示したまま上に重ねてローディング表示したいときもあるかもしれません。

ですが、Successのラムダの中ではfeedState.feedにのみアクセスできて上手く関心の分離ができている印象を持ちました。
ローディングが終了してエラーも起きていない成功の場合は成功のためのUI構築に専念したいですよね。
data classのプロパティに全ての状態を持たせるとどの状態でも全てのプロパティにアクセスできてしまいました。

UiStateを再設計する

nowinandroidの設計を参考に考えてみます。
サンプルとして以下の仕様を実現してみようと思います。

- 画面は状態ごとに表示を切り替える
    - ローディング
    - エラー
    - コンテンツ
- コンテンツは条件で切り替わる
    - ログイン時
        - リストの表示
        - リストが空のときはEmpty表示
    - 未ログイン時
        - 未ログイン用の表示  

以下のようにUiStateを設計してみます。

sealed interface HomeUiState {
    data object Init : NewHomeUiState
    
    data object Loading : NewHomeUiState

    data class Error(val error: Throwable) : NewHomeUiState

    data class Success(
        val content: Content,
    ) : NewHomeUiState {
        sealed interface Content {
            data object Guest : Content

            data class Default(val contentList: List<String>) : Content

            data object Empty : Content
        }
    }
}

Sucessのときだけ画面の仕様に合うようにContentを持たせています。
これにより未ログインのGuest時とそれ以外のコンテンツを表示するとき、空の表示などを表現しています。
Contentは3つの表示状態があることや、Guest時はcontentListに関しては無関心なこと、そして画面大枠としてはLoadingErrorSuccessの状態があることがUiStateの構造を見るだけでわかるようになったと思います。

これをStateFlowでUIに公開しますが、UIではこのような記述になります。

@Composable
fun HomeScreen(
    uiState: HomeUiState,
) {
    when (uiState) {
        HomeUiState.Init -> {}
        is HomeUiState.Success -> HomeScreenContent(uiState.content)
        HomeUiState.Loading -> LoadingIndicator()
        is HomeUiState.Error -> Text("Error")
    }
}

@Composable
private fun HomeScreenContent(
    content: HomeUiState.Success.Content,
) {
    when (content) {
        is HomeUiState.Success.Content.Default -> LazyColumn {
            items(content.contentList.size) { index ->
                Text(content.contentList[index])
            }
        }

        HomeUiState.Success.Content.Guest -> Text("Guest")
        HomeUiState.Success.Content.Empty -> Text("Empty")
    }
}

それぞれの状態をwhen式で分岐していますが、それぞれどのようなロジックでsealed interfaceが分岐されているかはViewModelにカプセル化できています。
これによってUIでネストが深くなりにくくなりそうです。
機能改修する際もViewModelの中でどのようにUiStateを返すかだけを考えればいいので、仮にEmptyになる条件を改修したいときはViewModelを修正するだけで済みます。
UIはそのロジックを意識せず、ViewModelから公開されるUiStateに従うだけです。

個人的には宣言的UIの考え方であるUI=f(State)っぽい表現ができているなと感じています。
Flutterの公式ドキュメントでも紹介があります。

https://docs.flutter.dev/data-and-backend/state-mgmt/declarative

ViewModel内では、RepositoryからFlowが返ってくるのかなどプロジェクトごとによりますが、以下のようなイメージでUiStateの分岐ロジックを書いています。

HomeViewModel.kt
        viewModelScope.launch {
            _state.update {
                HomeUiState.Loading
            }
            getData().map { result ->
                result.fold(
                    onSuccess = { data ->
                        _state.update {
                            if (data.isEmpty()) {
                                HomeUiState.Success(
                                    HomeUiState.Success.Content.Empty,
                                )
                            } else {
                                HomeUiState.Success(
                                    HomeUiState.Success.Content.Default(data),
                                )
                            }
                        }
                    },
                    onFailure = { error ->
                        _state.update {
                            HomeUiState.Error(error)
                        }
                    },
                )
            }
        }

では、仕様が変わって画面の上にローディングやエラーを表示したくなったとします。

- 画面はコンテンツの上にローディングやエラーを表示する
- コンテンツは条件で切り替わる
    - ログイン時
        - リストの表示
        - リストが空のときはEmpty表示
    - 未ログイン時
        - 未ログイン用の表示  

常にローディングやエラーの状態にアクセスできるようにする必要があります。
そのため全体をsealed interfaceからdata classにしてプロパティにローディングやエラーと一緒に画面の中身のコンテンツの状態を持つように変更します。
Contentはさきほどと同じです。

data class HomeUiState(
    val loading: Boolean = false,
    val error: Throwable? = null,
    val content: Content = Content.Init,
) {
    sealed interface Content {
        data object Init : Content

        data object Guest : Content

        data class Default(val contentList: List<String>) : Content

        data object Empty : Content
    }
}

UI側は以下のようになります。
HomeScreenContentの上にローディングやエラー表示が可能になりました。
ここは多少エラー表示をSnackbarにするのかなどによって書き方は変わるかもしれません。

HomeScreen.kt
@Composable
fun HomeScreen(
    uiState: HomeUiState,
) {
    Box {
        HomeScreenContent(uiState.content)

        if (uiState.error != null) Text("Error")
        if (uiState.loading) LoadingIndicator()
    }
}

@Composable
private fun HomeScreenContent(
    content: HomeUiState.Content,
) {
    when (content) {
        is HomeUiState.Content.Default -> LazyColumn {
            items(content.contentList.size) { index ->
                Text(content.contentList[index])
            }
        }

        HomeUiState.Content.Empty -> Text("Empty")
        HomeUiState.Content.Guest -> Text("Guest")
        HomeUiState.Content.Init -> {}
    }
}

最後にViewModel側の実装です。
UiStateがdata classになったので、it.copyで状態を更新しています。

HomeViewModel.kt
        viewModelScope.launch {
            _state.update {
                it.copy(loading = true)
            }
            getData().map { result ->
                result.fold(
                    onSuccess = { data ->
                        _state.update {
                            if (data.isEmpty()) {
                                it.copy(
                                    loading = false,
                                    content = HomeUiState.Content.Empty,
                                )
                            } else {
                                it.copy(
                                    loading = false,
                                    content = HomeUiState.Content.Default(data),
                                )
                            }
                        }
                    },
                    onFailure = { error ->
                        _state.update {
                            it.copy(loading = false, error = error)
                        }
                    },
                )
            }
        }

今回は詳細に触れませんが、ViewModelのテストもRepositoryなどが返してくる値によって、HomeUiState.ContentEmptyなのかDefaultなのかなどをチェックすることができて、Contentが切り替わることを確認することになり見やすくなると思っています。
例えばGuestのときは他のContentのプロパティや状態にアクセスが制限されるので、Repositoryから返ってきたリストの値がemptyかどうかなど必要のないassertを書いてしまう可能性も排除することができます。

チーム開発でどう設計するか

UiStateを再設計する例を紹介してみましたが、チーム開発の場合はチーム状況によっては従来よりも考えることが増えて大変になるかもしれないと思っています。
主にContentの設計です。

例えばEmptyの表示を今回1つのContentの状態として分けました。
しかし、これは専用の状態を用意せずDefaultとまとめてUI側で分岐ロジックを書いてもいいのではないかという意見も出てくると思います。

HomeScreen.kt
@Composable
private fun HomeScreenContent(
    content: HomeUiState.Content,
) {
    when (content) {
        is HomeUiState.Content.Default -> {
            // UI側でisEmptyのチェック
            if (content.contentList.isEmpty()) {
                Text("Empty")
            } else {

                LazyColumn {
                    items(content.contentList.size) { index ->
                        Text(content.contentList[index])
                    }
                }
            }
        }

        HomeUiState.Content.Guest -> Text("Guest")
        HomeUiState.Content.Init -> {}
    }
}

実際のアプリではもっと状態も多様かつ複雑でこのようにどっちにするか迷うケースが多発するのではと思っています。
ここはチームごとにルールをよく考える必要があるかなと思っています。
Empty表示に関わるロジックが複数のRepositoryの結果から計算されたり、ロジックが複雑な場合はContentとしてEmptyの状態を用意してあげてViewModel内でロジックを書いたほうがやりやすいなどありそうです。

また、実装前にこの画面がEmptyがあって、Guest表示があって、、、となり得る状態を理解できている必要があります。
仕様理解が浅かったり、仕様をKotlinで表現するスキルがないとここは設計段階で時間が取られることが増えるかもしれません。
個人的にここは設計段階で考えられているべきで、ふわふわした状態で実装をしたほうが後で手戻りやバグにもなるので自然と考える時間がシフトレフトされることはいいことだと思っています。

しかし、新規開発など速度が極端に求められるプロジェクトでは少しやり過ぎ感が出ることもあるかもしれないです。

さいごに

今回はこれまでとりあえずdata classに画面の状態すべてをプロパティとして保持させていた作りを再設計することを考えてみました。
全てのエンジニアやプロジェクトに当てはまる完璧なやりかたは存在しないのですが、皆さんのアプリの設計がより良く、保守運用しやすいものになるきっかけになれば嬉しいです。

自分も検討段階なので、コメントなどいただいてブラッシュアップできたら嬉しいです。

Discussion