
「AndroidのUI設計って、イベント駆動から状態駆動に完全にシフトしたよね」という話の続きです。
ViewModelから画面側(Compose)へ単発のイベントを伝えるとき、SharedFlowやChannelを使わずにUiStateだけで閉じた設計にしようとすると、だいたい次の3つのアプローチに行き着きます。
実務での使い分けを踏まえて整理してみました。
🤔 1. Single Event (nullable)
UiState の中に event: UiEvent? をひとつ持たせる一番シンプルで直感的なパターンです。
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
data class NavigateToDetail(val id: String) : UiEvent
data class ShowDialog(val type: DialogType) : UiEvent
}
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val event: UiEvent? = null,
)
// ViewModel
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun onSaveClicked() {
_uiState.update { it.copy(event = UiEvent.ShowSnackbar("保存しました")) }
}
fun onEventConsumed() {
_uiState.update { it.copy(event = null) }
}
// UI (Compose)
LaunchedEffect(uiState.event) {
when (val event = uiState.event) {
is UiEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
viewModel.onEventConsumed()
}
is UiEvent.NavigateToDetail -> {
navController.navigate("detail/${event.id}")
viewModel.onEventConsumed()
}
is UiEvent.ShowDialog -> viewModel.onEventConsumed()
null -> Unit
}
}
メリット
・ 構造がとにかくシンプル。フィールドも1つ、消費用コールの記述も1つで済む。
・ StateFlow だけで完結するので、Channel 管理の煩わしさやリークのリスクを回避できる。
・ uiState.value.event をアサートするだけで単体テストが書ける。
デメリット
・ ほぼ同時に複数のイベントが発生すると、最後のやつで上書きされて途中のイベントが消える。
・ 全く同じ内容のイベントが連続すると、data class の同値判定(equals)に引っかかって LaunchedEffect が再発火しない場合がある。
🤔 2. Queue + drop(1)
イベントをQueue(List
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val eventQueue: List<UiEvent> = emptyList(),
)
// ViewModel
private fun sendEvent(event: UiEvent) {
_uiState.update { it.copy(eventQueue = it.eventQueue + event) }
}
fun onSaveClicked() {
sendEvent(UiEvent.ShowSnackbar("保存しました"))
sendEvent(UiEvent.NavigateToDetail(savedId))
}
fun onEventConsumed() {
_uiState.update { it.copy(eventQueue = it.eventQueue.drop(1)) }
}
// UI (Compose)
LaunchedEffect(uiState.eventQueue) {
val event = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
when (event) {
is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
is UiEvent.ShowDialog -> { /* ... */ }
}
viewModel.onEventConsumed()
}
メリット
・ 複数イベントの連打や同時発火に対応でき、発生した順番通りに処理できる。
・ when 式の型チェック(exhaustive)をそのまま活かせる。
デメリット
・ UI側で onEventConsumed() を呼び忘れるとキューの先頭が残り続け、後続のイベントがすべて詰まる。
・ 単純に drop(1) しているだけなので、「今処理したイベント」と「実際に削除されたイベント」がずれるリスクが残る(同一内容のイベントが連続した場合など)。
🤔 3. Queue + ID matching
キュー構造に加えて、イベントごとに一意のID(System.nanoTime() など)を付与し、ID指定でピンポイントに消費させるパターンです。
data class QueuedEvent(
val id: Long = System.nanoTime(),
val event: UiEvent,
)
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val eventQueue: List<QueuedEvent> = emptyList(),
)
// ViewModel
private fun sendEvent(event: UiEvent) {
_uiState.update { it.copy(eventQueue = it.eventQueue + QueuedEvent(event = event)) }
}
fun onSaveClicked() {
sendEvent(UiEvent.ShowSnackbar("保存しました"))
sendEvent(UiEvent.NavigateToDetail(savedId))
}
fun onEventConsumed(id: Long) {
_uiState.update { state ->
state.copy(eventQueue = state.eventQueue.filterNot { it.id == id })
}
}
// UI (Compose)
LaunchedEffect(uiState.eventQueue.firstOrNull()?.id) {
val queued = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
when (val event = queued.event) {
is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
is UiEvent.ShowDialog -> { /* ... */ }
}
viewModel.onEventConsumed(queued.id)
}
メリット
・ 内容が全く同じイベントが並んでも、ID指定で削除するため誤削除が発生しない。
・ LaunchedEffect のキーにIDを渡せるため、二重処理や再発火漏れを極力防げる。
デメリット
・ ラッパークラスやID生成、filterNot の処理など、ボイラープレートコードが増える。
・ 通常のトースト表示や画面遷移程度なら、正味オーバースペック。
🤔 比較

🤔 現場での判断軸
プロダクト開発では、最初から凝りすぎず段階的に育てるアプローチが取りやすいです。
1, まずは「Single Event」で組む
8割方の画面はこれで十分事足ります。コードも簡潔で可読性が高く、チーム内での認知負荷も低く抑えられます。
2.「保存成功スナックバーを出しつつ元の画面に戻る」など、同時発生が要件になったら「Queue」へ移行
Single Eventの限界(上書き問題)が見えたタイミングでキュー構造にリファクタリングします。
3. 二重処理や損失が致命傷になる重要フロー(決済やアカウント削除など)のみ「ID matching」を入れる
堅牢性が必要な局所に絞ってピンポイントで採用するのが、過剰な抽象化(オーバーエンジニアリング)を防ぐコツです。
🤔 参考
Related Categories : Android・AndroidStudio・Developmemt・JetpackCompose・KMP・Kotlin・Kotlin Multiplatform Mobile