現代の UI で静的なものほとんどありません。UI 状態は、ユーザーが UI を操作したとき、またはアプリに新しいデータを表示する必要があるときに変化します。
このドキュメントでは、UI 状態の生成と管理に関するガイドラインを示します。本ドキュメントの目的は、以下を理解していただくことです。
- どの API を使って UI 状態を生成するか。これは、単方向データフローの原則に従い、状態ホルダーで利用可能な状態変化のソースがどのような性質かによって異なります。
- システム リソースを意識して UI 状態生成のスコープを決定する方法。
- UI が使用する UI 状態の公開方法。
基本的に、状態生成とは、こうした変化の増分を UI 状態に適用することです。状態は常に存在し、イベントの結果として変化します。次の表にイベントと状態の違いをまとめます。
イベント | 状態 |
---|---|
一過性、予測不可能、有限の期間だけ存在 | 常に存在 |
状態生成の入力 | 状態生成の出力 |
UI またはその他のソースから生成される | UI が使用する |
状態は「である」、イベントは「起きる」と覚えてください。下図は、イベント発生で状態が変化する様子を時系列で視覚化したものです。各イベントは対応する状態ホルダーが処理し、そうすることで状態が変化します。
イベントは以下から発生します。
- ユーザー: アプリの UI を操作したとき
- その他の状態変化のソース: UI(スナックバーのタイムアウト イベントなど)、ドメインレイヤ(ユースケースなど)、またはデータレイヤ(リポジトリなど)からのアプリデータを表示する API
UI 状態生成パイプライン
Android アプリにおける状態生成は、以下から構成される処理のパイプラインとみなすことができます。
- 入力: 状態変化のソース。次の場合があります。
- UI レイヤの内部: ユーザー イベント(例: タスク管理アプリでユーザーが「To-Do」のタイトルを入力する)や、UI 状態の変化をつかさどる UI ロジックにアクセスできるようにする API などです。たとえば、Jetpack Compose で
DrawerState
のopen
メソッドを呼び出すことが挙げられます。 - UI レイヤの外部: UI 状態が変化する原因となる、ドメインレイヤまたはデータレイヤのソースです。
NewsRepository
からの読み込みが完了したニュースなどのイベントです。 - 上記のすべてが混じり合ったもの。
- UI レイヤの内部: ユーザー イベント(例: タスク管理アプリでユーザーが「To-Do」のタイトルを入力する)や、UI 状態の変化をつかさどる UI ロジックにアクセスできるようにする API などです。たとえば、Jetpack Compose で
- 状態ホルダー: 状態変化のソースにビジネス ロジックや UI ロジックを適用し、ユーザー イベントを処理して UI 状態を生成するタイプ。
- 出力: ユーザーに必要な情報を提供するためにアプリがレンダリングすることができる UI 状態。
状態生成の 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 から選択できます。その際には次の点を考慮します。
- ライフサイクルを意識した方法で状態を読み取る。
- 状態ホルダーの 1 つまたは複数のフィールドで状態を公開すべきかどうか。
次の表は、入力とコンシューマーの組み合わせに対して、どの API を状態生成パイプラインに使うべきかをまとめたものです。
入力 | コンシューマー | 出力 |
---|---|---|
ワンショット API | View | StateFlow または LiveData |
ワンショット API | Compose | StateFlow または Compose State |
ストリーム API | View | StateFlow または LiveData |
ストリーム API | Compose | StateFlow |
ワンショット API とストリーム API | View | 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 レイヤでの状態の生成を示しています。このガイダンスを実践するためにご利用ください。
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- UI レイヤ
- オフラインファースト アプリの作成
- 状態ホルダーと UI の状態 {:#mad-arch}