Zdarzenia interfejsu

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 użytkownika, wchodząc w interakcję z aplikacją, np. klikając ekran lub wykonując gesty. Interfejs wykorzystuje te zdarzenia za pomocą wywołań zwrotnych, takich jak detektory onClick().

ViewModel jest zwykle odpowiedzialny za obsługę logiki biznesowej konkretnego 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 może wywoływać interfejs. Zdarzenia użytkownika mogą też mieć logikę zachowania interfejsu, którą może obsługiwać bezpośrednio interfejs, np. przechodzenie do innego ekranu lub wyświetlanie Snackbar.

O ile logika biznesowa pozostaje taka sama w przypadku tej samej aplikacji na różnych platformach mobilnych lub w różnych formatach, o tyle logika zachowania interfejsu jest szczegółem implementacji, który może się różnić w zależności od tych przypadków. Na stronie Warstwa interfejsu strona te typy logiki są zdefiniowane w ten sposób:

  • Logika biznesowa odnosi się do tego, co należy zrobić ze zmianami stanu, np. dokonanie płatności lub zapisanie preferencji użytkownika. Zwykle ta logika jest obsługiwana przez warstwę domeny i warstwę danych. W tym przewodniku klasa Architecture Components ViewModel komponentów architektury jest używana jako rozwiązanie w przypadku klas, które obsługują logikę biznesową.
  • Logika zachowania 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. Ta logika jest obsługiwana przez interfejs.

Schemat decyzyjny dotyczący zdarzeń interfejsu

Poniższy diagram przedstawia schemat decyzyjny, który pomaga znaleźć najlepsze podejście do obsługi konkretnego przypadku użycia zdarzenia. Pozostała część tego przewodnika szczegółowo wyjaśnia te podejścia.

Jeśli zdarzenie pochodzi z obiektu ViewModel, zaktualizuj stan interfejsu. Jeśli zdarzenie pochodzi z interfejsu i wymaga logiki biznesowej, przekaż ją do ViewModelu. Jeśli zdarzenie pochodzi z interfejsu i wymaga logiki działania interfejsu, zmień stan elementu interfejsu bezpośrednio w interfejsie.
Rysunek 1. Schemat decyzyjny dotyczący 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 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.

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

Views

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

Compose

@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 RecyclerView

Jeśli działanie jest wykonywane w dalszej części drzewa interfejsu, np. w elemencie RecyclerView lub niestandardowym View, to ViewModel powinien nadal obsługiwać zdarzenia użytkownika.

Załóżmy na przykład, że wszystkie artykuły w NewsActivity zawierają przycisk dodawania do zakładek. ViewModel musi znać identyfikator artykułu dodanego do zakładek. Gdy użytkownik doda artykuł do zakładek, adapter RecyclerView nie wywoła udostępnionej funkcji addBookmark(newsId) z ViewModel, co wymagałoby zależności od ViewModel. Zamiast tego ViewModel udostępnia obiekt stanu o nazwie NewsItemUiState, który zawiera implementację 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)
            }
        )
    }
}

W ten sposób adapter RecyclerView działa tylko z potrzebnymi mu danymi – listą obiektów NewsItemUiState. Adapter nie ma dostępu do całego ViewModel, co zmniejsza prawdopodobieństwo nadużycia funkcji udostępnianych przez ViewModel. Gdy zezwolisz tylko klasie aktywności na pracę z ViewModel, rozdzielisz obowiązki. Dzięki temu obiekty specyficzne dla interfejsu, takie jak widoki czy adaptery RecyclerView, nie będą wchodzić w bezpośrednią interakcję z ViewModel.

Konwencje nazewnictwa funkcji zdarzeń użytkownika

W tym przewodniku funkcje ViewModel, które obsługują zdarzenia użytkownika, są nazywane czasownikiem na podstawie działania, które obsługują, np. addBookmark(id) lub logIn(username, password).

Obsługa zdarzeń ViewModel

Działania interfejsu, które pochodzą z ViewModel (zdarzenia ViewModel), powinny zawsze powodować aktualizację stanu interfejsu. Jest to zgodne z zasadami jednokierunkowego przepływu danych. Dzięki temu zdarzenia można odtwarzać po zmianach konfiguracji i gwarantuje to, że działania interfejsu nie zostaną utracone. Opcjonalnie, możesz też sprawić, że zdarzenia będą odtwarzane po śmierci procesu, jeśli używasz modułu zapisanego stanu.

Mapowanie działań interfejsu na stan interfejsu nie zawsze jest proste, ale prowadzi do prostszej logiki. Proces myślowy nie powinien się kończyć na określeniu, jak sprawić, aby interfejs przeszedł do określonego ekranu. Musisz pomyśleć dalej i zastanowić się, jak przedstawić ścieżkę użytkownika w stanie interfejsu. In nymi słowy: nie zastanawiaj się, jakie działania musi wykonać interfejs, ale jak te działania wpływają na stan interfejsu.

Rozważmy na przykład przejście do ekranu głównego, gdy użytkownik jest zalogowany na ekranie logowania. Możesz modelować to w stanie interfejsu 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:

Views

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

Compose

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

Wykorzystywanie zdarzeń może powodować aktualizacje stanu

Wykorzystywanie niektórych zdarzeń ViewModel w interfejsie może powodować inne aktualizacje stanu interfejsu. Na przykład, gdy wyświetlasz na ekranie przejściowe komunikaty, aby poinformować użytkownika o tym, że coś się stało, interfejs musi powiadomić ViewModel, aby wywołać kolejną aktualizację stanu, gdy komunikat zostanie wyświetlony na ekranie. Zdarzenie, które występuje, gdy użytkownik wykorzysta komunikat (odrzucając go 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żna modelować 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
)

ViewModel zaktualizuje stan interfejsu w ten sposób, gdy logika biznesowa wymaga wyświetlenia użytkownikowi nowego przejściowego komunikatu:

Views

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

Compose

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

ViewModel nie musi wiedzieć, jak interfejs wyświetla komunikat na ekranie. Wie tylko, że jest komunikat użytkownika, który trzeba wyświetlić. Gdy przejściowy komunikat zostanie wyświetlony, interfejs musi powiadomić o tym ViewModel, co spowoduje kolejną aktualizację stanu interfejsu, aby wyczyścić właściwość userMessage:

Views

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

Compose

@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 wiernie odzwierciedla to, co jest wyświetlane na ekranie w każdym momencie. Komunikat użytkownika jest wyświetlany lub nie.

W sekcji Wykorzystywanie zdarzeń może powodować aktualizacje stanu opisujemy, jak używać stanu interfejsu do wyświetlania komunikatów użytkownika na ekranie. Zdarzenia nawigacji są też częstym typem zdarzeń w aplikacji na Androida.

Jeśli zdarzenie jest wywoływane w interfejsie, ponieważ użytkownik kliknął przycisk, interfejs zajmuje się tym, wywołując kontroler nawigacji lub udostępniając zdarzenie wywołującemu elementowi kompozycyjnemu.

Views

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

Compose

@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ą sprawdzenia logiki biznesowej przed przejściem, ViewModel musi udostępnić ten stan interfejsowi. Interfejs zareaguje na tę zmianę stanu i odpowiednio przejdzie. Ten przypadek użycia jest opisany w sekcji Obsługa zdarzeń ViewModel covers. Oto podobny kod:

Views

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

Compose

@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ż bieżące miejsce docelowe (Logowanie) nie będzie przechowywane na stosie wstecznym. Użytkownicy nie mogą do niego wrócić, naciskając przycisk Wstecz. W przypadkach, w których może się to zdarzyć, rozwiązanie będzie wymagało dodatkowej logiki.

Gdy ViewModel ustawia stan, który powoduje zdarzenie nawigacji z ekranu A na ekran B, a ekran A jest przechowywany na stosie wstecznym nawigacji, może być potrzebna dodatkowa logika, aby nie przechodzić automatycznie do ekranu B. Aby to zaimplementować, musisz mieć dodatkowy stan, który wskazuje, czy interfejs powinien przejść do innego ekranu. Zwykle ten stan jest przechowywany w interfejsie, ponieważ logika nawigacji jest kwestią interfejsu, a nie ViewModel. Aby to zilustrować, rozważmy ten przypadek użycia.

Załóżmy, że jesteś w procesie rejestracji w aplikacji. Na ekranie sprawdzania daty urodzenia , gdy użytkownik wpisze datę, ViewModel sprawdzi ją po kliknięciu przycisku „Dalej”. ViewModel przekazuje logikę sprawdzania do warstwy danych. Jeśli data jest prawidłowa, użytkownik przechodzi do następnego ekranu. Dodatkowo użytkownicy mogą przechodzić między różnymi ekranami rejestracji, jeśli chcą zmienić niektóre dane. Dlatego wszystkie miejsca docelowe w procesie rejestracji są przechowywane na tym samym stosie wstecznym. Biorąc pod uwagę te wymagania, możesz zaimplementować ten ekran w ten sposób:

Views

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

Compose

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

Sprawdzanie daty urodzenia to logika biznesowa , za którą odpowiada ViewModel. W większości przypadków ViewModel przekazuje tę logikę do warstwy danych. Logika przechodzenia użytkownika do następnego ekranu to logika interfejsu , ponieważ te wymagania mogą się zmieniać w zależności od konfiguracji interfejsu. Na przykład możesz nie chcieć automatycznie przechodzić do innego ekranu na tablecie, jeśli wyświetlasz jednocześnie kilka etapów rejestracji. Zmienna validationInProgress w powyższym kodzie implementuje tę funkcję i określa, czy interfejs powinien automatycznie przechodzić, gdy data urodzenia jest prawidłowa, a użytkownik chce przejść do następnego etapu rejestracji.

Inne przypadki użycia

Jeśli uważasz, że przypadku użycia zdarzenia interfejsu nie można rozwiązać za pomocą aktualizacji stanu interfejsu, być może musisz ponownie rozważyć sposób przepływu danych w aplikacji. Weź pod uwagę te zasady:

  • Każda klasa powinna robić to, za co jest odpowiedzialna, i nic więcej. Interfejs 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 zdarzenie. Postępuj zgodnie ze schematem 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, to zdarzenie musi być obsługiwane w interfejsie. Niektóre elementy logiki można przekazać do ViewModel, ale obsługi zdarzenia nie można w całości przekazać do ViewModel.
  • Jeśli masz wielu odbiorców i obawiasz się, że zdarzenie zostanie wykorzystane kilka razy, być może musisz ponownie rozważyć architekturę aplikacji. W przypadku wielu równoczesnych odbiorców bardzo trudno jest zagwarantować umowę dostarczania dokładnie raz, dlatego złożoność i subtelne zachowanie gwałtownie rosną. Jeśli masz ten problem, rozważ przeniesienie tych kwestii wyżej w drzewie interfejsu. Może być potrzebny inny element o wyższym zakresie w hierarchii.
  • Zastanów się, kiedy stan ma być wykorzystywany. W niektórych sytuacjach możesz nie chcieć wykorzystywać stanu, gdy aplikacja działa w tle, np. wyświetlać Toast. W takich przypadkach rozważ wykorzystywanie stanu, gdy interfejs jest na pierwszym planie.

Przykłady

Te przykłady Google pokazują zdarzenia interfejsu w warstwie interfejsu. Zapoznaj się z nimi, aby zobaczyć te wskazówki w praktyce: