Eventos de interface

Eventos de interface são ações que precisam ser processadas na camada da interface, seja pela própria interface ou pelo ViewModel. Os tipos de evento mais comuns são os eventos do usuário. O usuário produz esses eventos interagindo com o app, por exemplo, ao tocar na tela ou gerar gestos. Em seguida, a interface consome esses eventos usando callbacks, como listeners onClick().

O ViewModel normalmente é responsável por processar a lógica de negócios de um evento de usuário específico. Por exemplo, o usuário que clica em um botão para atualizar alguns dados. Geralmente, o ViewModel processa isso expondo funções que a interface pode chamar. Os eventos do usuário também podem ter uma lógica de comportamento que pode ser processada diretamente pela interface, como navegar para uma tela diferente ou mostrar uma Snackbar.

Enquanto a lógica de negócios permanece a mesma para o mesmo app em diferentes plataformas ou formatos de dispositivos móveis, a lógica de comportamento da interface é um detalhe de implementação que pode ser diferente entre esses casos. A página de camada da interface define esses tipos de lógica da seguinte maneira:

  • Lógica de negócios se refere a o que fazer com mudanças de estado. Por exemplo, efetuar um pagamento ou armazenar preferências de usuários. As camadas de domínio e dados geralmente processam essa lógica. Neste guia, a classe Architecture Components ViewModel é usada como uma solução rigorosa para classes que gerenciam a lógica de negócios.
  • Lógica de comportamento da interface ou lógica da interface refere-se a como exibir alterações de estado. Por exemplo, lógica de navegação ou como mostrar mensagens. A interface processa essa lógica.

Árvore de decisões de evento da interface

O diagrama a seguir mostra uma árvore de decisões para encontrar a melhor abordagem para lidar com um caso de uso específico de um evento. No restante deste guia, explicaremos essas abordagens em detalhes.

Se o evento foi originado no ViewModel, atualize o estado da interface. Se
    o evento for originado na interface e exigir uma lógica de negócios, delegue
    a lógica de negócios ao ViewModel. Se o evento for originado na interface e
    exigir lógica de comportamento, modifique o estado do elemento diretamente na
    interface.
Figura 1. Árvore de decisões para lidar com eventos.

Processar eventos do usuário

A interface pode processar os eventos do usuário diretamente se esses eventos se referem à modificação do estado de um elemento da interface, como, por exemplo, o estado de um item expansível. Se o evento requer a execução de uma lógica de negócios, como, por exemplo, atualizar os dados na tela, ele precisa ser processado pelo ViewModel.

O exemplo a seguir mostra como botões diferentes são usados para abrir um elemento da interface (lógica da interface) e atualizar os dados na tela (lógica de negócios):

Visualizações

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

Eventos do usuário em RecyclerViews

Se a ação for produzida mais abaixo na árvore da interface, como em um item RecyclerView ou em uma View personalizada, o ViewModel ainda deverá ser o responsável pelos eventos do usuário.

Por exemplo, suponha que todos os itens de notícias de NewsActivity tenham um botão de favoritos. O ViewModel precisa saber o ID do item de notícias favorito. Quando o usuário adiciona um item de notícias aos favoritos, o adaptador RecyclerView não chama a função exposta addBookmark(newsId) do ViewModel, o que exigiria uma dependência no ViewModel de dados. Em vez disso, o ViewModel expõe um objeto de estado chamado NewsItemUiState, que contém a implementação para processar o evento:

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

Dessa forma, o adaptador RecyclerView só funcionará com os dados necessários: a lista de objetos NewsItemUiState. O adaptador não tem acesso a todo o ViewModel, diminuindo a probabilidade de abusar da funcionalidade exposta por ele. Ao permitir que apenas a classe da atividade funcione com o ViewModel, você separa as responsabilidades. Isso garante que objetos específicos da interface, como visualizações ou adaptadores RecyclerView, não interajam diretamente com o ViewModel.

Convenções de nomenclatura para funções de evento do usuário

Neste guia, as funções do ViewModel que processam eventos de usuários são nomeadas com um verbo baseado na ação que elas processam, como addBookmark(id) ou logIn(username, password).

Processar eventos do ViewModel

As ações da interface originadas do ViewModel (eventos ViewModel) sempre precisam resultar em uma atualização do estado da interface. Isso está em conformidade com os princípios do fluxo de dados unidirecional. Esse fluxo torna os eventos reproduzíveis depois que a configuração é alterada e garante que as ações da interface não sejam perdidas. Você também pode tornar os eventos reproduzíveis após a interrupção do processo se usar o módulo de estado salvo.

Mapear ações de interface para o estado da interface nem sempre é um processo simples, mas leva a uma lógica mais simples. Seu processo de criação não deve terminar com a determinação de como fazer com que a interface navegue para uma tela específica, por exemplo. Você precisa pensar em como representar esse fluxo de usuário no estado da interface. Em outras palavras: não pense em quais ações a interface precisa realizar; mas em como essas ações afetam o estado da interface.

Por exemplo, considere o caso de navegar para a tela inicial quando o usuário estiver conectado na tela de login. Você pode modelar isso no estado da interface da seguinte maneira:

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

Essa interface reage às alterações no estado isUserLoggedIn e navega para o destino correto, conforme necessário:

Visualizações

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

O consumo de eventos pode acionar atualizações de estado

