Zdarzenia interfejsu

Zdarzenia interfejsu to działania, które powinny być wykonywane w warstwie UI (przez interfejs użytkownika lub przez model ViewModel). Najczęściej są to zdarzenia użytkownika. Użytkownik generuje zdarzenia, korzystając z aplikacji, np. klikając ekran lub generując gesty. Interfejs użytkownika obsługuje te zdarzenia za pomocą wywołań zwrotnych takich jak detektory onClick().

Model ViewModel odpowiada zwykle za obsługę logiki biznesowej konkretnego zdarzenia użytkownika, np. kliknięcia przycisku, aby odświeżyć niektóre dane. Zwykle obiekt ViewModel obsługuje to, udostępniając funkcje, które może wywołać interfejs użytkownika. Zdarzenia użytkownika mogą też mieć własną logikę działania interfejsu, którą interfejs może obsłużyć bezpośrednio – na przykład przejście na inny ekran lub wyświetlenie Snackbar.

Zasada logiki biznesowej jest taka sama w przypadku danej aplikacji i na różnych platformach mobilnych oraz w różnych formatach, ale logika działania interfejsu to szczegóły implementacji, które w każdym z tych przypadków mogą się różnić. Strona warstwy interfejsu definiuje te rodzaje logiki w taki sposób:

  • Logika biznesowa oznacza, co zrobić ze zmianami stanu, np. dokonać płatności lub zapisać preferencje użytkownika. Domena i warstwy danych zwykle obsługują tę logikę. W tym przewodniku klasa ViewModel Architektury jest używana jako zoptymalizowane rozwiązanie w przypadku klas obsługujących logikę biznesową.
  • Logika działania interfejsu lub logika UI odnosi się do sposobu wyświetlania zmian stanu, np. logiki nawigacji czy sposobu wyświetlania komunikatów użytkownikowi. Logiką tę obsługuje interfejs.

Drzewo decyzyjne zdarzenia interfejsu

Poniższy diagram przedstawia drzewo decyzyjne, który pomoże Ci znaleźć najlepszą metodę obsługi konkretnego przypadku użycia zdarzenia. W pozostałej części tego przewodnika znajdziesz szczegółowe omówienie tych metod.

Jeśli zdarzenie pochodzi w modelu ViewModel, zaktualizuj stan interfejsu użytkownika. Jeśli zdarzenie pochodzi z interfejsu użytkownika i wymaga logiki biznesowej, przekaż logikę biznesową do modelu ViewModel. Jeśli zdarzenie pochodzi z interfejsu użytkownika i wymaga logiki jego działania, zmodyfikuj stan elementu interfejsu bezpośrednio w interfejsie.
Rysunek 1. Drzewo decyzyjne dotyczące obsługi zdarzeń.

Obsługa zdarzeń użytkownika

Interfejs może bezpośrednio obsługiwać zdarzenia użytkownika, jeśli są one związane z modyfikacją stanu elementu interfejsu, np. stanu elementu rozwijanego. Jeśli zdarzenie wymaga wykonania logiki biznesowej, np. odświeżania danych na ekranie, powinno zostać przetworzone przez ViewModel.

Poniższy przykład pokazuje, jak różne przyciski służą do rozwijania elementu interfejsu (logiki interfejsu) i odświeżania danych na ekranie (logika biznesowa):

Wyświetlenia

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()
        }
    }
}

Utwórz

@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 w obiektach RecyclerViews

Jeśli działanie zostanie wykonane w dolnej części drzewa interfejsu, np. w elemencie RecyclerView lub niestandardowym View, to ViewModel nadal powinno obsługiwać zdarzenia użytkownika.

Załóżmy np., że wszystkie wiadomości z NewsActivity zawierają przycisk zakładki. ViewModel musi znać identyfikator elementu wiadomości dodanego do zakładek. Gdy użytkownik dodaje stronę do zakładek, adapter RecyclerView nie wywołuje z metody ViewModel ujawnionej funkcji addBookmark(newsId), która wymagałaby zależności ViewModel. Zamiast tego ViewModel ujawnia obiekt stanu o nazwie NewsItemUiState, który zawiera implementację do obsługi zdarzenia:

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)
            }
        )
    }
}

Dzięki temu adapter RecyclerView będzie działać tylko z potrzebnymi danymi: z listą obiektów NewsItemUiState. Adapter nie ma dostępu do całego obiektu ViewModel, co zmniejsza prawdopodobieństwo nadużywania funkcji ujawnianych przez ten model. Gdy zezwalasz na pracę z ViewModel tylko klasie aktywności, rozdzielasz obowiązki. Dzięki temu obiekty związane z interfejsem, takie jak widoki czy adaptery RecyclerView, nie wchodzą w bezpośrednią interakcję z modelem ViewModel.

Konwencje nazewnictwa funkcji zdarzeń użytkownika

