[Android] 003. 状態ホルダーとUI状態(UiState)

雰囲気で実装している感が若干あるUiState
Googleのサンプルでも時代と中の人のトレンドによって扱いが変わってきているのでちょっと見直し

2022/10/26あたりのGoogleの考え方
https://developer.android.com/topic/architecture/ui-layer/stateholders

状態の保存など
https://developer.android.com/develop/ui/compose/state

よくあるUiStateの実装

class HogeViewModel {
    private val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.asStateFlow()
    
    data class UiState(
        val isLoading: Boolean = false,
        val text: String = "huga"
    )

    init {
        _uiState.update(...)
    }
}

fun HogeView(vm: ViewModel) {
    val uiState by viewModel.uiState.collectAsState()
    ...
}

昔はcollectAsStateWithLifecycleはなかったのでcollectAsStateで呼び出していたけど、今はKMP向けとかではなければcollectAsStateWithLifecycleで呼び出すのがマスト

プラットフォームに依存しないコードの場合は、Android のみの collectAsStateWithLifecycle ではなく、collectAsState を使用します。
https://developer.android.com/develop/ui/compose/state#use-other-types-of-state-in-jetpack-compose

最近のNow in Android

ForYouScreenをベースと実装同じメソッド名にしてたり直接UiStateを部品に渡す作りはちょっと好みじゃないというか流派が違うけど、これが最新の中の人のトレンドっぽい
Now in Androidのサンプルの画面構成のためそうなってる感もあるけど、UiStateはViewModelとセットではなく、コンテンツごとに定義して状態ベースで表示内容を渡す作り

UiStateの状態取得はStateFlow上で実装

sealed interface OnboardingUiState {
    /**
     * The onboarding state is loading.
     */
    data object Loading : OnboardingUiState
    /**
     * The onboarding state was unable to load.
     */
    data object LoadFailed : OnboardingUiState

    /**
     * There is no onboarding state.
     */
    data object NotShown : OnboardingUiState

    /**
     * There is a onboarding state, with the given lists of topics.
     */
    data class Shown(
        val topics: List<FollowableTopic>,
    ) : OnboardingUiState {
        /**
         * True if the onboarding can be dismissed.
         */
        val isDismissable: Boolean get() = topics.any { it.isFollowed }
    }
}

class ForYouViewModel(...) {
    val onboardingUiState: StateFlow<OnboardingUiState> =
        combine(
            shouldShowOnboarding,
            getFollowableTopics(),
        ) { shouldShowOnboarding, topics ->
            if (shouldShowOnboarding) {
                OnboardingUiState.Shown(topics = topics)
            } else {
                OnboardingUiState.NotShown
            }
        }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = OnboardingUiState.Loading,
            )

}

internal fun ForYouScreen(...) {
    val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
    ForYouScreen(onboardingUiState)
}

internal fun ForYouScreen(
    onboardingUiState: OnboardingUiState
) {
    val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading

    LazyVerticalStaggeredGrid(...) {
        onboarding(
            onboardingUiState = onboardingUiState,
            ...
    }
}

private fun LazyStaggeredGridScope.onboarding(
    onboardingUiState: OnboardingUiState
) {
    when (onboardingUiState) {
        OnboardingUiState.Loading,
        OnboardingUiState.LoadFailed,
        OnboardingUiState.NotShown,
        -> Unit

        is OnboardingUiState.Shown -> {
        ...
}

どっちがベストプラクティスかってこともないと思います
Now in Androidのように一つの画面にいろいろなコンテンツ制御があって複雑でないなら、よくあるUiStateで十分で状態に対して表示される内容を分離して扱えるので、むしろ直感的に実装できると思います(たとえばUiStateにErrorの状態を追加した場合、画面に渡すデータはShownの内容と同じものを渡さないといけないのか…などいちいち考えなくてすむ)

以前のパターンとの違いと使い分け
以前の「UiStateは単一のdata classで、その中にローディングやエラーのフラグを持つ」というパターンも引き続き有効であり、多くのシンプルな画面で使われます。

単一のdata classパターンが向いている場合:
状態が比較的シンプルで、ローディング中やエラー時でも、一部のデータは常に表示され続けるような画面。
例えば、ユーザープロフィール画面で、名前やプロフィール画像は常に表示しつつ、詳細データ(過去の投稿履歴など)の読み込みがエラーになった場合にのみエラーメッセージを表示するようなケース。

sealed class/interfaceパターンが向いている場合:
UIが複数の明確な「フェーズ」を持ち、各フェーズでUIの表示内容や振る舞いが大きく異なる場合。
例えば、オンボーディング、フォーム入力(複数のステップがある場合)、データが完全に揃わないと何も表示できない画面など。
エラーが発生した場合に、それまでのデータは破棄してエラー状態に完全に遷移するような振る舞いをしたい場合(今回のオンボーディングの例ではLoadFailedがそれに当たります)。

ベンリダナー…


Android Studio Narwhal 2025.1.1, built on June 19, 2025