Camada de IU

A função da interface é mostrar os dados do app na tela. A interface também serve como o ponto principal de interação do usuário. Sempre que os dados mudam, seja devido à interação do usuário (como o pressionamento de um botão) ou a entradas externas (como uma resposta de rede), a interface é atualizada para refletir essas mudanças. A IU é uma representação visual do estado do app recuperado da camada de dados.

No entanto, os dados do app recebidos da camada de dados costumam estar em um formato diferente das informações que são que precisam ser mostradas. Por exemplo, talvez você só precise de parte dos dados da IU ou tenha que combinar duas fontes de dados diferentes para apresentar informações relevantes ao usuário. Independentemente da lógica aplicada, você deve transmitir à IU todas as informações que ela precisa renderizar totalmente. A camada de UI é o pipeline que converte as mudanças de dados do app em um formato que a UI pode usar para que elas sejam mostradas.

Em uma arquitetura típica, os elementos da camada de interface dependem dos detentores
    do estado, que por sua vez, dependem de classes da camada de dados ou
    da camada de domínios opcional.
Figura 1. O papel da camada de IU na arquitetura do app.

Um estudo de caso básico

Considere um app que busca matérias para um usuário ler. Ele tem uma tela que apresenta as matérias disponíveis para leitura e também permite que os usuários conectados adicionem as mais interessantes aos favoritos. Como pode haver várias matérias em um determinado momento, o leitor precisa ter uma função para procurar por categoria. Em resumo, o app permite que os usuários:

  • vejam as matérias disponíveis para leitura;
  • procurem matérias por categoria;
  • façam login e adicionem matérias específicas aos favoritos;
  • acessem alguns recursos premium, se estiverem qualificados.
Um exemplo de app de notícias mostrando prévias de artigos, um dos quais está marcado.
Figura 2. Um exemplo de app de notícias para um estudo de caso de IU.

As seções a seguir usam esse exemplo como estudo de caso para apresentar os princípios de fluxo de dados unidirecional e ilustrar os problemas que esses princípios ajudam a resolver no contexto da arquitetura do app para a camada de interface.

Arquitetura da camada de IU

O termo IU se refere a elementos da interface, como contêineres e funções combináveis que mostram dados. Para criar IUs do Android, o Jetpack Compose é o kit de ferramentas recomendado. Como o papel da camada de dados é reter, gerenciar e fornecer acesso aos dados do app, a camada de IU precisa seguir as seguintes etapas:

  1. Consumir dados do app e os transformar para que possam ser renderizados facilmente pela IU.
  2. Consumir dados que podem ser renderizados pela IU e os transformar em elementos da IU para apresentação ao usuário.
  3. Consumir eventos de entrada do usuário desses elementos da IU criados e refletir os efeitos nos dados da IU conforme adequado.
  4. Repetir as etapas de 1 a 3 pelo tempo necessário.

O restante deste guia demonstra como implementar uma camada de interface que realiza essas etapas. Especificamente, este guia abrange os seguintes conceitos e tarefas:

  • Como definir o estado da interface
  • O fluxo de dados unidirecional (UDF, na sigla em inglês) como um meio de produção e gerenciamento do estado da interface.
  • Como expor o estado da interface com tipos de dados observáveis de acordo com os princípios do UDF
  • Como implementar uma interface que consome o estado de interface observável

O mais importante é a definição do estado da IU.

Definir o estado da IU

No estudo de caso descrito anteriormente, a UI mostra uma lista de matérias com alguns metadados para cada uma delas. Essas informações que o app apresenta ao usuário são o estado da IU.

Em outras palavras, se a interface é o que o usuário vê, o estado dela é o que o app diz que precisa ser mostrado. Assim como dois lados da mesma moeda, a IU é a representação visual do estado da IU. Todas as mudanças no estado são imediatamente refletidas nela.

A IU é resultado da vinculação de elementos da IU na tela com o estado dela.
Figura 3. A IU é resultado da vinculação de elementos da IU na tela com o estado dela.

Considere o estudo de caso: para atender aos requisitos do app Google Notícias, as informações necessárias para renderizar totalmente a interface podem ser encapsuladas em uma classe de dados NewsUiState definida da seguinte maneira:

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

Para mais informações sobre o estado da interface, consulte Estado e Jetpack Compose.

Imutabilidade

