Camada de interface

O papel da IU é mostrar os dados do app na tela e atuar 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 precisa ser 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 IU é o pipeline que converte as mudanças de dados do app em um formato que a IU 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.
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 IU, como atividades e fragmentos que mostram os dados, independentemente de quais APIs são usadas para fazer isso (Views ou Jetpack Compose). 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 IU.
  • O fluxo de dados unidirecional (UDF, na sigla em inglês) como um meio de produção e gerenciamento do estado da IU.
  • Como expor o estado da IU com tipos de dados observáveis de acordo com os princípios do UDF.
  • Como implementar uma IU que consome o estado de IU observável.

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

Definir o estado da IU

Consulte o estudo de caso descrito acima. Resumidamente, a IU 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 IU é o que o usuário vê, o estado é o que o app diz que ele deve ver. 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 anterior. Para atender aos requisitos do app Google Notícias, as informações necessárias para renderizar totalmente a IU 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,
    ...
)

Imutabilidade

A definição do estado da IU no exemplo acima é 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 IU para se concentrar em uma única tarefa: ler o estado e atualizar os elementos da IU de acordo com ele. Por isso, nunca modifique o estado diretamente na IU, 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, se a sinalização bookmarked em um objeto NewsItemUiState do estado da IU no estudo de caso foi atualizada na classe Activity, essa sinalização pode 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 antipadrão.

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 realizando as transformações necessárias nas fontes de dados de apoio para criar o estado da IU. As interações e a lógica delas podem ser hospedadas na própria IU, mas isso pode ficar difícil de manejar conforme a IU começa a se tornar mais do que o nome sugere: ela se torna proprietária, produtora e transformadora dos dados e mais. Isso também pode afetar a capacidade de teste, porque o código resultante é um acoplamento rígido sem limites discerníveis. Em última análise, a IU se beneficia com a redução da carga. A menos que o estado da IU seja muito simples, a única responsabilidade da IU é 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

As classes responsáveis pela produção do estado da IU e que contêm a lógica necessária para essa tarefa são chamadas de detentores de estado. Os detentores 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 inferior (em inglês), 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:

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 IU e processar os eventos necessários para que a IU 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.

A lógica da IU, especialmente quando ela envolve tipos de IU como um Context, precisa residir na IU, e não no ViewModel. 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. Como você está usando o UDF para gerenciar a produção do estado, pode considerar o estado produzido como um fluxo. Em outras palavras, várias versões dele serão produzidas ao longo do tempo. Assim, você precisa expor o estado da IU em um detentor de dados observáveis, como LiveData ou StateFlow. O motivo é que a IU pode reagir a qualquer mudança feita no estado sem precisar extrair dados de forma manual diretamente da ViewModel. Esses tipos também têm o benefício de sempre ter a versão mais recente do estado da IU armazenada em cache, o que é útil para uma restauração rápida dele após mudanças de configuração.

Visualizações

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

    val uiState: StateFlow<NewsUiState> = …
}

Compose

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

    val uiState: NewsUiState = …
}

Para ver uma introdução ao LiveData como um detentor de dados observáveis, consulte este codelab. Para ver uma introdução semelhante para fluxos Kotlin, consulte Fluxos Kotlin no Android.

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. Além disso, à medida que o elemento da IU fica mais complexo, é sempre mais fácil fazer adições à definição do estado da IU para acomodar as informações extras necessárias para renderizar o elemento da IU.

Uma maneira comum de criar um fluxo da classe UiState é expor um fluxo mutável de apoio como um fluxo imutável da ViewModel. Por exemplo, expor MutableStateFlow<UiState> como StateFlow<UiState>.

Visualizações

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

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

Compose

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. Considere, por exemplo, o caso em que uma ação assíncrona precisa ser realizada. Uma corrotina pode ser iniciada usando a propriedade viewModelScope, e o estado mutável pode ser atualizado após a conclusão.

Visualizações

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

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

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

Compose

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 acima, a classe NewsViewModel tenta buscar matérias de uma determinada categoria e, em seguida, reflete o resultado da tentativa (com êxito ou falha) no estado da IU, onde a IU pode reagir a ela de maneira adequada. Consulte a seção Mostrar erros na tela para saber mais sobre como lidar com erros.

Outras considerações

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

  • Um objeto de estado da IU precisa 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 IU 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 IU em um único fluxo ou em vários está no tópico anterior, ou seja, a relação entre os itens emitidos. A maior vantagem de uma exposição de fluxo único é 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 desses estados 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 as visualizações 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 na visualização. Isso significa que a mitigação usando o Flow APIs ou métodos como distinctUntilChanged() no LiveData pode ser necessário.

Consumir o estado da IU

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

Ao consumir detentores de dados observáveis na IU, considere o ciclo de vida dela. Isso é importante porque a interface não pode observar o estado dela quando a visualização não está sendo mostrada para o usuário. Para saber mais sobre esse assunto, consulte esta postagem do blog (em inglês). Ao usar o LiveData, a interface LifecycleOwner cuida das questões do ciclo de vida de forma implícita. Ao usar fluxos, é recomendável gerenciar isso com o escopo adequado de corrotinas e a API repeatOnLifecycle:

Visualizações

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    viewModel: NewsViewModel = viewModel()
) {
    // Show UI elements based on the viewModel.uiState
}

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.

Visualizações

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

Compose

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

Por exemplo, considere o exemplo da seção anterior, que mostrou uma barra de progresso ao buscar matérias. 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 IU, como snackbars (link em inglês). Como isso está relacionado à forma como os eventos de IU são produzidos e consumidos, consulte a página Eventos de IU para saber mais.

Linhas de execução e simultaneidade

Todo trabalho realizado em um ViewModel precisa ser protegido, ou seja, ser seguro para chamadas da linha de execução principal. Isso ocorre porque as camadas de dados e de domínios 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. Gatilhos como esses precisam ser consumidos assim como os tratados na seção Consumir o estado da IU acima, exceto pelo fato de que a implementação do consumo precisa mudar para o componente de navegação.

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 IU imutável. Em vez disso, é preciso expor esse tipo na ViewModel de forma independente no fluxo dele. Consulte o codelab da Android Paging para ver um exemplo específico disso.

Animações

Para fornecer transições de navegação de nível superior fluidas e suaves, recomendamos que você aguarde a segunda tela carregar os dados antes de iniciar a animação. O framework de visualização do Android fornece hooks para atrasar transições entre destinos de fragmento com as APIs postponeEnterTransition() e startPostponedEnterTransition(). Essas APIs fornecem uma maneira de garantir que os elementos da IU na segunda tela, normalmente uma imagem buscada na rede, estejam prontos para serem mostrados antes da animação da transição para essa tela. Para ter mais detalhes e especificações de implementação, consulte o exemplo do Android Motion (em inglês).

Exemplos

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