UI-Ereignisse

UI-Ereignisse sind Aktionen, die in der UI-Schicht verarbeitet werden sollten, entweder von der UI oder vom ViewModel. Die häufigste Art von Ereignissen sind Nutzerereignisse. Der Nutzer löst Nutzerereignisse aus, indem er mit der App interagiert, z. B. durch Tippen auf den Bildschirm oder durch Gesten. Die Benutzeroberfläche verwendet diese Ereignisse dann über Callbacks wie Lambdas, die für verschiedene Composables definiert sind.

Das ViewModel ist normalerweise für die Verarbeitung der Geschäftslogik eines bestimmten Nutzerereignisses verantwortlich, z. B. wenn der Nutzer auf eine Schaltfläche klickt, um einige Daten zu aktualisieren. Normalerweise übernimmt das ViewModel diese Aufgabe, indem es Funktionen bereitstellt, die von der UI aufgerufen werden können. Nutzerereignisse können auch eine Logik für das UI-Verhalten haben, die das UI direkt verarbeiten kann, z. B. das Navigieren zu einem anderen Bildschirm oder das Anzeigen eines Snackbar.

Die Geschäftslogik bleibt für dieselbe App auf verschiedenen mobilen Plattformen oder Formfaktoren gleich, die Logik für das UI-Verhalten ist jedoch ein Implementierungsdetail, das sich in diesen Fällen unterscheiden kann. Auf der Seite UI-Ebene werden diese Arten von Logik so definiert:

  • Geschäftslogik bezieht sich darauf, was bei Zustandsänderungen zu tun ist, z. B. eine Zahlung vornehmen oder Nutzereinstellungen speichern. Diese Logik wird in der Regel von der Domain- und der Datenschicht verarbeitet. In diesem Leitfaden wird die Klasse Architecture Components ViewModel als empfohlene Lösung für Klassen verwendet, die Geschäftslogik verarbeiten.
  • UI-Verhaltenslogik oder UI-Logik bezieht sich darauf, wie Zustandsänderungen angezeigt werden, z. B. Navigationslogik oder wie Nachrichten für den Nutzer angezeigt werden. Die Benutzeroberfläche übernimmt diese Logik.

Entscheidungsbaum für UI-Ereignisse

Das folgende Diagramm zeigt einen Entscheidungsbaum, mit dem Sie den besten Ansatz für die Verarbeitung eines bestimmten Anwendungsfalls für Ereignisse ermitteln können. Im weiteren Verlauf dieses Leitfadens werden diese Ansätze im Detail erläutert.

Wenn das Ereignis im ViewModel entstanden ist, aktualisieren Sie den UI-Status. Wenn das Ereignis in der Benutzeroberfläche ausgelöst wurde und Geschäftslogik erfordert, delegieren Sie die Geschäftslogik an das ViewModel. Wenn das Ereignis in der Benutzeroberfläche ausgelöst wurde und eine Logik für das Verhalten der Benutzeroberfläche erfordert, ändern Sie den Status des UI-Elements direkt in der Benutzeroberfläche.
Abbildung 1. Entscheidungsbaum für die Verarbeitung von Ereignissen.

Nutzerereignisse verarbeiten

Die Benutzeroberfläche kann Nutzerereignisse direkt verarbeiten, wenn sie sich auf die Änderung des Status eines UI-Elements beziehen, z. B. des Status eines minimierbaren Elements. Wenn für das Ereignis Geschäftslogik ausgeführt werden muss, z. B. das Aktualisieren der Daten auf dem Bildschirm, sollte es vom ViewModel verarbeitet werden.

Im folgenden Beispiel wird gezeigt, wie verschiedene Schaltflächen verwendet werden, um ein UI-Element (UI-Logik) zu maximieren und die Daten auf dem Bildschirm zu aktualisieren (Geschäftslogik):

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
        // The expand details event is processed by the UI that
        // modifies this composable's internal state.
        onClick = { expanded = !expanded }
        ) {
        val expandText = if (expanded) "Collapse" else "Expand"
        Text("$expandText details")
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the UI's business logic.
        Button(onClick = { viewModel.refreshNews() }) {
            Text("Refresh data")
        }
    }
}

Nutzerereignisse in Lazy Lists

Wenn die Aktion weiter unten im UI-Baum, z. B. in einem LazyColumnitem, generiert wird, sollte das ViewModel weiterhin Nutzerereignisse verarbeiten.

Betrachten wir beispielsweise eine Liste mit klickbaren Elementen. Übergeben Sie die ViewModel-Instanz nicht an die List-Composable-Funktion (MyList), da dadurch die UI-Komponente eng an die Implementierungsdetails gekoppelt wird.

