UI 状態生成

現代の 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 ジャンクをなくすために、コルーチンや Flow などの非同期 API を使用して UI スレッド外で処理を実行することをおすすめします。
出力 Compose State や StateFlow などのオブザーバブルなデータホルダーの API を使用し、状態が変化したときに UI を無効化して再レンダリングすることをおすすめします。オブザーバブルなデータホルダーは、画面に表示される UI 状態を UI が常に持つことを保証します。

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

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

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

  • ライフサイクル対応: UI が見えない状態にある場合や、UI がアクティブでない場合、 状態生成パイプラインは、明示的に必要な場合を除き、リソースを 消費すべきではありません。
  • 状態を使用しやすい: UI は、生成された UI 状態を簡単にレンダリングできるべきです。Jetpack Compose では、コンポーザブルは状態の変化に基づいて更新できるため、状態の使用は UI の中心となります。

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

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

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

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

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

観測可能なデータホルダーで状態を管理します。mutableStateOf API、 特に Compose のテキスト API を使用するときは、使用します。より複雑な状態 管理を行う場合や、他のアーキテクチャ コンポーネントと統合する場合は、 MutableStateFlow API を使用します。どちらの API にも、更新が同期か非同期かにかかわらず、ホストする値の安全でアトミックな更新ができるメソッドが用意されています。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 がバックスタックにあるか、画面上にないタブにある限り、パイプラインをアクティブにする必要がある場合に使用します。

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

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

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

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

TaskDetailViewModelarchitecture-samples リポジトリから検討してください。UI 状態は、現在のタスクのストリーム(_task)と、タスクが削除されたときに更新されるワンショット ソース(_isTaskDeleted)に依存します。このフラグは、ID が正しくないためにデータベースでタスクが見つからない場合と、ユーザーが削除したためにタスクが見つからない場合を区別するために必要です。

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

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

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

UI 状態に対する出力 API の選択と、その表示の性質は、アプリで UI のレンダリングに使用する API(Compose など)に大きく左右されます。 Jetpack Compose は、ネイティブ UI をビルドする際に推奨される最新ツールキットです。 その際には次の点を考慮します。

次の表は、Jetpack Compose を使用する場合に、どの API を状態生成パイプラインに使うべきかをまとめたものです。

入力 出力
ワンショット API StateFlow または Compose State
ストリーム API StateFlow
ワンショット API とストリーム API StateFlow

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

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

可能であれば、システム リソースを節約するために状態生成パイプラインを初期化することをおすすめします。実際には多くの場合、出力のコンシューマが存在するようになるまで待機することになります。Flow API では、started 引数 stateIn メソッドを使用して、これを行うことができます。これに該当しない場合は、状態 生成パイプラインを明示的に開始するように、 べき等である idempotent 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 レイヤでの状態の生成を示しています。このガイダンスを実践するためにご利用ください。

参考情報

UI 状態について詳しくは、以下のリソースをご覧ください。

ドキュメント

Views のコンテンツ