UI-Ereignisse sind Aktionen, die in der UI-Ebene verarbeitet werden sollten, entweder von der UI oder vom ViewModel. Die häufigste Art von Ereignissen sind Nutzerereignisse. Nutzerereignisse werden vom Nutzer durch Interaktion mit der App ausgelöst, z. B. durch Tippen auf den Bildschirm oder durch Gesten. Die UI verarbeitet diese Ereignisse dann mithilfe von Callbacks wie onClick()-Listenern.
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. In der Regel stellt das ViewModel dazu Funktionen bereit, die von der UI aufgerufen werden können. Nutzerereignisse können auch UI-Verhaltenslogik haben, die direkt von der UI verarbeitet werden kann, z. B. die Navigation zu einem anderen Bildschirm oder die Anzeige einer Snackbar.
Während die Geschäftslogik für dieselbe App auf verschiedenen mobilen Plattformen oder Formfaktoren gleich bleibt, ist die UI-Verhaltenslogik ein Implementierungsdetail, das sich in diesen Fällen unterscheiden kann. Auf der UI-Ebene Seite werden diese Arten von Logik wie folgt definiert:
- Geschäftslogik bezieht sich darauf, was mit Statusänderungen geschehen soll, z. B. eine Zahlung vornehmen oder Nutzereinstellungen speichern. Diese Logik wird in der Regel von der Domain- und der Datenebene verarbeitet. In diesem Leitfaden wird die Architecture Components ViewModel-Klasse als empfohlene Lösung für Klassen verwendet, die Geschäftslogik verarbeiten.
- UI-Verhaltenslogik oder UI-Logik bezieht sich darauf, wie Statusänderungen angezeigt werden, z. B. Navigationslogik oder wie Nachrichten für den Nutzer angezeigt werden. Diese Logik wird von der UI verarbeitet.
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 finden. Die restlichen Abschnitte dieses Leitfadens erläutern diese Ansätze im Detail.
Nutzerereignisse verarbeiten
Die UI kann Nutzerereignisse direkt verarbeiten, wenn diese Ereignisse sich auf die Änderung des Status eines UI-Elements beziehen, z. B. des Status eines erweiterbaren Elements. Wenn für das Ereignis Geschäftslogik ausgeführt werden muss, z. B. die Daten auf dem Bildschirm aktualisiert werden müssen, sollte es vom ViewModel verarbeitet werden.
Im folgenden Beispiel wird gezeigt, wie verschiedene Schaltflächen verwendet werden, um ein UI-Element zu erweitern (UI-Logik) und die Daten auf dem Bildschirm zu aktualisieren (Geschäftslogik):
Aufrufe
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand details event is processed by the UI that
// modifies a View's internal state.
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// The refresh event is processed by the ViewModel that is in charge
// of the business logic.
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
Schreiben
@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 RecyclerViews
Wenn die Aktion weiter unten in der UI-Struktur ausgeführt wird, z. B. in einem RecyclerView
Element oder einer benutzerdefinierten View, sollte das ViewModel weiterhin für die Verarbeitung von Nutzer
Ereignissen zuständig sein.
Angenommen, alle Nachrichten in NewsActivity enthalten eine Schaltfläche zum Hinzufügen zu den Lesezeichen. Das ViewModel muss die ID der Nachricht kennen, die zu den Lesezeichen hinzugefügt wurde. Wenn der Nutzer eine Nachricht zu den Lesezeichen hinzufügt, ruft der RecyclerView-Adapter nicht die bereitgestellte Funktion addBookmark(newsId) aus dem ViewModel auf, was eine Abhängigkeit vom ViewModel erfordern würde. Stattdessen stellt das ViewModel ein Statusobjekt namens NewsItemUiState bereit, das die Implementierung für die Verarbeitung des Ereignisses enthält:
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
Auf diese Weise arbeitet der RecyclerView-Adapter nur mit den Daten, die er benötigt: der Liste der NewsItemUiState-Objekte. Der Adapter hat keinen Zugriff auf das gesamte ViewModel, wodurch die Wahrscheinlichkeit geringer ist, dass die vom ViewModel bereitgestellte Funktionalität missbraucht wird. Wenn Sie nur der Aktivitätsklasse erlauben, mit dem ViewModel zu arbeiten, trennen Sie die Verantwortlichkeiten. So wird sichergestellt, dass UI-spezifische Objekte wie Ansichten oder RecyclerView-Adapter nicht direkt mit dem ViewModel interagieren.
Namenskonventionen für Nutzerereignisfunktionen
In diesem Leitfaden werden die ViewModel-Funktionen, die Nutzerereignisse verarbeiten, mit einem Verb benannt, das auf der Aktion basiert, die sie verarbeiten, z. B. addBookmark(id) oder logIn(username, password).
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 Daten flusses. Dadurch werden Ereignisse nach Konfigurationsänderungen reproduzierbar und es wird garantiert, dass UI-Aktionen nicht verloren gehen. Optional können Sie Ereignisse auch nach dem Beenden des Prozesses reproduzierbar machen, wenn Sie das Modul für den gespeicherten Status verwenden.
Das Zuordnen von UI-Aktionen zum UI-Status ist nicht immer einfach, führt aber zu einer einfacheren Logik. Ihr Denkprozess sollte nicht damit enden, festzulegen, wie die UI beispielsweise zu einem bestimmten Bildschirm navigiert. Sie müssen weiterdenken und überlegen, wie Sie diesen Nutzerfluss im UI-Status darstellen. Anders ausgedrückt: Überlegen Sie nicht, welche Aktionen die UI ausführen muss, sondern wie sich diese Aktionen auf den UI-Status auswirken.
Nehmen wir an, der Nutzer ist auf dem Anmeldebildschirm angemeldet und navigiert zum Startbildschirm. Sie können dies im UI-Status so modellieren:
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
Diese UI reagiert auf Änderungen am Status isUserLoggedIn und navigiert bei Bedarf zum richtigen Ziel:
Aufrufe
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
Schreiben
class LoginViewModel : ViewModel() {
var uiState by mutableStateOf(LoginUiState())
private set
/* ... */
}
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel(),
onUserLogIn: () -> Unit
) {
val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
// Whenever the uiState changes, check if the user is logged in.
LaunchedEffect(viewModel.uiState) {
if (viewModel.uiState.isUserLoggedIn) {
currentOnUserLogIn()
}
}
// Rest of the UI for the login screen.
}
Die Verarbeitung von Ereignissen kann Statusaktualisierungen auslösen
Die Verarbeitung bestimmter ViewModel-Ereignisse in der UI kann zu anderen Aktualisierungen des UI-Status führen. Wenn beispielsweise kurzzeitige Nachrichten auf dem Bildschirm angezeigt werden, um den Nutzer darüber zu informieren, dass etwas passiert ist, muss die UI das ViewModel benachrichtigen, um eine weitere Statusaktualisierung auszulösen, nachdem die Nachricht auf dem Bildschirm angezeigt wurde. Das Ereignis, das auftritt, wenn der Nutzer die Nachricht verarbeitet hat (indem er sie schließt oder nach einem Timeout), kann als „Nutzereingabe“ behandelt werden. Daher sollte das ViewModel darüber informiert sein. In dieser Situation 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 kurzzeitige Nachricht angezeigt wird:
Aufrufe
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = "No Internet connection")
}
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
}
Schreiben
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 UI die Nachricht auf dem Bildschirm anzeigt. Es weiß nur, dass eine Nutzernachricht angezeigt werden muss. Sobald die kurzzeitige Nachricht angezeigt wurde, muss die UI das ViewModel darüber informieren, was zu einer weiteren Aktualisierung des UI-Status führt, um die Eigenschaft userMessage zu löschen:
Aufrufe
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessage?.let {
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
...
}
}
}
}
}
Schreiben
@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()
}
}
}
Obwohl die Nachricht kurzzeitig ist, ist der UI-Status zu jedem Zeitpunkt eine genaue Darstellung dessen, was auf dem Bildschirm angezeigt wird. Entweder wird die Nutzernachricht angezeigt oder nicht.
Navigationsereignisse
Im Abschnitt Die Verarbeitung von Ereignissen kann Statusaktualisierungen auslösen wird beschrieben, wie Sie den UI-Status verwenden, um Nutzernachrichten auf dem Bildschirm anzuzeigen. Navigationsereignisse sind auch eine häufige Art von Ereignissen in einer Android-App.
Wenn das Ereignis in der UI ausgelöst wird, weil der Nutzer auf eine Schaltfläche getippt hat, kümmert sich die UI darum, indem sie den Navigationscontroller aufruft oder das Ereignis gegebenenfalls für die aufrufende zusammensetzbare Funktion bereitstellt.
Aufrufe
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.helpButton.setOnClickListener {
navController.navigate(...) // Open help screen
}
}
}
Schreiben
@Composable
fun LoginScreen(
onHelp: () -> Unit, // Caller navigates to the right screen
viewModel: LoginViewModel = viewModel()
) {
// Rest of the UI
Button(onClick = onHelp) {
Text("Get help")
}
}
Wenn für die Dateneingabe vor der Navigation eine Validierung der Geschäftslogik erforderlich ist, muss das ViewModel diesen Status für die UI bereitstellen. Die UI reagiert auf diese Statusänderung und navigiert entsprechend. Im Abschnitt ViewModel-Ereignisse verarbeiten wird dieser Anwendungsfall behandelt. Hier ist ein ähnlicher Code:
Aufrufe
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
Schreiben
@Composable
fun LoginScreen(
onUserLogIn: () -> Unit, // Caller navigates to the right screen
viewModel: LoginViewModel = viewModel()
) {
Button(
onClick = {
// ViewModel validation is triggered
viewModel.login()
}
) {
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 „Anmeldung“ nicht im Back Stack gespeichert wird. Nutzer können nicht dorthin zurückkehren, wenn sie auf die Schaltfläche „Zurück“ tippen. In Fällen, in denen das passieren kann, ist jedoch zusätzliche Logik erforderlich.
Navigationsereignisse, wenn das Ziel im Back-Stack gespeichert wird
Wenn ein ViewModel einen Status festlegt, der ein Navigationsereignis von Bildschirm A zu Bildschirm B erzeugt, und Bildschirm A im Back Stack der Navigation gespeichert wird, benötigen Sie möglicherweise zusätzliche Logik, um nicht automatisch zu B weitergeleitet zu werden. Dazu ist ein zusätzlicher Status erforderlich, der angibt, ob die UI zur Navigation zum anderen Bildschirm verwendet werden soll. Normalerweise wird dieser Status in der UI gespeichert, da die Navigationslogik ein Anliegen der UI und nicht des ViewModel ist. Betrachten wir dazu den folgenden Anwendungsfall.
Angenommen, Sie befinden sich im Registrierungsablauf Ihrer App. Auf dem Bildschirm zur Validierung des Geburtsdatums wird das Datum vom ViewModel validiert, wenn der Nutzer auf die Schaltfläche „Weiter“ tippt. Das ViewModel delegiert die Validierungslogik an die Datenebene. Wenn das Datum gültig ist, wird der Nutzer zum nächsten Bildschirm weitergeleitet. Als zusätzliche Funktion können Nutzer zwischen den verschiedenen Registrierungsbildschirmen hin- und herwechseln, wenn sie einige Daten ändern möchten. Daher werden alle Ziele im Registrierungsablauf im selben Back-Stack gespeichert. Angesichts dieser Anforderungen können Sie diesen Bildschirm so implementieren:
Aufrufe
// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"
class DobValidationFragment : Fragment() {
private var validationInProgress: Boolean = false
private val viewModel: DobValidationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = // ...
validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false
binding.continueButton.setOnClickListener {
viewModel.validateDob()
validationInProgress = true
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect { uiState ->
// Update other parts of the UI ...
// If the input is valid and the user wants
// to navigate, navigate to the next screen
// and reset `validationInProgress` flag
if (uiState.isDobValid && validationInProgress) {
validationInProgress = false
navController.navigate(...) // Navigate to next screen
}
}
}
return binding
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
}
}
Schreiben
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 Geschäftslogik , für die das ViewModel verantwortlich ist. In den meisten Fällen delegiert das ViewModel diese Logik an die Datenebene. 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. Auf einem Tablet möchten Sie beispielsweise möglicherweise nicht automatisch zu einem anderen Bildschirm weitergeleitet werden, wenn Sie mehrere Registrierungsschritte gleichzeitig anzeigen. Die Variable validationInProgress im obigen Code implementiert diese Funktionalität und verarbeitet, ob die UI 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 Aktualisierungen des UI-Status gelöst werden kann, müssen Sie möglicherweise überdenken, wie Daten in Ihrer App fließen. Beachten Sie die folgenden Prinzipien:
- Jede Klasse sollte nur das tun, wofür sie verantwortlich ist. Die UI ist für die bildschirmspezifische Verhaltenslogik verantwortlich, z. B. für Navigationsaufrufe, Klickereignisse und das Abrufen von Berechtigungsanfragen. Das ViewModel enthält Geschäftslogik und konvertiert die Ergebnisse aus niedrigeren Ebenen der Hierarchie in den UI-Status.
- Überlegen Sie, wo das Ereignis seinen Ursprung hat. Folgen Sie dem Entscheidungs baum am Anfang dieses Leitfadens und lassen Sie jede Klasse das verarbeiten, wofür sie verantwortlich ist. Wenn das Ereignis beispielsweise von der UI stammt und zu einem Navigationsereignis führt, muss dieses Ereignis in der UI verarbeitet werden. Einige Logik kann an das ViewModel delegiert werden, aber die Verarbeitung des Ereignisses kann nicht vollständig an das ViewModel delegiert werden.
- Wenn Sie mehrere Consumer haben und befürchten, dass das Ereignis mehrmals verarbeitet wird, müssen Sie möglicherweise Ihre App-Architektur überdenken. Bei mehreren gleichzeitigen Consumern ist es äußerst schwierig, die genau einmal zugestellt-Garantie einzuhalten, sodass die Komplexität und das subtile Verhalten explodieren. Wenn dieses Problem auftritt, sollten Sie diese Bedenken in der UI-Struktur nach oben verschieben. Möglicherweise benötigen Sie eine andere Entität, die weiter oben in der Hierarchie angesiedelt ist.
- Überlegen Sie, wann der Status verarbeitet werden muss. In bestimmten Situationen möchten Sie möglicherweise nicht, dass der Status verarbeitet wird, wenn die App im Hintergrund ausgeführt wird, z. B. wenn eine
Toast-Nachricht angezeigt wird. In diesen Fällen sollten Sie den Status verarbeiten, wenn die UI im Vordergrund ist.
Beispiele
In den folgenden Google-Beispielen werden die UI-Ereignisse in der UI-Ebene veranschaulicht. Sehen Sie sich die Beispiele an, um diese Anleitung in der Praxis zu sehen:
Empfehlungen für Sie
- Hinweis: Linktext wird angezeigt, wenn JavaScript deaktiviert ist
- UI-Ebene
- Statusinhaber und UI-Status {:#mad-arch}
- Leitfaden zur App-Architektur