Stellen Sie das Ereignis stattdessen als Lambda-Funktionsparameter im Composable zur Verfügung. So kann die Liste das Ereignis auslösen, ohne dass bekannt ist, wer oder wie es verarbeitet wird.

data class MyItem(val id: Int)

@Composable
fun MyList(
    items: List<String>,
    onItemClick: (MyItem) -> Unit
) {
    Card {
        LazyColumn {
            itemsIndexed(items) { index, string ->
                ListItem(
                    modifier = Modifier.clickable {
                        onItemClick(MyItem(index))
                    },
                    headlineContent = {
                        Text(text = string)
                    }
                )
            }
        }
    }
}

Bei diesem Ansatz funktioniert die zusammensetzbare MyList nur mit den Daten, die sie anzeigt, und den Ereignissen, die sie bereitstellt. Es hat keinen Zugriff auf das ViewModel. Das Ereignis wird gehoistet und an ein ViewModel in einem vorherigen Composable übergeben.

Weitere Informationen zur Ereignisverarbeitung finden Sie unter Ereignisse in Compose.

Namenskonventionen für Nutzerereignisfunktionen und ‑handler

In diesem Leitfaden werden die ViewModel-Funktionen, die Nutzerereignisse verarbeiten, mit einem Verb benannt, das auf der Aktion basiert, die sie verarbeiten, z. B. validateInput() oder login().

Event-Handler in Compose folgen einer Standardnamenskonvention, um den Datenfluss zu verdeutlichen:

  • Parametername:on + Verb + Target (z. B. onExpandClicked oder onValueChange).
  • Lambda-Ausdruck:Beim Aufrufen der Composable ist das Lambda oft nur die Implementierung dieses Ereignisses.

ViewModel-Ereignisse verarbeiten

UI-Aktionen, die vom ViewModel ausgehen – ViewModel-Ereignisse – sollten immer zu einer Aktualisierung des UI-Status führen. Dies entspricht den Prinzipien des unidirektionalen Datenflusses. So können Ereignisse nach Konfigurationsänderungen reproduziert werden und UI-Aktionen gehen nicht verloren. Optional können Sie Ereignisse auch nach dem Beenden des Prozesses reproduzierbar machen, wenn Sie das Modul für den gespeicherten Status verwenden.

Die Zuordnung von UI-Aktionen zum UI-Status ist nicht immer einfach, führt aber zu einer einfacheren Logik. Ihr Denkprozess sollte nicht damit enden, dass Sie festlegen, wie die Benutzeroberfläche zu einem bestimmten Bildschirm navigiert. Sie müssen weiterdenken und überlegen, wie Sie diesen Nutzerfluss in Ihrem UI-Status darstellen. Mit anderen Worten: Denken Sie nicht darüber nach, welche Aktionen die Benutzeroberfläche ausführen muss, sondern darüber, wie sich diese Aktionen auf den UI-Status auswirken.

Betrachten wir beispielsweise einen Anmeldebildschirm. Sie könnten den UI-Zustand dieses Bildschirms so modellieren:

