Eventos de IU

Eventos de IU são ações que precisam ser processadas na camada da IU, seja pela própria IU 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 IU consome esses eventos usando callbacks, como listeners onClick().

O ViewModel normalmente é responsável por gerenciar 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 IU pode chamar. Os eventos do usuário também podem ter uma lógica de comportamento que pode ser processada diretamente pela IU, 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 IU é um detalhe de implementação que pode ser diferente entre esses casos. A página de camada da IU define esses tipos de lógica da seguinte maneira:

  • Lógica de negócios refere-se 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 IU ou lógica da IU refere-se a como exibir alterações de estado. Por exemplo, lógica de navegação ou como mostrar mensagens. A IU processa essa lógica.

Árvore de decisões de evento da IU

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 IU. Se
    o evento for originado na IU e exigir uma lógica de negócios, delegue
    a lógica de negócios ao ViewModel. Se o evento for originado na IU e
    exigir lógica de comportamento, modifique o estado do elemento diretamente na
    IU.
Figura 1. Árvore de decisões para lidar com eventos.

Processar eventos do usuário

A IU pode processar os eventos do usuário diretamente se esses eventos se referem à modificação do estado de um elemento da IU, 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 IU (lógica da IU) 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 section 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 NewsApp() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = "latestNews") {
        composable("latestNews") {
            LatestNewsScreen(
                // The navigation event is processed by calling the NavController
                // navigate function that mutates its internal state.
                onProfileClick = { navController.navigate("profile") }
            )
        }
        /* ... */
    }
}

@Composable
fun LatestNewsScreen(
    viewModel: LatestNewsViewModel = viewModel(),
    onProfileClick: () -> Unit
) {
    Column {
        // 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")
        }
        Button(onClick = onProfileClick) {
            Text("Profile")
        }
    }
}

Eventos do usuário em RecyclerViews

Se a ação for produzida mais abaixo na árvore da IU, 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 IU, 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 IU originadas do ViewModel (eventos ViewModel) sempre precisam resultar em uma atualização do estado da IU. 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 IU 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 IU para o estado da IU 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 IU navegue para uma tela específica, por exemplo. Você precisa pensar em como representar esse fluxo de usuário no estado da IU. Em outras palavras: não pense em quais ações a IU precisa realizar; mas em como essas ações afetam o estado da IU.

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 IU da seguinte maneira:

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

Essa IU 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 IU pode resultar em outras atualizações do estado da IU. Por exemplo, ao exibir mensagens transitórias na tela para avisar ao usuário que algo aconteceu, a IU precisa notificar o ViewModel para que acione outra atualização do estado quando a mensagem tiver sido mostrada na tela. Esse estado de IU pode ser modelado da seguinte maneira:

// Models the message to show on the screen.
data class UserMessage(val id: Long, val message: String)

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessages: List<UserMessage> = emptyList()
)

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 IU 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 ->
                    val messages = currentUiState.userMessages + UserMessage(
                        id = UUID.randomUUID().mostSignificantBits,
                        message = "No Internet connection"
                    )
                    currentUiState.copy(userMessages = messages)
                }
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown(messageId: Long) {
        _uiState.update { currentUiState ->
            val messages = currentUiState.userMessages.filterNot { it.id == messageId }
            currentUiState.copy(userMessages = messages)
        }
    }
}

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()) {
                val messages = uiState.userMessages + UserMessage(
                    id = UUID.randomUUID().mostSignificantBits,
                    message = "No Internet connection"
                )
                uiState = uiState.copy(userMessages = messages)
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown(messageId: Long) {
        val messages = uiState.userMessages.filterNot { it.id == messageId }
        uiState = uiState.copy(userMessages = messages)
    }
}

O ViewModel não precisa saber como a IU está mostrando a mensagem na tela; ele já sabe que uma mensagem do usuário precisa ser exibida. Quando a mensagem temporária for exibida, a IU precisará notificar o ViewModel sobre isso, causando outra atualização do estado da IU:

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.userMessages.firstOrNull()?.let { userMessage ->
                        // TODO: Show Snackbar with userMessage.
                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown(userMessage.id)
                    }
                    ...
                }
            }
        }
    }
}

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 the first one and notify the ViewModel.
    viewModel.uiState.userMessages.firstOrNull()?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage.message)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown(userMessage.id)
        }
    }
}

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.