A definição do estado da interface no exemplo anterior é imutável. O principal benefício disso é que os objetos imutáveis fornecem garantias sobre o estado do app em apenas um instante. Isso libera a interface para se concentrar na função principal: ler o estado e atualizar os elementos da interface de acordo com ele. Nunca modifique o estado diretamente na interface, a menos que ela seja a única fonte dos dados. A violação desse princípio resulta em várias fontes da verdade para a mesma informação, levando a inconsistências de dados e bugs sutis.

Por exemplo, considere o estudo de caso anterior. Se a flag bookmarked em um objeto NewsItemUiState do estado da UI for atualizada na classe Activity, ela vai competir com a camada de dados como fonte do status de favorito de uma matéria. As classes de dados imutáveis são muito úteis para evitar esse tipo de inconsistência.

Convenções de nomenclatura neste guia

Neste guia, as classes de estado da IU são nomeadas com base na funcionalidade da tela ou parte da tela que descrevem. A convenção é esta:

funcionalidade + UiState.

Por exemplo, o estado de uma tela que mostra notícias pode ser chamado de NewsUiState, e o estado de um item de notícias em uma lista de itens de notícias pode ser um NewsItemUiState.

Gerenciar o estado com um fluxo de dados unidirecional

A seção anterior estabeleceu que o estado da IU é um snapshot imutável dos detalhes necessários para que a IU seja renderizada. No entanto, a natureza dinâmica dos dados em apps significa que o estado pode mudar com o tempo. Isso pode ocorrer devido à interação do usuário ou a outros eventos que modificam os dados usados para preencher o app.

Essas interações podem se beneficiar de um mediador para o processamento, definindo a lógica a ser aplicada a cada evento e transformando as fontes de dados de apoio para criar o estado da UI. Embora essas interações e a lógica delas possam ser hospedadas na própria interface, isso pode ficar difícil de manejar conforme a interface assume muita responsabilidade. Isso também pode afetar a capacidade de teste, porque o código resultante é um acoplamento rígido. A menos que o estado da interface seja muito simples, a única responsabilidade da interface é consumir e mostrar o estado.

Nesta seção, discutiremos o fluxo de dados unidirecional (UDF, na sigla em inglês), um padrão de arquitetura que ajuda a aplicar essa separação saudável de responsabilidades.

Detentores de estado

Os detentores de estado são as classes responsáveis por produzir o estado da interface e pela lógica necessária para isso. Os detentores de estado têm vários tamanhos, dependendo do escopo dos elementos da interface correspondentes que eles gerenciam, variando de um único widget, como uma barra de apps na parte de baixo, a uma tela inteira ou um destino de navegação.

No último caso, a implementação típica é uma instância de uma classe ViewModel. No entanto, dependendo dos requisitos do app, uma classe simples pode ser suficiente. O app de notícias do estudo de caso, por exemplo, usa uma classe NewsViewModel como detentor de estado para produzir o estado da IU para a tela mostrada nessa seção.

Há várias maneiras de modelar a codependência entre a IU e o produtor de estados dela. No entanto, como a interação entre a IU e a classe ViewModel dela pode ser entendida como uma entrada do evento e o consequente estado de saída, a relação pode ser representada conforme mostrado no diagrama a seguir:

Os dados do app fluem da camada de dados para a ViewModel. O estado da IU
    flui da ViewModel para os elementos da IU, e os eventos fluem dos elementos da
    IU de volta para a ViewModel.
Figura 4. Diagrama de como o UDF funciona na arquitetura do app.

O padrão em que o fluxo do estado desce e dos eventos sobe é chamado de fluxo de dados unidirecional (UDF, na sigla em inglês). As implicações desse padrão para a arquitetura do app são as seguintes:

  • A ViewModel retém e expõe o estado a ser consumido pela IU. O estado da IU são os dados do app transformados pela ViewModel.
  • A IU notifica a ViewModel sobre eventos do usuário.
  • A ViewModel processa as ações do usuário e atualiza o estado.
  • O estado atualizado é retornado à IU para renderização.
  • O processo acima é repetido para qualquer evento que cause uma mutação de estado.