data class LoginUiState(
    val isLoginInProgress: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

Der Anmeldebildschirm reagiert auf Änderungen am UI-Status.

class LoginViewModel : ViewModel() {

    var uiState by mutableStateOf(LoginUiState())

    fun tryLogin(username: String, password: String) {
        viewModelScope.launch {
            // Emit a new state indicating that login is in progress
            uiState = uiState.copy(isLoginInProgress = true)

            uiState = if (login(username, password)) {
                // Emit a new state indicating that login was successful
                uiState.copy(isLoginInProgress = false, isUserLoggedIn = true)
            } else {
                // Emit a new state with the error message
                LoginUiState(isLoginInProgress = false, errorMessage = "Login failed")
            }
        }
    }

    private suspend fun login(username: String, password: String): Boolean {
        delay(1000)
        return (username == "Hello" && password == "World!")
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel, onSuccessfulLogin: () -> Unit) {

    val uiState = viewModel.uiState

    LaunchedEffect(uiState) {
        if (uiState.isUserLoggedIn) {
            onSuccessfulLogin()
        }
    }

    if (uiState.isLoginInProgress) {
        CircularProgressIndicator()
    } else {
        LoginForm(
            onLoginAttempt = { username, password ->
                viewModel.tryLogin(username, password)
            },
            errorMessage = uiState.errorMessage
        )
    }
}

Durch den Empfang von Ereignissen können Statusaktualisierungen ausgelöst werden.

Wenn bestimmte ViewModel-Ereignisse in der Benutzeroberfläche verarbeitet werden, kann dies zu anderen Aktualisierungen des UI-Status führen. Wenn beispielsweise kurzzeitig Meldungen auf dem Bildschirm angezeigt werden, um den Nutzer darüber zu informieren, dass etwas passiert ist, muss die Benutzeroberfläche das ViewModel benachrichtigen, damit eine weitere Statusaktualisierung ausgelöst wird, wenn die Meldung auf dem Bildschirm angezeigt wurde. Das Ereignis, das eintritt, wenn der Nutzer die Nachricht gesehen hat (durch Schließen oder nach einem Zeitlimit), kann als „Nutzereingabe“ behandelt werden. Das ViewModel sollte sich dessen bewusst sein. In diesem Fall kann der UI-Status so modelliert werden:

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

Das ViewModel würde den UI-Status so aktualisieren, wenn die Geschäftslogik erfordert, dass dem Nutzer eine neue temporäre Nachricht angezeigt wird:

class LatestNewsViewModel(/* ... */) : ViewModel() {

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

Das ViewModel muss nicht wissen, wie die Meldung auf dem Bildschirm angezeigt wird. Es weiß nur, dass eine Nutzermeldung angezeigt werden muss. Nachdem die temporäre Meldung angezeigt wurde, muss die UI das ViewModel darüber informieren. Dadurch wird ein weiterer UI-Status aktualisiert, um die userMessage-Eigenschaft zu löschen:

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

Auch wenn die Meldung vorübergehend ist, spiegelt der UI-Zustand zu jedem Zeitpunkt genau das wider, was auf dem Bildschirm angezeigt wird. Entweder wird die Nutzernachricht angezeigt oder nicht.

Im Abschnitt Durch das Verarbeiten von Ereignissen können Statusaktualisierungen ausgelöst werden wird beschrieben, wie Sie den UI-Status verwenden, um Nutzern Nachrichten auf dem Bildschirm anzuzeigen. Navigationsereignisse sind ebenfalls ein häufiger Ereignistyp in einer Android-App.

Wenn das Ereignis in der Benutzeroberfläche ausgelöst wird, weil der Nutzer auf eine Schaltfläche getippt hat, wird es über die Benutzeroberfläche für das aufrufende Composable verfügbar gemacht.

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the help screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI
    Button(
        onClick = dropUnlessResumed { onHelp() }
    ) {
        Text("Get help")
    }
}

dropUnlessResumed ist Teil der Lifecycle-Bibliothek und ermöglicht es Ihnen, die Funktion onHelp nur auszuführen, wenn der Lebenszyklus mindestens RESUMED ist.

Wenn für die eingegebenen Daten eine Validierung der Geschäftslogik erforderlich ist, bevor navigiert werden kann, muss das ViewModel diesen Status für die Benutzeroberfläche verfügbar machen. Die Benutzeroberfläche würde auf diese Statusänderung reagieren und entsprechend navigieren. Dieser Anwendungsfall wird im Abschnitt „ViewModel-Ereignisse verarbeiten“ behandelt. Hier ist ein ähnlicher Code:

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.tryLogin()
        }
    ) {
        Text("Log in")
    }
    // Rest of the UI

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
    LaunchedEffect(viewModel, lifecycle)  {
        // Whenever the uiState changes, check if the user is logged in and
        // call the `onUserLogin` event when `lifecycle` is at least STARTED
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                currentOnUserLogIn()
            }
    }
}

Im obigen Beispiel funktioniert die App wie erwartet, da das aktuelle Ziel „Login“ nicht im Back Stack gespeichert wird. Nutzer können nicht mehr darauf zurückgreifen, wenn sie die Zurück-Taste drücken. In Fällen, in denen das passieren könnte, wäre für die Lösung jedoch zusätzliche Logik erforderlich.

Wenn ein ViewModel einen Zustand festlegt, der ein Navigationsereignis von Bildschirm A zu Bildschirm B erzeugt, und Bildschirm A im Navigations-Backstack verbleibt, benötigen Sie möglicherweise zusätzliche Logik, damit nicht automatisch zu B gewechselt wird. Dazu benötigen Sie einen zusätzlichen Zustand, der angibt, ob die Benutzeroberfläche zum anderen Bildschirm wechseln soll. Normalerweise wird dieser Status in der Benutzeroberfläche gespeichert, da die Navigationslogik zur Benutzeroberfläche gehört und nicht zum ViewModel. Das folgende Beispiel veranschaulicht diesen Aspekt.

Angenommen, Sie befinden sich im Registrierungsablauf Ihrer App. Auf dem Validierungsbildschirm Geburtsdatum wird das vom Nutzer eingegebene Datum vom ViewModel validiert, wenn der Nutzer auf die Schaltfläche „Weiter“ tippt. Das ViewModel delegiert die Validierungslogik an die Datenschicht. Wenn das Datum gültig ist, wird dem Nutzer der nächste Bildschirm angezeigt. Als zusätzliche Funktion können Nutzer zwischen den verschiedenen Registrierungsbildschirmen hin- und herwechseln, falls sie einige Daten ändern möchten. Daher befinden sich alle Ziele im Registrierungsablauf im selben Backstack. Angesichts dieser Anforderungen könnte der Bildschirm so aussehen:

