UI Events: Flüchtige Aktionen sauber von Zustand trennen
UI Events trennen flüchtige Benutzeraktionen von persistentem Zustand. Sie verhindern doppelte Navigation und inkonsistente UI-Zustände.
In modernen Android-Apps trennt die Architektur bewusst zwischen dem, was bleibt – dem UI-Zustand – und dem, was einmalig passiert: UI Events. Ein Klick auf einen Button, eine erfolgreiche Anmeldung oder ein Netzwerkfehler sind Ereignisse, die die UI kurz beeinflussen, danach aber vergessen werden sollen. Wer diesen Unterschied früh versteht, schreibt stabilen, testbaren Code und verhindert subtile Fehler wie doppelte Navigation oder endlos leuchtende Ladeindikatoren.
Was ist das?
UI Events bezeichnen in der Android-Architektur alle Signale, die aus einer Benutzeraktion oder einer Systemreaktion entstehen und eine einmalige Reaktion in der UI auslösen sollen. Im Gegensatz zum UI-Zustand (UiState), der kontinuierlich in einem StateFlow gehalten wird und den gesamten sichtbaren Zustand eines Screens beschreibt, sind UI Events transient: Sie werden einmal konsumiert und danach verworfen.
Typische Beispiele für UI Events sind:
- Navigationsanweisungen (“Gehe zum Detailscreen”)
- Einmalige Snackbar- oder Toast-Meldungen (“Gespeichert!”)
- Fokus- oder Scroll-Befehle
- Dialoge, die nach einer Aktion exakt einmal erscheinen sollen
Das offizielle Android-Architekturmodell unterteilt die App in drei Schichten: UI Layer, Domain Layer und Data Layer. UI Events entstehen typischerweise an der Grenze zwischen UI und ViewModel. Die UI löst eine Aktion aus – etwa onLoginClicked() – und das ViewModel entscheidet, ob daraus eine dauerhafte Zustandsänderung, ein einmaliges Event oder beides folgt. Diese Trennung ist keine kosmetische Konvention, sondern verhindert eine ganze Klasse von Laufzeitfehlern.
Wie funktioniert es?
Das ViewModel ist der zentrale Ort, an dem UI Events erzeugt und nach oben geschickt werden. Die empfohlene Infrastruktur ist ein Channel<T> (bei genau einem Empfänger) oder ein SharedFlow<T> (bei mehreren Empfängern). Beide ermöglichen es, Werte zu senden, ohne sie dauerhaft zu speichern.
Channel vs. SharedFlow
Ein Channel mit der Kapazität BUFFERED garantiert, dass Events nicht verloren gehen, wenn die UI kurz nicht sammelt – zum Beispiel während eines Recompose. Ein SharedFlow mit replay = 0 eignet sich, wenn mehrere Composables denselben Event beobachten sollen.
class LoginViewModel : ViewModel() {
private val _events = Channel<LoginEvent>(capacity = Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun onLoginClicked(email: String, password: String) {
viewModelScope.launch {
val success = authRepository.login(email, password)
if (success) {
_events.send(LoginEvent.NavigateToHome)
} else {
_events.send(LoginEvent.ShowError("Anmeldung fehlgeschlagen"))
}
}
}
}
sealed interface LoginEvent {
data object NavigateToHome : LoginEvent
data class ShowError(val message: String) : LoginEvent
}
In der Compose-UI sammelst du den Flow mit LaunchedEffect:
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> navController.navigate("home")
is LoginEvent.ShowError -> Toast.makeText(
context, event.message, Toast.LENGTH_SHORT
).show()
}
}
}
LaunchedEffect(Unit) stellt sicher, dass der Collector während der gesamten Lebensdauer des Composable aktiv bleibt, aber nicht bei jedem Recompose neu gestartet wird. Das ist entscheidend: Ein Neustart würde laufende Coroutinen abbrechen und bereits gepufferte Events könnten verloren gehen.
In der Praxis
Das häufigste Antipattern ist, UI Events als Teil des UiState zu modellieren:
// Falsch: Event als persistenter State
data class LoginUiState(
val isLoading: Boolean = false,
val navigateToHome: Boolean = false, // Event versteckt als State
val errorMessage: String? = null
)
Das Problem: StateFlow hält immer den letzten Wert. Wenn der Screen nach einer Gerätedrehung neu zusammengestellt wird, liefert collectAsState() sofort wieder navigateToHome = true – und die App navigiert ein zweites Mal zum Home-Screen. Du müsstest den Wert nach dem Konsumieren manuell zurücksetzen (navigateToHome = false), was zu Race Conditions und doppeltem Boilerplate führt.
Stolperfalle: Viele Teams bemerken diesen Fehler erst spät – typischerweise durch Nutzer-Reports (“Die App springt manchmal zurück”) oder durch flackernde Dialoge im Emulator. In Unit-Tests ist er kaum sichtbar, weil keine Konfigurationsänderungen simuliert werden.
Die Daumenregel lautet: Alles, was nach einem Neustart des Screens nicht mehr sichtbar sein soll, ist ein Event – alles andere ist State. Eine Fehlermeldung, die dauerhaft unter einem Textfeld steht, ist State. Eine Snackbar, die einmalig erscheint und dann verschwindet, ist ein Event.
Das Android-Architecture-Recommendations-Dokument von Google empfiehlt explizit, einmalige Events nicht als persistenten State zu modellieren und stattdessen auf Channel oder SharedFlow zu setzen. Die Trennung zahlt sich besonders bei Navigationsgraphen aus: Wenn jeder Navigationsbefehl ein Event ist, lässt sich der gesamte Navigationsfluss einer Funktion in einem einzigen ViewModel-Test überprüfen, ohne einen echten NavController zu benötigen.
Fazit
UI Events sind ein kleines, aber entscheidendes Konzept in der modernen Android-Architektur. Sie trennen flüchtige Benutzeraktionen sauber vom dauerhaften UI-Zustand und verhindern eine ganze Klasse subtiler Fehler rund um Navigation und Einmal-Dialoge. Überprüfe in deinem nächsten Projekt, ob dein ViewModel Boolean-Felder wie navigateTo... oder showDialog im UiState trägt – wenn ja, handelt es sich wahrscheinlich um verschleierte Events, die in einen Channel oder SharedFlow gehören. Schreibe anschließend einen Unit-Test, der sicherstellt, dass dein Navigations-Event nach einem Klick genau einmal emittiert wird: Dieser Test ist oft die schnellste Methode, um das Antipattern aufzudecken, bevor es in der Produktion auffällt.