前置き

Jetpack Compose では、一つの画面で、 UI 状態を一つだけ定義しておくことが一般的かと思います。

画面が複雑になっていき、 UI のプロパティが増えていくと、 combine 関数で Flow を合成できなくなることがあります。

これは、 combine 関数の引数が最大でも 5 であるため、それよりたくさんの Flow を直接 combine することができないためです。

そのような場合の一つの対応案のここに残します。

結論

UI 状態のプロパティを以下の 4 つのプロパティにまとめます (制限します) 。

  • UI ローカル状態
  • フォーム入力状態
  • 派生 UI 状態
  • 非同期取得データ

これらをすべて StateFlow で定義し、データの変更を監視します。

データの変更が検出されるたびに、 combine 関数で StateFlow 内の実データを UI 状態のプロパティにセットします。

これらの 4 つの状態の説明は最後の方で解説しています。

【追記】
後からふと思ったのですが、 UI ローカル状態とフォーム入力状態は
val xxx by remember{ mutableStateOf() }
として Jetpack Compose 側で保持するのもありですが、
その場合は、 ViewModel からはそのプロパティを参照できないため (※)
状況によって使い分ける必要があります。

(※)
そもそも ViewModel からアクセスする方法がないと思いますし、
Compose よりも ViewModel の方がライフサイクルが長いため、メモリリークが発生したり、
本来の依存関係が逆転してしまい、再利用ができなくなります。

実装例

実際に私が実装していたコードが以下になります。

後から思い出せるように「ポイントだけの抜粋」になっているため、要点の把握程度の参考にしてください。

UI 状態の定義

/**
 * UI で必要とされる全ての状態.
 *
 * 各上流 Flow のデータを加工した結果を状態として保持したい場合はここに定義する。
 */
data class DestinationEditUiState(
//    val transferDate: String = "",
//    val transferAmount: String = "",
    val uiLocalState: UiLocalState = UiLocalState(),
    val formInputState: FormInputState = FormInputState(),
    val derivedUiState: DerivedUiState = DerivedUiState(),
    val persistedDataState: PersistedDataState = PersistedDataState(
        sourceUiModels = emptyList(),
    ),
)

UI 状態の内部に保持する各データクラスの定義

/**
 * 永続化の対象外の UI 状態.
 *
 * 主に UI の見た目にのみ関わる状態を管理する。
 */
data class UiLocalState(
    val destErrorMessage: Int? = null,
    val sourceErrorMessage: Int? = null,
    val isLoading: Boolean = false,
    val dialog: DestinationEditDialog? = null,
)

/**
 * ユーザーが入力し、永続化の対象となる UI 状態.
 */
data class FormInputState(
    val sourceId: Int = 0,
    val inputType: DestInputType = DestInputType.Keyboard,
    val keyboardDestId: Int = 0,
    val keyboardDestName: String = "",
    val dialogDestId: Int = 0,
)

/**
 * 画面表示専用の派生状態.
 *
 * Read-Only の状態だけを扱う。
 */
data class DerivedUiState(
    val sourceName: String = "",
    val dialogDestName: String = "",
)

/**
 * データレイヤーから取得したデータ.
 */
data class PersistedDataState(
    val sourceUiModels: List<SourceSelectionUiModel>
)