Para telas ou destinos de navegação, a ViewModel funciona com repositórios ou classes de casos de uso para receber dados e os transformar no estado da IU, incorporando os efeitos de eventos que podem causar mutações do estado. O estudo de caso mencionado anteriormente contém uma lista de matérias, cada uma com título, descrição, fonte, nome do autor, data de publicação e indicação se ela foi adicionada aos favoritos ou não. A IU de cada item de matéria vai ficar assim:

Um único item de artigo do app de estudo de caso. A interface mostra uma miniatura, o título do artigo, o autor, o tempo estimado de leitura do artigo e um ícone de marcador.
Figura 5. IU de um item de matéria no app do estudo de caso.

Quando um usuário pede para adicionar uma matéria aos favoritos, temos um exemplo de evento que pode causar mutações do estado. Como produtora do estado, é responsabilidade da ViewModel definir toda a lógica exigida para preencher todos os campos no estado da interface e processar os eventos necessários para que a interface seja totalmente renderizada.

Um evento de IU ocorre quando o usuário adiciona uma matéria aos favoritos. A ViewModel
    notifica a camada de dados sobre a mudança de estado. A camada de dados mantém as
    mudanças de dados e atualiza os dados do app. Os novos dados do app com a
    matéria adicionada aos favoritos são transmitidos para a ViewModel, que produz o
    novo estado da IU e os transmite para os elementos dela para exibição.
Figura 6. Diagrama ilustrando o ciclo de eventos e dados no UDF.

As seções a seguir detalham os eventos que causam mudanças de estado e como eles podem ser processados usando o UDF.

Tipos de lógica

Adicionar uma matéria aos favoritos é um exemplo de lógica de negócios, porque agrega valor ao seu app. Para saber mais, consulte a página Camada de dados. No entanto, há diferentes tipos de lógica que precisam ser definidas:

  • A lógica de negócios é a implementação de requisitos de produtos para dados do app. Como já mencionamos, um exemplo é adicionar uma matéria aos favoritos no app do estudo de caso. A lógica de negócios geralmente é colocada nas camadas de domínio ou de dados, mas nunca na de IU.
  • A lógica de comportamento da interface ou lógica da interface se refere a como mostrar as mudanças de estado na tela, por exemplo, extrair o texto certo a ser mostrado na tela usando Resources do Android, navegar para uma tela específica quando o usuário clicar em um botão ou mostrar uma mensagem na tela usando um aviso ou uma snackbar.

Mantenha a lógica da interface na interface, e não no ViewModel, principalmente quando ela envolve tipos de interface como Context. Se a IU ficar mais complexa e você quiser delegar a lógica dela para outra classe, favorecendo a capacidade de teste e a separação de conceitos, crie uma classe simples como detentor de estado. Classes simples criadas na IU podem usar dependências do SDK do Android porque seguem o ciclo de vida da IU. Os objetos ViewModel têm uma vida útil mais longa.

Para saber mais sobre os detentores de estado e como eles se encaixam no contexto de ajuda para criar a IU, consulte o guia de estado do Jetpack Compose.

Por que usar o UDF?

O UDF modela o ciclo de produção de estado, conforme mostrado na Figura 4. Ele também separa o local de origem das mudanças de estado, o lugar onde elas são transformadas e onde são finalmente consumidas. Essa separação permite que a IU faça exatamente o que nome dela indica: mostre informações observando as mudanças de estado e redirecione a intent do usuário transmitindo essas mudanças para a ViewModel.

Em outras palavras, o UDF permite o seguinte:

  • Consistência de dados. Há uma única fonte da verdade para a IU.
  • Capacidade de teste. A origem do estado é isolada e, portanto, testável independentemente da IU.
  • Capacidade de manutenção. A mutação do estado segue um padrão bem definido, em que as mutações são resultado de eventos do usuário e das fontes de dados extraídas.

Expor o estado da IU

Após definir o estado da IU e determinar como você vai gerenciar a produção desse estado, a próxima etapa é apresentar o estado produzido à IU.

Ao usar o UDF para gerenciar a produção do estado, você pode considerar o estado produzido como um fluxo. Em outras palavras, várias versões dele são produzidas ao longo do tempo. Exponha o estado da interface em um detentor de dados observáveis, como StateFlow. Isso permite que a UI reaja a qualquer mudança feita no estado sem precisar extrair dados de forma manual diretamente da ViewModel. Isso também tem o benefício de sempre ter a versão mais recente do estado da interface armazenada em cache, o que é útil para uma restauração rápida dele após mudanças de configuração.