W tym przewodniku funkcje ViewModel obsługujące zdarzenia użytkownika są nazywane czasownikami na podstawie wykonywanej przez nie czynności, np. addBookmark(id) lub logIn(username, password).

Obsługuj zdarzenia ViewModel

Działania interfejsu użytkownika pochodzące ze zdarzeń ViewModel – ViewModel – powinny zawsze powodować aktualizację stanu interfejsu użytkownika. Jest to zgodne z zasadami jednokierunkowego przepływu danych. Dzięki temu zdarzenia są odtwarzane po wprowadzeniu zmian w konfiguracji i gwarantują, że działania interfejsu użytkownika nie zostaną utracone. Opcjonalnie możesz też umożliwić odtwarzanie zdarzeń po śmierci procesu, jeśli używasz modułu zapisanego stanu.

Mapowanie działań interfejsu na stan UI nie zawsze jest prostym procesem, ale prowadzi do uproszczenia logiki. Proces myślowy nie powinien skończyć się na przykład ustaleniu, jak UI ma przechodzić na konkretny ekran. Musisz się bardziej szczegółowo zastanowić i zastanowić, jak przedstawić ten przepływ użytkowników w stanie UI. Inaczej mówiąc: nie myśl o działaniach, które musi wykonać interfejs. Zastanów się, jak wpływają one na stan UI.

Weźmy pod uwagę na przykład przejście do ekranu głównego, gdy użytkownik jest zalogowany na ekranie logowania. Możesz modelować ten stan w interfejsie w ten sposób:

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

Ten interfejs reaguje na zmiany stanu isUserLoggedIn i w razie potrzeby przechodzi do odpowiedniego miejsca docelowego:

Wyświetlenia

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.
                    }
                    ...
                }
            }
        }
    }
}

Utwórz

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.
}

Wykorzystanie zdarzeń może aktywować aktualizacje stanu

Wykorzystanie określonych zdarzeń ViewModel w interfejsie użytkownika może spowodować zmiany innych stanów interfejsu. Gdy na przykład wyświetlasz na ekranie wiadomości przejściowe, aby poinformować użytkownika o konkretnej sytuacji, interfejs użytkownika musi powiadomić model Viewmodel, aby uruchomił kolejną aktualizację stanu, gdy wiadomość zostanie pokazana na ekranie. Zdarzenie, które ma miejsce, gdy użytkownik skorzysta z wiadomości (przez zamknięcie go lub po upływie czasu oczekiwania), może być traktowane jako „dane wejściowe użytkownika” i z tego względu obiekt ViewModel powinien o tym wiedzieć. W takiej sytuacji stan interfejsu może wyglądać tak:

// 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
)

Model ViewModel aktualizuje stan interfejsu w ten sposób, gdy logika biznesowa wymaga wyświetlenia użytkownikowi nowej wiadomości przejściowej:

Wyświetlenia

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)
        }
    }
}

Utwórz

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)
    }
}

Model ViewModel nie musi wiedzieć, jak interfejs wyświetla komunikat na ekranie – po prostu wie, że istnieje komunikat użytkownika, który trzeba pokazać. Gdy wyświetli się komunikat przejściowy, interfejs musi powiadomić o tym obiekt ViewModel, co spowoduje kolejną aktualizację stanu interfejsu w celu wyczyszczenia właściwości userMessage:

Wyświetlenia

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()
                    }
                    ...
                }
            }
        }
    }
}

Utwórz

@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()
        }
    }
}

Mimo że komunikat jest przejściowy, stan interfejsu przedstawia wiernie to, co widać na ekranie w każdym punkcie w czasie. Wiadomość dla użytkownika jest wyświetlana lub nie jest.

Sekcja Zdarzenia wykorzystania mogą aktywować aktualizacje stanu zawiera szczegółowe informacje o tym, jak używać stanu interfejsu do wyświetlania komunikatów dla użytkowników na ekranie. Zdarzenia związane z nawigacją to też typowy typ zdarzeń w aplikacjach na Androida.

Jeśli zdarzenie jest wywoływane w interfejsie po kliknięciu przycisku przez użytkownika, zajmuje się tym, wywołując kontroler nawigacji lub udostępniając zdarzenie elementowi wywołującemu kompozycyjnemu.

Wyświetlenia

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
        }
    }
}

Utwórz

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI

    Button(onClick = onHelp) {
        Text("Get help")
    }
}

Jeśli dane wejściowe wymagają weryfikacji logiki biznesowej przed nawigacją, obiekt ViewModel musi ujawnić ten stan w interfejsie użytkownika. Interfejs odpowiednio zareaguje na tę zmianę i odpowiednio do niej nawiguje. Sekcja Obsługa zdarzeń ViewModel obejmuje ten przypadek użycia. Oto podobny kod:

Wyświetlenia

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.
                    }
                    ...
                }
            }
        }
    }
}

Utwórz

@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()
            }
    }
}