各 StateFlow の定義


    private val _uiLocalState = MutableStateFlow(UiLocalState())

    private val _formInputState = MutableStateFlow(FormInputState())

    private val derivedUiState: StateFlow<DerivedUiState> = combine(
        sourceLabelsById,
        _formInputState
    ) { sourceLabelsById, formInputState ->
        val dialogDestLabel = when (formInputState.inputType) {
            DestInputType.SourceList -> sourceLabelsById[formInputState.dialogDestId]
            else -> null
        }

        DerivedUiState(
            sourceName = sourceLabelsById[formInputState.sourceId] ?: "",
            dialogDestName = dialogDestLabel ?: "",
        )
    }.stateIn(
        scope = viewModelScope,
        started = WhileUiSubscribed,
        initialValue = DerivedUiState(sourceName = "")
    )

    private val persistedAsync: StateFlow<Async<PersistedDataState>> =
        sourcesQueryUseCase.loadSources()
            .map { sources ->
                PersistedDataState(
                    sourceUiModels = sources.toSourceSelectionUiModel(),
                )
            }
            .map<PersistedDataState, Async<PersistedDataState>> { state ->
                Async.Success(state)
            }
            .catch {
                Log.e(TAG, "Failed to read trans sources.", it)
                emit(Async.Error(R.string.load_error))
            }
            .stateIn(
                scope = viewModelScope,
                started = WhileUiSubscribed,
                initialValue = Async.Loading
            )

combine 関数で UI 状態を生成する

    val uiState: StateFlow<DestinationEditUiState> =
        combine(
            _uiLocalState,
            _formInputState,
            derivedUiState,
            persistedAsync,
        ) { uiLocalState, formUiState, derivedUiState, persistedAsync ->
            when (persistedAsync) {
                is Async.Loading -> {
                    DestinationEditUiState(uiLocalState = UiLocalState(isLoading = true))
                }

                is Async.Error -> {
                    _eventChannel.send(UiEvent.ShowSnackbar(persistedAsync.errorMessage))
                    DestinationEditUiState()
                }

                is Async.Success -> {
                    DestinationEditUiState(
                        uiLocalState = uiLocalState.copy(isLoading = false),
                        formInputState = formUiState,
                        derivedUiState = derivedUiState,
                        persistedDataState = persistedAsync.data
                    )
                }
            }
        }.stateIn(
            scope = viewModelScope,
            started = WhileUiSubscribed,
            initialValue = DestinationEditUiState(uiLocalState = UiLocalState(isLoading = true))
        )

ポイント

4 つの UI 状態に制限したこと

4 つの UI 状態に制限したことで、 combine 関数の引数上限 (5) に収まるようになっています。

プロパティが増えても、そのプロパティは UI 状態に直接追加されることはなく、 combine 関数の引数からあふれることがありません。

どんなプロパティでも UI 状態に定義した 4 つのプロパティにだいたい収まる

UI 状態に定義した、以下 4 つプロパティの分け方をすると、多くのプロパティは、だいたいどれかに収まる。

  • UI ローカル状態
  • フォーム入力状態
  • 派生 UI 状態
  • 非同期取得データ

4 つのプロパティがそれぞれどのような意味なのか解説すると、

UI ローカル状態 は、主に UI の見た目にのみ関わる状態を管理します。例えば、長文テキストの折畳み状態などがあります。 DB やサーバーなどの不揮発領域に保存することがないのが特徴です。

フォーム入力状態 は、その名の通り、ユーザーが入力フォームに入力したデータを管理します。このデータは、これから不揮発領域に保存される可能性があるが、まだ保存されていないことが特徴です。

派生 UI 状態 は、他のプロパティをそのまま使うのではなく、何らかのデータの加工をしてから使用する状態を管理します。ユーザーが直接変更できるデータは、フォーム入力状態に分類されるため、派生 UI 状態には、ユーザーが直接変更できるデータは含まれません。

非同期取得データ は、その名の通り、不揮発領域 (データレイヤー) から取得したデータを管理します。

まとめ

現在作成中のアプリでは、最終的にはこの実装方針は採用しませんでしたが、今後のアプリでは採用の可能性もある気がするので、ここにメモとして残します。

設計では、 YAGNI (You aren’t gonna need it) という原則もある通り、過剰に一般化することは、逆に複雑さを増加させることが多いです。

この設計は、どちらかと言えば過剰寄りだと思います。しかし、特段複雑な画面など、無法地帯になるような場面では、力を発揮するのではないかとも感じます。