class DobValidationViewModel(/* ... */) : ViewModel() {
    var uiState by mutableStateOf(DobValidationUiState())
        private set
}

@Composable
fun DobValidationScreen(
    onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
    viewModel: DobValidationViewModel = viewModel()
) {
    // TextField that updates the ViewModel when a date of birth is selected

    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(
        onClick = {
            viewModel.validateInput()
            validationInProgress = true
        }
    ) {
        Text("Continue")
    }
    // Rest of the UI

    /*
        * The following code implements the requirement of advancing automatically
        * to the next screen when a valid date of birth has been introduced
        * and the user wanted to continue with the registration process.
        */

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
        LaunchedEffect(viewModel, lifecycle) {
            // If the date of birth is valid and the validation is in progress,
            // navigate to the next screen when `lifecycle` is at least STARTED,
            // which is the default Lifecycle.State for the `flowWithLifecycle` operator.
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    currentNavigateToNextScreen()
                }
        }
    }
}

Die Validierung des Geburtsdatums ist eine Geschäftslogik, für die das ViewModel zuständig ist. In den meisten Fällen delegiert das ViewModel diese Logik an die Datenschicht. Die Logik, mit der der Nutzer zum nächsten Bildschirm weitergeleitet wird, ist UI-Logik, da sich diese Anforderungen je nach UI-Konfiguration ändern können. Wenn Sie beispielsweise mehrere Registrierungsschritte gleichzeitig auf einem Tablet anzeigen, möchten Sie möglicherweise nicht, dass automatisch zum nächsten Bildschirm gewechselt wird. Die Variable validationInProgress im obigen Code implementiert diese Funktion und legt fest, ob die Benutzeroberfläche automatisch navigieren soll, wenn das Geburtsdatum gültig ist und der Nutzer mit dem nächsten Registrierungsschritt fortfahren möchte.

Andere Anwendungsfälle

Wenn Sie der Meinung sind, dass Ihr Anwendungsfall für UI-Ereignisse nicht mit UI-Statusaktualisierungen gelöst werden kann, müssen Sie möglicherweise überdenken, wie Daten in Ihrer App fließen. Beachten Sie die folgenden Grundsätze:

  • Jede Klasse sollte nur das tun, wofür sie zuständig ist. Die Benutzeroberfläche ist für bildschirmspezifische Verhaltenslogik wie Navigationsaufrufe, Klickereignisse und das Einholen von Berechtigungsanfragen zuständig. Das ViewModel enthält die Geschäftslogik und wandelt die Ergebnisse aus den unteren Ebenen der Hierarchie in den UI-Status um.
  • Überlegen Sie, wo das Ereignis seinen Ursprung hat. Folgen Sie dem Entscheidungsbaum am Anfang dieses Leitfadens und lassen Sie jede Klasse die Aufgaben übernehmen, für die sie zuständig ist. Wenn das Ereignis beispielsweise aus der Benutzeroberfläche stammt und zu einem Navigationsereignis führt, muss es in der Benutzeroberfläche verarbeitet werden. Einige Logik kann an das ViewModel delegiert werden, die Verarbeitung des Ereignisses jedoch nicht vollständig.
  • Wenn Sie mehrere Nutzer haben und befürchten, dass das Ereignis mehrmals genutzt wird, sollten Sie Ihre App-Architektur überdenken. Wenn mehrere gleichzeitige Consumer vorhanden sind, wird es extrem schwierig, den Vertrag genau einmal zugestellt einzuhalten. Die Komplexität und das subtile Verhalten nehmen dann enorm zu. Wenn dieses Problem auftritt, sollten Sie die entsprechenden Elemente in Ihrem UI-Baum nach oben verschieben. Möglicherweise benötigen Sie ein anderes Element, das weiter oben in der Hierarchie angesiedelt ist.
  • Überlegen Sie, wann der Status verwendet werden muss. In bestimmten Situationen möchten Sie möglicherweise nicht, dass der Status weiterhin genutzt wird, wenn die App im Hintergrund ausgeführt wird, z. B. wenn eine Toast angezeigt wird. In diesen Fällen sollten Sie den Status abrufen, wenn sich die Benutzeroberfläche im Vordergrund befindet.

Beispiele

Die folgenden Google-Beispiele veranschaulichen die UI-Ereignisse in der UI-Ebene. Sehen Sie sich die Beispiele an, um zu sehen, wie die Richtlinien in der Praxis aussehen:

Zusätzliche Ressourcen

Weitere Informationen zu UI-Ereignissen finden Sie in den folgenden zusätzlichen Ressourcen:

Codelabs

Dokumentation

Inhalte ansehen