Zdarzenia interfejsu to działania, które powinny być obsługiwane w warstwie interfejsu, przez interfejs lub ViewModel. Najczęstszym typem zdarzeń są zdarzenia użytkownika. Użytkownik generuje zdarzenia, wchodząc w interakcję z aplikacją, np. dotykając ekranu lub wykonując gesty. Interfejs użytkownika wykorzystuje te zdarzenia za pomocą wywołań zwrotnych, takich jak funkcje lambda zdefiniowane w różnych komponentach kompozycyjnych.
ViewModel jest zwykle odpowiedzialny za obsługę logiki biznesowej określonego zdarzenia użytkownika, np. kliknięcia przez użytkownika przycisku odświeżania danych. Zwykle ViewModel obsługuje to, udostępniając funkcje, które interfejs może wywoływać. Zdarzenia użytkownika mogą też mieć logikę działania interfejsu, którą interfejs może obsługiwać bezpośrednio, np. przechodzenie do innego ekranu lub wyświetlanie Snackbar.
Chociaż logika biznesowa pozostaje taka sama w przypadku tej samej aplikacji na różnych platformach mobilnych lub urządzeniach, logika działania interfejsu jest szczegółem implementacji, który może się różnić w zależności od przypadku. Na stronie warstwy interfejsu te typy logiki są zdefiniowane w ten sposób:
- Logika biznesowa odnosi się do tego, co zrobić ze zmianami stanu, np. dokonać płatności lub zapisać preferencje użytkownika. Zazwyczaj logikę tę obsługują warstwy domeny i danych. W tym przewodniku klasa Architecture Components ViewModel jest używana jako rozwiązanie oparte na opiniach w przypadku klas, które obsługują logikę biznesową.
- Logika działania interfejsu lub logika interfejsu odnosi się do tego, jak wyświetlać zmiany stanu, np. logika nawigacji lub sposób wyświetlania komunikatów użytkownikowi. Interfejs użytkownika obsługuje tę logikę.
Drzewo decyzyjne zdarzeń interfejsu
Na poniższym diagramie przedstawiono schemat decyzyjny, który pomoże Ci znaleźć najlepsze podejście do obsługi konkretnego przypadku użycia zdarzenia. W dalszej części tego przewodnika znajdziesz szczegółowe informacje o tych podejściach.
Obsługa zdarzeń użytkownika
Interfejs może obsługiwać zdarzenia użytkownika bezpośrednio, jeśli są one związane z modyfikowaniem stanu elementu interfejsu, np. stanu elementu rozwijanego. Jeśli zdarzenie wymaga wykonania logiki biznesowej, np. odświeżenia danych na ekranie, powinno być przetwarzane przez ViewModel.
Ten przykład pokazuje, jak różne przyciski są używane do rozwijania elementu interfejsu (logika interfejsu) i odświeżania danych na ekranie (logika biznesowa):
@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")
}
}
}
Zdarzenia użytkownika na listach leniwych
Jeśli działanie jest wykonywane w dalszej części drzewa interfejsu, np. w LazyColumn
elemencie, to ViewModel nadal powinien obsługiwać zdarzenia użytkownika.
Weźmy na przykład listę elementów, które można kliknąć. Nie przekazuj instancji ViewModel
do funkcji kompozycyjnej listy (MyList), ponieważ powoduje to ścisłe powiązanie
komponentu interfejsu z detalami implementacji.
Zamiast tego udostępnij zdarzenie jako parametr funkcji lambda w funkcji kompozycyjnej. Dzięki temu lista może wywołać zdarzenie bez wiedzy o tym, kto i w jaki sposób je obsłuży.
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)
}
)
}
}
}
}
W tym podejściu funkcja kompozycyjna MyList działa tylko z wyświetlanymi przez nią danymi i udostępnianymi przez nią zdarzeniami. Nie ma dostępu do ViewModelu. Zdarzenie jest przenoszone i przekazywane do ViewModel w poprzednim komponencie.
Więcej informacji o obsłudze zdarzeń znajdziesz w artykule Zdarzenia w Compose.
Konwencje nazewnictwa funkcji zdarzeń użytkownika i modułów obsługi zdarzeń
W tym przewodniku funkcje ViewModel, które obsługują zdarzenia użytkownika, mają nazwy z czasownikiem określającym działanie, które obsługują, np. validateInput() lub login().
Obsługa zdarzeń w Compose jest zgodna ze standardową konwencją nazewnictwa, która sprawia, że przepływ danych jest oczywisty:
- Nazwa parametru:
on+Verb+Target(np.onExpandClickedlubonValueChange). - Wyrażenie lambda: podczas wywoływania funkcji kompozycyjnej lambda jest często po prostu implementacją tego zdarzenia.
Obsługa zdarzeń ViewModel
Działania w interfejsie, które pochodzą z obiektu ViewModel – zdarzenia ViewModel – powinny zawsze powodować aktualizację stanu interfejsu. Jest to zgodne z zasadami jednokierunkowego przepływu danych. Umożliwia to odtwarzanie zdarzeń po zmianach konfiguracji i gwarantuje, że działania w interfejsie nie zostaną utracone. Opcjonalnie możesz też sprawić, że zdarzenia będą odtwarzane po śmierci procesu, jeśli używasz modułu stanu zapisanego.
Mapowanie działań interfejsu na stan interfejsu nie zawsze jest proste, ale prowadzi do uproszczenia logiki. Proces myślowy nie powinien kończyć się na określeniu, jak sprawić, aby interfejs użytkownika przechodził do określonego ekranu. Musisz się zastanowić, jak przedstawić ten przepływ użytkownika w stanie interfejsu. Innymi słowy: nie zastanawiaj się, jakie działania musi wykonać interfejs, tylko jak te działania wpływają na jego stan.
Rozważmy na przykład ekran logowania. Stan interfejsu tego ekranu możesz modelować w ten sposób:
data class LoginUiState(
val isLoginInProgress: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
Ekran logowania reaguje na zmiany stanu interfejsu.
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
)
}
}
Przetwarzanie zdarzeń może powodować aktualizacje stanu
Wykorzystanie w interfejsie niektórych zdarzeń ViewModel może spowodować aktualizację innych stanów interfejsu. Na przykład podczas wyświetlania na ekranie przejściowych komunikatów informujących użytkownika o jakimś zdarzeniu interfejs musi powiadomić ViewModel, aby po wyświetleniu komunikatu na ekranie wywołać kolejną aktualizację stanu. Zdarzenie, które występuje, gdy użytkownik przetworzy wiadomość (odrzuci ją lub po upływie limitu czasu), można traktować jako „dane wejściowe użytkownika”, dlatego ViewModel powinien o tym wiedzieć. W takiej sytuacji stan interfejsu może być modelowany w ten sposób:
// 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
)
Gdy logika biznesowa wymaga wyświetlenia użytkownikowi nowego tymczasowego komunikatu, ViewModel zaktualizuje stan interfejsu w ten sposób:
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)
}
}
Obiekt ViewModel nie musi wiedzieć, jak interfejs wyświetla wiadomość na ekranie. Wie tylko, że jest wiadomość dla użytkownika, którą należy wyświetlić. Gdy komunikat tymczasowy zostanie wyświetlony, interfejs musi powiadomić o tym obiekt ViewModel, co spowoduje kolejną aktualizację stanu interfejsu, która wyczyści właściwość userMessage:
@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()
}
}
}
Chociaż komunikat jest tymczasowy, stan interfejsu jest wiernym odzwierciedleniem tego, co jest wyświetlane na ekranie w każdym momencie. Komunikat użytkownika jest wyświetlany albo nie.
Zdarzenia nawigacji
W sekcji Wywoływanie zdarzeń może powodować aktualizacje stanu znajdziesz szczegółowe informacje o tym, jak używać stanu interfejsu, aby wyświetlać użytkownikom komunikaty na ekranie. Zdarzenia nawigacji to też typowe zdarzenia w aplikacji na Androida.
Jeśli zdarzenie jest wywoływane w interfejsie, ponieważ użytkownik kliknął przycisk, interfejs zajmuje się tym, udostępniając zdarzenie wywołującemu komponentowi.
@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 jest częścią biblioteki Lifecycle i umożliwia uruchamianie funkcji onHelp tylko wtedy, gdy cykl życia osiągnie co najmniej stan RESUMED.
Jeśli wprowadzanie danych wymaga sprawdzenia logiki biznesowej przed przejściem do następnego kroku, ViewModel musi udostępnić ten stan interfejsowi. Interfejs zareaguje na tę zmianę stanu i odpowiednio się dostosuje. Ten przypadek użycia opisujemy w sekcji Obsługa zdarzeń ViewModel. Oto podobny kod:
@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()
}
}
}
W powyższym przykładzie aplikacja działa zgodnie z oczekiwaniami, ponieważ bieżące miejsce docelowe, czyli ekran logowania, nie jest przechowywane na stosie wstecznym. Jeśli użytkownik naciśnie przycisk Wstecz, nie będzie mógł wrócić do tej strony. W takich przypadkach rozwiązanie wymagałoby jednak dodatkowej logiki.
Zdarzenia nawigacji, gdy miejsce docelowe jest przechowywane na stosie wstecznym
Jeśli ViewModel ustawi stan, który spowoduje zdarzenie nawigacji z ekranu A na ekran B, a ekran A pozostanie w stosie wstecznym nawigacji, może być potrzebna dodatkowa logika, aby nie przechodzić automatycznie do ekranu B. Aby to zaimplementować, musisz dodać dodatkowy stan, który będzie wskazywać, czy interfejs powinien przejść do innego ekranu. Zwykle ten stan jest przechowywany w interfejsie, ponieważ logika nawigacji jest kwestią interfejsu, a nie modelu widoku. Aby to zilustrować, rozważmy ten przypadek użycia.
Załóżmy, że użytkownik jest w procesie rejestracji w aplikacji. Na ekranie weryfikacji daty urodzenia po wpisaniu daty jest ona weryfikowana przez ViewModel, gdy użytkownik kliknie przycisk „Dalej”. ViewModel przekazuje logikę sprawdzania poprawności do warstwy danych. Jeśli data jest prawidłowa, użytkownik przechodzi do następnego ekranu. Dodatkowo użytkownicy mogą wracać do poprzednich ekranów rejestracji i przechodzić do następnych, jeśli chcą zmienić niektóre dane. Dlatego wszystkie miejsca docelowe w procesie rejestracji są przechowywane w tym samym stosie wstecznym. Biorąc pod uwagę te wymagania, możesz zaimplementować ten ekran w ten sposób:
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()
}
}
}
}
Walidacja daty urodzenia to logika biznesowa, za którą odpowiada ViewModel. Większość czasu ViewModel przekazuje tę logikę do warstwy danych. Logika przenoszenia użytkownika na następny ekran to logika interfejsu, ponieważ te wymagania mogą się zmieniać w zależności od konfiguracji interfejsu. Na przykład jeśli na tablecie wyświetlasz jednocześnie kilka kroków rejestracji, możesz nie chcieć automatycznie przechodzić do kolejnego ekranu. Zmienna validationInProgress w powyższym kodzie implementuje tę funkcję i określa, czy interfejs powinien nawigować automatycznie, gdy data urodzenia jest prawidłowa, a użytkownik chce przejść do następnego kroku rejestracji.
Inne przypadki użycia
Jeśli uważasz, że Twojego przypadku użycia zdarzenia interfejsu nie można rozwiązać za pomocą aktualizacji stanu interfejsu, być może musisz ponownie rozważyć przepływ danych w aplikacji. Weź pod uwagę te zasady:
- Każda klasa powinna wykonywać tylko te czynności, za które jest odpowiedzialna. Interfejs użytkownika odpowiada za logikę zachowania specyficzną dla ekranu, taką jak wywołania nawigacji, zdarzenia kliknięcia i uzyskiwanie próśb o uprawnienia. ViewModel zawiera logikę biznesową i przekształca wyniki z niższych warstw hierarchii w stan interfejsu.
- Zastanów się, skąd pochodzi wydarzenie. Postępuj zgodnie z drzewem decyzyjnym przedstawionym na początku tego przewodnika i spraw, aby każda klasa obsługiwała to, za co jest odpowiedzialna. Jeśli na przykład zdarzenie pochodzi z interfejsu i powoduje zdarzenie nawigacji, musi być obsługiwane w interfejsie. Niektóre elementy logiki mogą być przekazywane do ViewModelu, ale obsługa zdarzenia nie może być w całości przekazywana do ViewModelu.
- Jeśli masz wielu odbiorców i martwisz się, że zdarzenie zostanie przetworzone wiele razy, być może musisz przemyśleć architekturę aplikacji. Wielu równoczesnych odbiorców sprawia, że dostarczenie dokładnie raz staje się niezwykle trudne do zagwarantowania, więc złożoność i subtelne zachowania gwałtownie rosną. Jeśli masz ten problem, rozważ przesunięcie tych elementów wyżej w drzewie interfejsu. Może być potrzebny inny obiekt o szerszym zakresie w hierarchii.
- Zastanów się, kiedy stan musi zostać wykorzystany. W niektórych sytuacjach możesz nie chcieć zachowywać stanu konsumpcji, gdy aplikacja działa w tle – na przykład wyświetlać
Toast. W takich przypadkach rozważ wykorzystanie stanu, gdy interfejs jest na pierwszym planie.
Przykłady
Poniższe przykłady Google pokazują zdarzenia interfejsu w warstwie interfejsu. Zapoznaj się z nimi, aby zobaczyć te wskazówki w praktyce:
Dodatkowe materiały
Więcej informacji o zdarzeniach interfejsu znajdziesz w tych materiałach:
Codelabs
Dokumentacja
Wyświetla treści
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy język JavaScript jest wyłączony.
- Warstwa interfejsu
- Obiekty stanu i stan interfejsu {:#mad-arch}
- Przewodnik po architekturze aplikacji