class NewsViewModel(...) : ViewModel() {

    val uiState: NewsUiState = 
}

Para uma introdução aos fluxos Kotlin, consulte Fluxos Kotlin no Android. Para saber como usar StateFlow como um contêiner de dados observáveis, consulte o codelab Estado avançado e efeitos colaterais no Jetpack Compose.

Nos casos em que os dados expostos são relativamente simples, geralmente vale a pena envolver os dados em um tipo de estado da interface, porque ele transmite a relação entre a emissão do detentor do estado e o elemento da interface ou da tela associados. À medida que o elemento da interface fica mais complexo, é fácil adicionar à definição do estado da interface, para que você possa acomodar as informações extras necessárias para renderizar o elemento da interface.

Uma maneira comum de criar um fluxo de UiState é expondo uma propriedade mutableStateOf com um private set, mantendo o estado mutável dentro da ViewModel mas somente leitura para a UI.

class NewsViewModel(...) : ViewModel() {

    var uiState by mutableStateOf(NewsUiState())
        private set

    ...
}

A ViewModel pode expor métodos que modificam internamente o estado, publicando atualizações para que a IU consuma. Por exemplo, quando você precisa realizar uma ação assíncrona. Você pode iniciar uma corrotina usando o viewModelScope e atualizar o estado mutável após a conclusão.

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    var uiState by mutableStateOf(NewsUiState())
        private set

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                uiState = uiState.copy(newsItems = newsItems)
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                val messages = getMessagesFromThrowable(ioe)
                uiState = uiState.copy(userMessages = messages)
            }
        }
    }
}

No exemplo anterior, a classe NewsViewModel tenta buscar artigos de uma determinada categoria e, em seguida, reflete o resultado da tentativa (com êxito ou falha) no estado da UI, onde a UI pode reagir a ela de maneira adequada. Para mais informações sobre tratamento de erros, consulte a seção Mostrar erros na tela.

Outras considerações

Além da orientação anterior, considere o seguinte ao expor o estado da IU:

  • Use um único objeto de estado da UI para processar estados relacionados entre si. Isso leva a menos inconsistências e facilita a compreensão do código. Se você expuser a lista de itens de notícias e o número de favoritos em dois fluxos diferentes, poderá acabar em uma situação em que um foi atualizado, mas o outro não. Quando você usa um único fluxo, os dois elementos são mantidos atualizados. Além disso, algumas lógicas de negócios podem exigir uma combinação de fontes. Por exemplo, talvez seja necessário mostrar um botão de favorito apenas se o usuário estiver conectado e for assinante de um serviço premium de notícias. Você pode definir uma classe de estado da UI da seguinte maneira:

    data class NewsUiState(
        val isSignedIn: Boolean = false,
        val isPremium: Boolean = false,
        val newsItems: List<NewsItemUiState> = listOf()
    )
    
    val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
    

    Nessa declaração, a visibilidade do botão de favoritos é uma propriedade derivada de duas outras. À medida que a lógica de negócios medida fica mais complexa, ter uma classe UiState única, em que todas as propriedades ficam imediatamente disponíveis, passa a ser cada vez mais importante.

  • Um ou vários fluxos para o estado da IU: O princípio orientador mais importante para escolher entre expor o estado da interface em um único fluxo ou em vários é a relação entre os itens emitidos. As maiores vantagens de uma exposição de fluxo único são a conveniência e a consistência de dados: os consumidores de estado sempre têm as informações mais recentes disponíveis a qualquer momento. No entanto, há casos em que fluxos separados de estado da ViewModel podem ser adequados:

    • Tipos de dados não relacionados: alguns estados necessários para renderizar a IU podem ser completamente independentes uns dos outros. Em casos como esses, os custos de agrupar os estados diferentes podem superar os benefícios, especialmente se um deles for atualizado com mais frequência do que os outros.

    • Diferenciação de UiState:quanto mais campos há em um objeto UiState, maior é a probabilidade de que o fluxo seja emitido como resultado de um dos campos dele sendo atualizado. Como os elementos da interface não têm um mecanismo de diferenciação para entender se as emissões consecutivas são diferentes ou iguais, cada emissão gera uma atualização no elemento da interface. Isso significa que a mitigação usando métodos da API Flow, como distinctUntilChanged(), pode ser necessária.