O consumo de determinados eventos do ViewModel na interface pode resultar em outras atualizações do estado dela. Por exemplo, ao mostrar mensagens transitórias na tela para avisar ao usuário que algo aconteceu, a interface precisa notificar o ViewModel para que ele acione outra atualização do estado quando a mensagem tiver aparecido. O evento que acontece quando o usuário consome a mensagem, após um tempo limite ou a dispensando, pode ser tratado como uma "entrada do usuário", e o ViewModel precisa estar ciente disso. Nessa situação, o estado da interface pode ser modelado desta maneira:

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

Quando a lógica de negócios exige que uma nova mensagem temporária apareça para o usuário, o ViewModel atualiza o estado da interface da seguinte forma:

Visualizações

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

O ViewModel não precisa saber como a interface está mostrando a mensagem. Ele apenas sabe que uma mensagem do usuário precisa aparecer na tela. Depois que a mensagem temporária for mostrada, a interface vai precisar notificar o ViewModel sobre isso, fazendo com que outra atualização do estado da interface limpe a propriedade userMessage:

Visualizações

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

O estado da interface é uma representação exata do conteúdo mostrado na tela em cada momento, mesmo que a mensagem seja temporária. A mensagem do usuário pode aparecer na tela ou não.

A seção O consumo de eventos pode acionar atualizações de estado apresenta detalhes sobre o uso do estado da interface ao mostrar mensagens do usuário na tela. Os eventos de navegação também são comuns em apps Android.

Se o evento for acionado na interface porque o usuário tocou em um botão, ela responde chamando o controlador de navegação ou expondo o evento ao elemento combinável do autor da chamada, dependendo do caso.

Visualizações

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

Se a entrada de dados exigir uma validação da lógica de negócios antes da navegação, o ViewModel precisará expor esse estado à interface. Nesse caso, a interface responde à mudança de estado e executa a navegação correspondente. A seção Processar eventos do ViewModel trata desse caso de uso. Confira um exemplo de código parecido:

Visualizações

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

No exemplo acima, o app funciona da maneira esperada porque o destino atual, Login, não é mantido na backstack. Os usuários não podem retornar a ele ao pressionar "Voltar". Nos casos em que isso acontece, é necessário adicionar outra lógica para solucionar o problema.

Quando um ViewModel define um estado que gera um evento de navegação da tela A para a B, e a tela A é mantida na backstack de navegação, pode ser necessário adicionar outra lógica para impedir que o app avance automaticamente para a tela B. Para implementar esse comportamento, você precisa incluir um novo estado que indique se a interface vai navegar para a outra tela ou não. Geralmente, esse estado é mantido na interface porque é ela que processa a lógica de navegação, e não o ViewModel. Para ilustrar esse comportamento, vamos analisar o caso de uso abaixo.

Vamos considerar o fluxo de registro do seu app. Na tela de validação da data de nascimento, a data inserida é validada pelo ViewModel quando o usuário toca no botão "Continuar". O ViewModel delega a lógica de validação à camada de dados. Se a data for válida, o usuário vai passar para a próxima tela. Como um recurso extra, o app pode permitir que o usuário navegue entre as diferentes telas de registro caso ele queira modificar os dados inseridos. Para isso, todos os destinos do fluxo de registro são mantidos na mesma backstack. Considerando esses requisitos, essa tela pode ser implementada desta forma:

Visualizações

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

A data de nascimento é uma lógica de negócios pela qual o ViewModel é responsável. Na maioria das vezes, o ViewModel delega essa lógica à camada de dados. A lógica que leva o usuário à próxima tela é a lógica da interface, porque esses requisitos podem mudar dependendo da configuração da interface do app. Por exemplo, pode ser preferível não avançar automaticamente para outra tela em um tablet caso o app esteja mostrando várias etapas de registro ao mesmo tempo. A variável validationInProgress no código acima implementa essa funcionalidade e define se a interface vai passar automaticamente para a tela seguinte quando a data de nascimento for validada e o usuário quiser avançar para a próxima etapa.

Outros casos de uso

Se você achar que o caso de uso de eventos da IU não pode ser resolvido com atualizações do estado, talvez seja necessário reconsiderar como os dados fluem no seu app. Considere os seguintes princípios:

  • Cada classe deve fazer aquilo pelo que é responsável, não mais que isso. A IU é responsável pela lógica de comportamento específico da tela, como, por exemplo, chamadas de navegação, eventos de clique e solicitações de permissão. O ViewModel contém lógica de negócios e converte os resultados de camadas inferiores da hierarquia em estado da IU.
  • Pense na origem do evento. Siga a árvore de decisões apresentada no início deste guia e faça com que cada classe processe aquilo pelo que é responsável. Por exemplo, se o evento tiver origem na IU e resultar em um evento de navegação, ele precisará ser processado na IU. Algumas lógicas podem ser delegadas ao ViewModel, mas o processamento do evento não pode ser totalmente delegado a ele.
  • Se você tiver vários consumidores e estiver preocupado com o evento ser consumido várias vezes, talvez seja necessário reconsiderar a arquitetura do seu app. Ter vários consumidores simultâneos torna extremamente difícil garantir um contrato entregue exatamente uma única vez, o que faz com que a complexidade e o comportamento sutil excedam os parâmetros. Se você tiver esse problema, tente enviar essas preocupações para a árvore de IU. Talvez seja necessário uma entidade diferente com escopo mais alto na hierarquia.
  • Pense em quando o estado precisa ser consumido. Em determinadas situações, talvez você não queira continuar consumindo o estado quando o app estiver em segundo plano, por exemplo, mostrando um Toast. Nesses casos, considere o consumo do estado quando a IU estiver em primeiro plano.

Exemplos

Os exemplos do Google abaixo demonstram os eventos de interface na camada de interface. Acesse-os para conferir a orientação na prática: