UI 状態生成

現代の UI で静的なものほとんどありません。UI 状態は、ユーザーが UI を操作したとき、またはアプリに新しいデータを表示する必要があるときに変化します。

このドキュメントでは、UI 状態の生成と管理に関するガイドラインを示します。本ドキュメントの目的は、以下を理解していただくことです。

  • どの API を使って UI 状態を生成するか。これは、単方向データフローの原則に従い、状態ホルダーで利用可能な状態変化のソースがどのような性質かによって異なります。
  • システム リソースを意識して UI 状態生成のスコープを決定する方法。
  • UI が使用する UI 状態の公開方法。

基本的に、状態生成とは、こうした変化の増分を UI 状態に適用することです。状態は常に存在し、イベントの結果として変化します。次の表にイベントと状態の違いをまとめます。

イベント 状態
一過性、予測不可能、有限の期間だけ存在 常に存在
状態生成の入力 状態生成の出力
UI またはその他のソースから生成される UI が使用する

状態は「である」、イベントは「起きる」と覚えてください。下図は、イベント発生で状態が変化する様子を時系列で視覚化したものです。各イベントは対応する状態ホルダーが処理し、そうすることで状態が変化します。

イベントと状態
図 1: イベントが原因となって状態が変化する

イベントは以下から発生します。

  • ユーザー: アプリの UI を操作したとき
  • その他の状態変化のソース: UI(スナックバーのタイムアウト イベントなど)、ドメインレイヤ(ユースケースなど)、またはデータレイヤ(リポジトリなど)からのアプリデータを表示する API

UI 状態生成パイプライン

Android アプリにおける状態生成は、以下から構成される処理のパイプラインとみなすことができます。

  • 入力: 状態変化のソース。次の場合があります。
    • UI レイヤの内部: ユーザー イベント(例: タスク管理アプリでユーザーが「To-Do」のタイトルを入力する)や、UI 状態の変化をつかさどる UI ロジックにアクセスできるようにする API などです。たとえば、Jetpack Compose で DrawerStateopen メソッドを呼び出すことが挙げられます。
    • UI レイヤの外部: UI 状態が変化する原因となる、ドメインレイヤまたはデータレイヤのソースです。NewsRepository からの読み込みが完了したニュースなどのイベントです。
    • 上記のすべてが混じり合ったもの。
  • 状態ホルダー: 状態変化のソースにビジネス ロジックUI ロジックを適用し、ユーザー イベントを処理して UI 状態を生成するタイプ。
  • 出力: ユーザーに必要な情報を提供するためにアプリがレンダリングすることができる UI 状態。
状態生成パイプライン
図 2: 状態生成パイプライン

状態生成の API

状態生成に使用される主な API は、パイプラインのステージに応じて次の 2 つがあります。

パイプラインのステージ API
入力 UI ジャンクをなくすために、非同期 API を使用して UI スレッド外で処理を実行することをおすすめします。たとえば、Kotlin ではコルーチンまたは Flow を使用し、Java プログラミング言語では RxJava またはコールバックを使用します。
出力 オブザーバブルなデータホルダーの API を使用し、状態が変化したときに UI を無効化して再レンダリングすることをおすすめします。たとえば、StateFlow、Compose State、LiveData などです。オブザーバブルなデータホルダーは、画面に表示される UI 状態を UI が常に持つことを保証します。

この 2 つの中で、入力に非同期 API を選ぶことは、出力にオブザーバブルな API を選ぶことよりも、状態生成パイプラインの性質に大きく影響します。これは、入力によって、パイプラインに適用できる処理の種類が決まるためです。

状態生成パイプラインの組み立て

以降のセクションでは、さまざまな入力に最適な状態生成の手法と、それに対応する出力 API について説明します。各状態生成パイプラインは、入力と出力の組み合わせであり、次のようになっていることが推奨されます。

  • ライフサイクル対応: UI が見えない状態にある場合や、UI がアクティブでない場合、状態生成パイプラインは、明示的に必要な場合を除き、リソースを消費すべきではありません。
  • 状態を使用しやすい: UI は、生成された UI 状態を簡単にレンダリングできるべきです。状態生成パイプラインの出力に関して考慮すべき点は、View システムや Jetpack Compose など、View の API によって異なります。

状態生成パイプラインにおける入力

状態生成パイプラインにおける入力は、以下のいずれかを通じて状態のソースを提供します。

  • 同期または非同期のワンショット オペレーション(suspend 関数の呼び出しなど)
  • ストリーム API(Flows など)
  • 上記すべて

以降のセクションでは、上記の各入力に対して状態生成パイプラインを作成する方法を説明します。

ワンショット API を状態変更のソースとする場合

MutableStateFlow API を、状態のオブザーバブルで変更可能なコンテナとして使用します。Jetpack Compose アプリの場合で、特に Compose のテキスト API を使用するときは、mutableStateOf もおすすめします。どちらの API にも、更新が同期か非同期かにかかわらず、ホストする値の安全でアトミックな更新ができるメソッドが用意されています。

たとえば、シンプルなサイコロ投げアプリで状態を更新する場合を考えてみましょう。ユーザーがサイコロを振るたびに、同期的な Random.nextInt() メソッドが呼び出され、その結果が UI 状態に書き込まれます。

StateFlow

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,
            )
        }
    }
}

Compose State

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

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

非同期呼び出しから UI 状態を変更する

非同期な結果を必要とする状態変更の場合は、適切な CoroutineScope でコルーチンを起動します。こうすることで、CoroutineScope がキャンセルされたときに処理を破棄できます。すると、状態ホルダーが suspend メソッドの呼び出しの結果を、UI 状態の公開に使用されるオブザーバブルな API に書き込みます。

たとえば、アーキテクチャ サンプルAddEditTaskViewModel について考えてみましょう。中断している saveTask() メソッドがタスクを非同期に保存すると、MutableStateFlow の update メソッドが状態変化を UI 状態に伝播します。

StateFlow

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

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

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Compose State

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

バックグラウンド スレッドから UI 状態を変更する

UI 状態の生成には、メイン ディスパッチャでコルーチンを起動することをおすすめします。つまり、以下のコード スニペットの withContext ブロックの外側ということです。ただし、別のバックグラウンド コンテキストで UI 状態を更新する必要がある場合は、次の API の使用をおすすめします。

  • withContext メソッドを使い、別の並行するコンテキストでコルーチンを実行する。
  • MutableStateFlow を使用する場合は、update メソッドを通常どおりに使用する。
  • Compose State を使用する場合は、Snapshot.withMutableSnapshot を使って、並行するコンテキストで State がアトミックに更新されることを保証する。

たとえば、以下の DiceRollViewModel のスニペットでは、SlowRandom.nextInt() が計算負荷の高い suspend 関数であり、CPU バウンドなコルーチンから呼び出す必要があると仮定しています。

StateFlow

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

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

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Compose State

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

ストリーム API を状態変更のソースとする場合

時間をかけて複数の値を生成する状態変化のソースの場合、状態生成を行うには、すべてのソースの出力を一つに集約するのが素直な方法です。

Kotlin の Flow を使用する場合、combine 関数でこれを実現できます。この例としては、InterestsViewModel の「Now in Android」のサンプルに、以下のコードがあります。

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

stateIn オペレータを使って StateFlows を作成すると、UI が見える状態にあるときにのみアクティブになればよいため、UI が状態生成パイプラインのアクティビティをきめ細かく制御できます。

  • SharingStarted.WhileSubscribed() は、UI が見える状態にある間にのみパイプラインをアクティブにし、ライフサイクルを意識した方法でフローを収集する必要がある場合に使用します。
  • SharingStarted.Lazily は、ユーザーが UI に戻る可能性がある限り、つまり UI がバックスタックにあるか、画面上にないタブにある限り、パイプラインをアクティブにする必要がある場合に使用します。

ストリーム ベースの状態ソースを集約することが当てはまらない場合には、Kotlin Flow のようなストリーム API に、マージフラット化など、ストリームを処理して UI 状態を生成するため変換が豊富に用意されています。

ワンショット API とストリーム API を状態変更のソースとする場合

状態生成パイプラインに状態変化のソースとしてワンショットの呼び出しとストリームの両方が使われている場合、ストリームが制約条件となります。したがって、ワンショット呼び出しをストリーム API に変換するか、その出力をストリームにつなげて上記のストリーム セクションの説明に従い処理を再開します。

フローの場合、これは通常、1 つ以上のプライベートなバッキング MutableStateFlow インスタンスを作成して状態変更を伝播することを意味します。Compose の状態からスナップショット フローを作成することもできます。

以下の architecture-samples リポジトリの TaskDetailViewModel を検討してください。

StateFlow

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Compose State

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

状態生成パイプラインの出力タイプ

UI 状態に対する出力 API の選択と、その表示の性質は、アプリで UI のレンダリングに使用する API に大きく左右されます。Android アプリでは、View と Jetpack Compose から選択できます。その際には次の点を考慮します。

次の表は、入力とコンシューマーの組み合わせに対して、どの API を状態生成パイプラインに使うべきかをまとめたものです。

入力 コンシューマー 出力
ワンショット API 視聴回数 StateFlow または LiveData
ワンショット API Compose StateFlow または Compose State
ストリーム API 視聴回数 StateFlow または LiveData
ストリーム API Compose StateFlow
ワンショット API とストリーム API 視聴回数 StateFlow または LiveData
ワンショット API とストリーム API Compose StateFlow

状態生成パイプラインの初期化

状態生成パイプラインの初期化では、パイプラインを実行するための初期条件を設定します。これには、パイプライン(例: ニュース記事の詳細を表示する id)の開始または非同期の読み込みの開始に不可欠な初期入力値の提供が含まれることがあります。

可能であれば、システム リソースを節約するために状態生成パイプラインを初期化することをおすすめします。実際には多くの場合、出力のコンシューマが存在するようになるまで待機することになります。Flow API では、stateIn メソッドの started 引数を使用して、これを行うことができます。これに該当しない場合は、状態生成パイプラインを明示的に開始するように、べき等initialize() 関数を定義します(以下のスニペットを参照)。

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

サンプル

次の Google サンプルは、UI レイヤでの状態の生成を示しています。このガイダンスを実践するためにご利用ください。