Para mais informações sobre renderização e estado da interface, consulte Ciclo de vida de elementos combináveis.

Consumir o estado da IU

Para consumir o fluxo de objetos UiState na UI, use o operador de terminal para o tipo de dados observáveis que você está usando. Por exemplo, para fluxos Kotlin, use o método collect() ou as variações dele.

Ao consumir detentores de dados observáveis na interface, considere o ciclo de vida dela. Não faça a interface observar o estado dela quando o elemento combinável não estiver sendo mostrado ao usuário. Para saber mais sobre esse assunto, consulte esta postagem do blog. Ao usar fluxos, é recomendável gerenciar questões de ciclo de vida com o escopo adequado de corrotinas e a API collectAsStateWithLifecycle:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

Mostrar operações em andamento

Uma maneira simples de representar os estados de carregamento em uma classe UiState é com um campo booleano:

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

O valor dessa sinalização representa a presença ou ausência de uma barra de progresso na interface.

@Composable
fun LatestNewsScreen(
    modifier: Modifier = Modifier,
    viewModel: NewsViewModel = viewModel()
) {
    Box(modifier.fillMaxSize()) {

        if (viewModel.uiState.isFetchingArticles) {
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }

        // Add other UI elements. For example, the list.
    }
}

Mostrar erros na tela

A ação de mostrar erros na IU é semelhante à de mostrar operações em andamento, porque ambas são facilmente representadas por valores booleanos que indicam a presença ou ausência delas. No entanto, os erros também podem incluir uma mensagem associada para ser mostrada ao usuário ou uma ação associada a eles, que tenta executar novamente a operação que falhou. Por isso, enquanto uma operação em andamento está ou não sendo carregada, os estados de erro podem precisar ser modelados com as classes de dados que hospedam os metadados adequados para o contexto do erro.

Considere o exemplo anterior que mostrou uma barra de progresso ao buscar artigos. Se essa operação resultar em um erro, recomendamos mostrar uma ou mais mensagens para o usuário detalhando o que deu errado.

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

As mensagens de erro podem ser apresentadas ao usuário na forma de elementos da interface, como snackbars (link em inglês). Para mais informações sobre como os eventos de UI são produzidos e consumidos, consulte Eventos de UI.

Linhas de execução e simultaneidade

Verifique se todo o trabalho realizado em um ViewModel é protegido, ou seja, seguro para chamadas da linha de execução principal. As camadas de dados e de domínio são responsáveis por mover o trabalho para uma linha de execução diferente.

Se uma ViewModel realizar operações de longa duração, ela também será responsável por mover essa lógica para uma linha de execução em segundo plano. As corrotinas do Kotlin são uma ótima maneira de gerenciar operações simultâneas, e os componentes de arquitetura do Jetpack fornecem suporte integrado para elas. Para saber mais sobre o uso de corrotinas em apps Android, consulte Corrotinas do Kotlin no Android.

As mudanças na navegação dos apps geralmente são impulsionadas por emissões semelhantes a eventos. Por exemplo, depois que uma classe SignInViewModel faz login, o objeto UiState pode ter um campo isSignedIn definido como true. Consuma gatilhos como esses assim como os tratados na seção anterior Consumir o estado da UI, mas adie a implementação do consumo para o componente Navigation.

Para mais informações sobre a navegação na interface, consulte Navegação 3.

Paging

A biblioteca Paging é consumida na IU com um tipo chamado PagingData. Como o PagingData representa e contém itens que podem mudar ao longo do tempo, ou seja, não esse é um tipo imutável, ele não pode ser representado em um estado de UI imutável. Em vez disso, exponha esse tipo na ViewModel de forma independente no fluxo dele.

O exemplo a seguir mostra a API Compose da biblioteca Paging:

@Composable
fun MyScreen(flow: Flow<PagingData<String>>) {
    val lazyPagingItems = flow.collectAsLazyPagingItems()
    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it }
        ) { index ->
            val item = lazyPagingItems[index]
            Text("Item is $item")
        }
    }
}

Animações

Para fornecer transições de navegação de nível superior suaves, você pode querer aguardar a segunda tela carregar os dados antes de iniciar a animação.

Para mais informações sobre transições de navegação, consulte Navigation 3 e Transições de elementos compartilhados no Compose.

Outros recursos

Visualiza conteúdo

Amostras

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