W powyższym przykładzie aplikacja działa zgodnie z oczekiwaniami, ponieważ obecne miejsce docelowe, czyli Login, nie znajduje się w stosie wstecznym. Użytkownicy nie mogą wrócić, jeśli klikną przycisk Wstecz. Jednak w niektórych przypadkach rozwiązanie wymagałoby dodatkowej logiki.

Gdy obiekt ViewModel ustawia stan, który wywołuje zdarzenie nawigacji z ekranu A na ekran B, a ekran A jest przechowywany w tylnym stosie nawigacji, może być potrzebna dodatkowa logika, aby nie przejść automatycznie do ekranu B. Do jego wdrożenia wymagany jest dodatkowy stan informujący o tym, czy interfejs użytkownika powinien rozważyć przejście na drugi ekran. Zwykle ten stan jest zatrzymywany w interfejsie, ponieważ logika nawigacji jest związana z interfejsem, a nie z modelem ViewModel. Aby to zilustrować, spójrzmy na poniższy przypadek użycia.

Załóżmy, że jesteś w trakcie procesu rejestracji aplikacji. Gdy użytkownik wpisze datę na ekranie weryfikacji daty urodzenia, data jest weryfikowana przez ViewModel po kliknięciu przycisku „Dalej”. Model ViewModel przekazuje logikę walidacji do warstwy danych. Jeśli data jest prawidłowa, użytkownik przechodzi do następnego ekranu. Użytkownicy mogą przełączać się między różnymi ekranami rejestracji, aby zmienić niektóre dane. Dlatego wszystkie miejsca docelowe podczas rejestracji są przechowywane w tym samym stosie wstecznym. Biorąc pod uwagę te wymagania, możesz zaimplementować ten ekran w taki sposób:

Wyświetlenia

// 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)
    }
}

Utwórz

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()
                }
        }
    }
}

Weryfikacja daty urodzenia jest logiką biznesową, za którą odpowiada model ViewModel. W większości przypadków model ViewModel deleguje tę logikę do warstwy danych. Logika prowadząca użytkownika do następnego ekranu opiera się na logice interfejsu, ponieważ te wymagania mogą się zmieniać w zależności od konfiguracji UI. Możesz np. nie chcieć automatycznie przechodzić do kolejnego ekranu na tablecie, jeśli w tym samym czasie pokazujesz kilka etapów rejestracji. Zmienna validationInProgress w powyższym kodzie implementuje tę funkcję i określa, czy interfejs powinien przechodzić automatycznie za każdym razem, gdy jest prawidłowa data urodzenia, a użytkownik chciał przejść do następnego kroku rejestracji.

Inne przypadki użycia

Jeśli uważasz, że nie da się rozwiązać problemu z zastosowaniem zdarzeń interfejsu przez aktualizacje stanu interfejsu, być może musisz jeszcze raz zastanowić się nad przepływem danych w aplikacji. Rozważ te zasady:

  • Każda klasa powinna robić to, za co jest odpowiedzialna, a nie więcej. Interfejs ten odpowiada za logikę zachowania specyficzną dla ekranu, np. wywołania nawigacji, zdarzenia kliknięć i uzyskiwanie próśb o uprawnienia. Model ViewModel wykorzystuje logikę biznesową i przekształca wyniki z niższych warstw hierarchii w stan interfejsu użytkownika.
  • Zastanów się, gdzie doszło do zdarzenia. Postępuj zgodnie z drzewem decyzji opisanym na początku tego przewodnika i upewnij się, że każda klasa odpowiada za to, za co odpowiada. Jeśli np. zdarzenie pochodzi z interfejsu i wygeneruje zdarzenie nawigacji, to zdarzenie to musi być obsługiwane w interfejsie. Część logiki można przekazać do modelu ViewModel, ale obsługi zdarzeń nie da się całkowicie przekazać do niego.
  • Jeśli masz wielu klientów i martwisz się, że wydarzenie będzie wielokrotnie używane, konieczna może być zmiana architektury aplikacji. Używanie wielu równoczesnych klientów sprawia, że umowa dostarczona dokładnie raz staje się bardzo trudna do zagwarantowania, co zwiększa złożoność i subtelne działanie tej funkcji. Jeśli napotkasz ten problem, rozważ przeniesienie tych wątpliwości w górę w drzewie interfejsu użytkownika. Może być potrzebna inna encja o zakresie wyżej w hierarchii.
  • Zastanów się, w jakim momencie należy korzystać z usług administracji publicznej. W niektórych sytuacjach możesz nie chcieć z niego korzystać, gdy aplikacja działa w tle, na przykład wyświetlając Toast. W takich przypadkach warto wykorzystać stan, gdy interfejs użytkownika działa na pierwszym planie.

Próbki

Poniższe przykłady Google pokazują zdarzenia interfejsu w warstwie UI. Zapoznaj się z nimi, aby zastosować te wskazówki w praktyce: