Android Coden
Android 4 min lesen

Die One-Off-Events-Debatte in modernem Android

Transiente Effekte wie Navigation oder Snackbars sollen die UI genau einmal erreichen. Der One-Off-Events-Streit zeigt, warum das schwieriger ist als gedacht.

Transiente Effekte – Navigationsbefehle nach erfolgreichem Login, Snackbar-Hinweise bei Netzwerkfehlern, einmalige Vibrations-Impulse – stellen die Android-Architektur vor eine knifflige Frage: Wie überträgst du Signale, die die UI genau einmal verarbeiten soll, sicher durch alle App-Schichten? Diese scheinbar kleine Entscheidung hat in der Community zu einer der hartnäckigsten Debatten der letzten Jahre geführt.

Was ist das?

Ein One-Off Event (auch: transienter Effekt oder Side Effect) ist ein Signal, das aus dem ViewModel kommt, aber kein dauerhafter State ist. Der entscheidende Unterschied zu normalem UI-State liegt in der Vergänglichkeit: Sobald das Ereignis einmal konsumiert wurde, soll es verschwinden – und nicht beim nächsten Recompose oder nach einer Bildschirmrotation erneut ausgelöst werden.

Warum ist das schwierig? Jetpack Compose und der beobachtbare State-Mechanismus von StateFlow wurden für dauerhafte Zustandsrepräsentation gebaut. Schickst du über denselben Mechanismus etwas, das nur einmal gelten soll, kämpfst du gegen das Design des Frameworks. Genau hier beginnt die Debatte: Verschiedene Teams, Bibliotheken und selbst Google-Dokumente haben über die Jahre unterschiedliche Muster empfohlen. Das Verständnis der jeweiligen Kompromisse ist eine Kernkompetenz für fortgeschrittene Android-Entwicklung.

Wie funktioniert es?

Drei Ansätze dominieren die Diskussion.

Channel / SharedFlow

Der intuitivste Ansatz: Das ViewModel sendet Events über einen Channel oder SharedFlow, die UI sammelt sie in einem LaunchedEffect ein.

// ViewModel
val events = Channel<UiEvent>(Channel.BUFFERED)

fun onLoginSuccess() {
    viewModelScope.launch {
        events.send(UiEvent.NavigateToHome)
    }
}

Problem: Ist kein aktiver Subscriber vorhanden – zum Beispiel während die UI im Hintergrund liegt oder sich gerade neu aufbaut –, kann das Event stillschweigend verloren gehen. Channel.BUFFERED mildert das, löst es aber nicht vollständig. Nach einer Konfigurationsänderung, bei der eine neue UI-Instanz entsteht, bevor der Channel geleert wurde, wird das Event nie konsumiert.

StateFlow mit Consumed-Flag

Ein anderer Ansatz modelliert das Event als Teil des regulären UiState, markiert es aber nach dem Konsum als null oder über ein consumed = true-Flag:

data class LoginUiState(
    val isLoading: Boolean = false,
    val error: String? = null,
    val navigateToHome: Boolean = false
)

Der Vorteil: StateFlow verliert keinen Wert, weil er immer den letzten Stand hält. Der Nachteil: Der UiState wird mit UI-spezifischen Metadaten vermischt, und zwischen dem Setzen auf true und dem Zurücksetzen auf false kann es zu Race Conditions kommen.

Googles aktuelle Empfehlung

Die offiziellen Android-Architecture-Empfehlungen sprechen sich dafür aus, ViewModels keine rohen Events nach außen schicken zu lassen. Jede transiente Aktion soll als Teil des beobachtbaren States auftauchen, und die UI entscheidet, wann sie ihn umsetzt. Navigation wird idealerweise über einen dedizierten Zustand im ViewModel oder direkt über den NavController im Compose-Baum gesteuert, ohne dass das ViewModel ihn kennt.

In der Praxis

Das folgende Muster setzt Googles Empfehlung für einen einfachen Login-Flow um:

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

    fun onLoginSuccess() {
        _uiState.update { it.copy(navigateToHome = true) }
    }

    fun onNavigationHandled() {
        _uiState.update { it.copy(navigateToHome = false) }
    }
}
// Composable
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

LaunchedEffect(uiState.navigateToHome) {
    if (uiState.navigateToHome) {
        navController.navigate(Screen.Home.route)
        viewModel.onNavigationHandled()
    }
}

Typische Stolperfalle: Wenn du LaunchedEffect(uiState.navigateToHome) verwendest und vergisst, onNavigationHandled() aufzurufen, bleibt der Wert dauerhaft true. Beim nächsten Recompose – etwa wegen eines anderen State-Updates – wird LaunchedEffect erneut ausgeführt, und du landest in einer Navigationsschleife. Der Bug tritt oft erst nach einer Konfigurationsänderung auf und ist dann schwer zu reproduzieren.

Eine weitere Falle beim Channel-Ansatz: Nutzt du SharedFlow mit replay = 0 und collectAsState, verpasst die UI jeden Event, der gesendet wird, bevor der Collector aktiv ist. Das passiert garantiert beim ersten Aufbau des Composable oder nach einer schnellen Rotation.

Fazit

Die One-Off-Events-Debatte hat keine universelle Antwort, aber ein klares Leitprinzip: Modelliere transiente Effekte so nah an regulärem State wie möglich, damit der Lifecycle-sichere Mechanismus von StateFlow greift und kein Event verloren geht. Channels sind verlockend einfach, aber in Lifecycle-Grenzsituationen fehleranfällig. Schau dir dein aktuelles Projekt an: Welche Events werden heute über Channel oder SharedFlow gesendet? Schreibe einen Unit-Test, der prüft, ob das Event auch dann ankommt, wenn der Subscriber sich erst kurz nach dem Senden einschreibt – und ergänze einen Instrumented Test mit Bildschirmrotation. Du wirst überrascht sein, wie viele Apps genau diesen Fall nicht korrekt behandeln.

Quellen (4)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.