Detentores de estado e estado da IU

O Guia da camada de interface aborda o fluxo de dados unidirecional (UDF, na sigla em inglês) como uma forma de produzir e gerenciar o estado da interface para a camada da interface.

Os dados fluem unidirecionalmente da camada de dados para a IU.
Figura 1: fluxo de dados unidirecional.

Ele também destaca os benefícios de delegar o gerenciamento do UDF a uma classe especial, conhecida como um detentor de estado. É possível implementar um detentor de estado usando um ViewModel ou uma classe simples. Este documento analisa melhor os detentores de estado e o papel que desempenham na camada da IU.

Ao final do documento, você vai compreender como gerenciar o estado do aplicativo na camada da interface. Esse é o pipeline de produção do estado da interface. Você vai aprender sobre as informações abaixo:

  • Os tipos de estado da IU que existem na camada da IU.
  • Os tipos de lógica que operam nesses estados da interface do usuário na camada da IU.
  • Como escolher a implementação adequada de um detentor de estado, como um ViewModel ou uma classe simples.

Elementos do pipeline de produção do estado da IU

O estado da IU e a lógica de produção dele definem a camada da IU.

Estado da IU

O estado da interface é a propriedade que descreve a interface. Há dois tipos de estados da interface:

  • O estado da interface da tela é o que você precisa mostrar na tela. Por exemplo, uma classe NewsUiState pode conter as matérias de notícias e outras informações necessárias para renderizar a interface. Esse estado geralmente está conectado a outras camadas da hierarquia, já que ele contém dados do app.
  • O estado do elemento da interface se refere a propriedades intrínsecas aos elementos da interface que influenciam a renderização. Um elemento da interface pode ser mostrado ou ocultado e pode ter um determinado tipo, tamanho ou cor da fonte. No Android, a visualização gerencia esse estado por conta própria, já que ela tem um estado inerente e expõe métodos de modificação ou consulta. Um exemplo disso são os métodos get e set da classe TextView para o texto. No Jetpack Compose, o estado é externo ao elemento combinável, e você pode até elevar o estado para fora da proximidade do elemento e o enviar até a função combinável que fez a chamada ou um detentor de estado. Um exemplo disso é o ScaffoldState do elemento combinável Scaffold.

Lógica

O estado da IU não é uma propriedade estática, já que os dados do app e os eventos do usuário fazem mudanças nele com o tempo. A lógica determina as especificidades da mudança, incluindo quais partes do estado da IU mudaram, por quais motivos a alteração ocorreu e quando ela precisa mudar.

A lógica produz o estado da IU
Figura 2: a lógica como a produtora do estado da interface

A lógica em um aplicativo pode ser de negócios ou de interface:

  • A lógica de negócios é a implementação de requisitos de produtos para dados do app. Por exemplo, adicionar uma matéria aos favoritos em um app de notícias quando o usuário toca no botão. Essa lógica para salvar um favorito em um arquivo ou banco de dados geralmente é colocada nas camadas de domínio ou de dados. O detentor do estado geralmente delega essa lógica a essas camadas chamando os métodos que elas expõem.
  • A lógica da interface está relacionada a como mostrar o estado da interface na tela. Por exemplo, acessar a dica correta da barra de pesquisa quando o usuário seleciona uma categoria, rolar para um determinado item em uma lista ou a lógica de navegação para uma tela específica quando o usuário clica em um botão.

Ciclo de vida do Android e os tipos de estado e lógica da interface

A camada da IU tem duas partes: uma dependente e a outra independente do ciclo de vida da IU. Essa separação determina as fontes de dados disponíveis para cada parte e exige diferentes tipos de estado e lógica da IU.

  • Independente do ciclo de vida da IU: essa parte da camada da IU lida com as camadas de produção de dados do app (camadas de dados ou de domínio) e é definida pela lógica de negócios. O ciclo de vida, as mudanças de configuração e a recriação da Activity na interface podem afetar a atividade do pipeline de produção de estado da interface, mas não afetam a validade dos dados produzidos.
  • Dependente do ciclo de vida da IU: essa parte da camada da interface do usuário lida com a lógica da IU e é diretamente influenciada por mudanças de ciclo de vida ou configuração. Essas mudanças afetam diretamente a validade das fontes de dados lidas na IU, e, como resultado, o estado só pode mudar quando o ciclo de vida está ativo. Exemplos incluem permissões de execução e de recebimento de recursos dependentes de configuração, como strings localizadas.

Confira um resumo na tabela abaixo:

Independente do ciclo de vida da IU Dependente do ciclo de vida da IU
Lógica de negócios Lógica da IU
Estado da IU na tela

O pipeline de produção do estado da IU

O pipeline de produção do estado da interface se refere às etapas realizadas para produzir o estado da interface. Essas são as etapas da aplicação dos tipos de lógica definidos anteriormente e são completamente dependentes das necessidades da IU. Algumas interfaces podem se beneficiar das partes independentes e dependentes do ciclo de vida da interface do pipeline ao mesmo tempo, de apenas uma delas ou de nenhuma.

Ou seja, estas permutações do pipeline da camada da IU são válidas:

  • Estado da IU produzido e gerenciado pela própria IU. Por exemplo, um contador básico simples e reutilizável:

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • Lógica da IU → IU. Por exemplo, mostrar ou ocultar um botão que permite que o usuário volte para o topo de uma lista.

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • Lógica de negócios → IU. Um elemento da IU mostrando a foto do usuário atual na tela.

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • Lógica de negócios → lógica da IU → IU. Um elemento da IU que rola para mostrar as informações corretas na tela de um determinado estado da IU.

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

Nos casos em que os dois tipos de lógica são aplicados ao pipeline de produção do estado da IU, a lógica de negócios precisa ser sempre aplicada antes da lógica da IU. Tentar aplicar na ordem inversa implica que a lógica de negócios depende da lógica da IU. As próximas seções abordam por que isso é um problema em uma análise detalhada de diferentes tipos de lógica e dos detentores de estado delas.

Fluxos de dados da camada de produção de dados para a IU
Figura 3: aplicação da lógica na camada da IU.

Detentores de estado e as responsabilidades deles

A responsabilidade de um detentor de estado é armazenar o estado para que ele possa ser lido pelo app. Nos casos em que a lógica é necessária, ela atua como intermediária e fornece acesso às fontes de dados que hospedam a lógica exigida. Dessa forma, o detentor de estado delega a lógica à fonte de dados adequada.

Isso gera os benefícios abaixo:

  • IUs simples: a IU se vincula apenas ao estado dela.
  • Facilidade de manutenção: a lógica definida no detentor de estado pode ser iterada sem mudar a IU em si.
  • Facilidade de testagem: a IU e a lógica de produção do estado dela podem ser testadas de forma independente.
  • Legibilidade: pessoas que lerem o código poderão notar claramente as diferenças entre o código de apresentação da interface e o código de produção do estado.

Independente do tamanho ou escopo, cada elemento da IU tem uma relação individual com o detentor de estado correspondente. Além disso, um detentor de estado precisa aceitar e processar qualquer ação do usuário que possa resultar em uma mudança de estado da interface e produzir a mudança de estado subsequente.

Tipos de detentores de estado

De forma semelhante aos tipos de estado e lógica, há dois tipos de detentores de estado definidos pela relação com o ciclo de vida da interface:

  • O detentor de estado da lógica de negócios.
  • O detentor de estado da lógica da IU.

As seções abaixo detalham os tipos de detentores de estado, começando com o detentor da lógica de negócios.

A lógica de negócios e o detentor de estado dela

Os detentores de estado da lógica de negócios processam eventos do usuário e transformam os dados das camadas de dados ou de domínios no estado da interface da tela. Para fornecer uma experiência do usuário ideal ao considerar as mudanças de configuração do ciclo de vida e do app Android, os detentores de estado que usam a lógica de negócios precisam ter estas propriedades:

Propriedade Detalhes
Produz o estado da interface Os detentores de estado da lógica de negócios são responsáveis por produzir o estado das próprias IUs. Geralmente, esse estado da interface é o resultado do processamento de eventos do usuário e da leitura de dados das camadas de domínio e de dados.
Mantido após a recriação da atividade Os detentores de estado da lógica de negócios mantêm os estados e pipelines de processamento após todas as recriações de Activity, ajudando a fornecer uma experiência do usuário perfeita. Nos casos em que o detentor de estado não pode ser retido e é recriado (geralmente após o encerramento do processo), ele precisa poder recriar facilmente o último estado para garantir uma experiência do usuário consistente.
Possui um estado de longa duração Muitas vezes, os detentores de estado da lógica de negócios são usados para gerenciar o estado dos destinos de navegação. Como resultado, eles costumam preservar o estado em todas as mudanças de navegação até serem removidos do gráfico.
É exclusivo da interface e não pode ser reutilizado Os detentores de estado da lógica de negócios geralmente produzem o estado de uma determinada função do app, por exemplo, um TaskEditViewModel ou TaskListViewModel. Portanto, eles são aplicáveis apenas à função correspondente. O mesmo detentor de estado pode oferecer suporte a essas funções do app em diferentes formatos. Por exemplo, as versões do app para dispositivos móveis, TVs e tablets podem reutilizar o mesmo detentor de estado da lógica de negócios.

Por exemplo, considere o destino de navegação do autor no app Now in Android (link em inglês):

O app Now in Android demonstra como um destino de navegação que representa uma função importante do app precisa ter
o próprio detentor de estado da lógica de negócios exclusivo.
Figura 4: o app Now in Android.

Ao atuar como detentor de estado da lógica de negócios, o AuthorViewModel (link em inglês) produz o estado da interface neste caso:

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = …

    // Business logic
    fun followAuthor(followed: Boolean) {
      …
    }
}

AuthorViewModel tem os atributos descritos anteriormente:

Propriedade Detalhes
Produz AuthorScreenUiState O AuthorViewModel lê dados do AuthorsRepository e do NewsRepository e os usa para produzir o AuthorScreenUiState. Ele também aplica uma lógica de negócios quando o usuário quer seguir ou deixar de seguir um Author delegando ao AuthorsRepository.
Tem acesso à camada de dados Uma instância de AuthorsRepository e NewsRepository é transmitida a ele no construtor, permitindo que ele implemente a lógica de negócios de seguir um Author.
Sobrevive à recriação da Activity Por ser implementado com um ViewModel, ele vai ser mantido após a recriação rápida da Activity. No caso de encerramento do processo, o objeto SavedStateHandle pode ser lido para fornecer a quantidade mínima de informações necessárias para restaurar o estado da interface da camada de dados.
Possui um estado de longa duração O ViewModel tem o escopo definido para o gráfico de navegação. Portanto, a menos que o destino do autor seja removido do gráfico, o estado da interface no uiState StateFlow permanece na memória. O uso do StateFlow também adiciona o benefício de fazer a aplicação da lógica de negócios que produz o estado lento, já que o estado só é produzido quando há um coletor do estado da interface.
É exclusivo da interface O AuthorViewModel só é aplicável ao destino de navegação do autor e não pode ser reutilizado em outro lugar. Se alguma lógica de negócios for reutilizada em destinos de navegação, ela vai precisar ser encapsulada em um componente com escopo da camada de dados ou de domínio.

O ViewModel como um detentor de estado da lógica de negócios

Os benefícios dos ViewModels no desenvolvimento Android os tornam adequados para fornecer acesso à lógica de negócios e preparar os dados do aplicativo para serem apresentados na tela. Esses benefícios incluem:

  • Operações acionadas por ViewModels sobrevivem a mudanças de configuração.
  • Integração com o Navigation:
    • A navegação armazena em cache os ViewModels enquanto a tela está na backstack. Isso é importante para disponibilizar os dados já carregados instantaneamente ao retornar ao destino. É mais difícil fazer isso com um detentor de estado que segue o ciclo de vida da tela de composição.
    • O ViewModel também é apagado quando o destino é retirado da backstack, garantindo que o estado seja limpo automaticamente. Isso é diferente da detecção do descarte de elementos de composição, que pode ocorrer por vários motivos, por exemplo, a abertura de uma nova tela devido a uma mudança de configuração, entre outros.
  • Integração com outras bibliotecas do Jetpack, como a Hilt.

O detentor de estado da lógica da interface

A lógica da interface é uma lógica que opera nos dados fornecidos pela própria interface. Ela pode ficar no estado de elementos da interface ou em fontes de dados da interface, como a API de permissões ou Resources. Os detentores de estado que utilizam a lógica da interface normalmente têm as propriedades abaixo:

  • Produz o estado da interface e gerencia o estado dos elementos da interface.
  • Não sobrevivem à recriação da Activity: os detentores de estado hospedados na lógica da interface geralmente dependem de fontes de dados da própria interface, e uma tentativa de manter essas informações entre as mudanças de configuração geralmente causa vazamento de memória. Se os detentores de estado precisarem de dados para serem mantidos após as mudanças de configuração, eles vão precisar delegar a outro componente mais adequado para sobreviverem à recriação da Activity. No Jetpack Compose, por exemplo, os estados de elementos de composição da interface criados com funções remembered geralmente delegam para rememberSaveable a fim de preservar o estado em uma recriação da Activity. Exemplos dessas funções incluem rememberScaffoldState() e rememberLazyListState().
  • Tem referências a fontes de dados com escopo na interface: as fontes de dados, como APIs e recursos do ciclo de vida, podem ser referenciadas e lidas com segurança, já que o detentor de estado da lógica da interface tem o mesmo ciclo de vida que ela.
  • É reutilizável em várias interfaces: instâncias diferentes do mesmo detentor de estado da lógica da interface podem ser reutilizadas em diferentes partes do app. Por exemplo, um detentor de estado para gerenciar eventos de entrada do usuário em um grupo de ícones pode ser usado para ícones de filtro em uma página de pesquisa e também no campo "para" de destinatários de um e-mail.

O detentor de estado da lógica da interface normalmente é implementado com uma classe simples. Isso ocorre porque a própria interface é responsável pela criação do detentor de estado da lógica da interface, e ele tem o mesmo ciclo de vida que ela. No Jetpack Compose, por exemplo, o detentor de estado faz parte da composição e segue o ciclo de vida dela.

Você pode conferir esse comportamento no app de exemplo Now in Android (link em inglês):

O app Now in Android usa um detentor de estado de classe simples para gerenciar a lógica da interface
Figura 5: o app de exemplo Now in Android

O app de exemplo Now in Android mostra uma barra de apps na parte de baixo ou uma coluna de navegação, dependendo do tamanho da tela do dispositivo. Telas menores usam a barra de apps na parte de baixo e telas maiores usam a coluna de navegação.

Como a lógica para decidir o elemento da interface de navegação adequado usado na função de composição NiaApp não depende da lógica de negócios, ela pode ser gerenciada por um detentor de estado de classe simples conhecido como NiaAppState (link em inglês):

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

No exemplo acima, estes detalhes sobre NiaAppState são importantes:

  • Não sobrevive à recriação da Activity: o NiaAppState é remembered (lembrado) na composição porque a criação dele é feita com uma função de composição rememberNiaAppState (link em inglês) de acordo com as convenções de nomenclatura do Compose. Depois que a Activity é recriada, a instância anterior é perdida, e uma nova instância é criada com todas as dependências transmitidas, de maneira adequada à nova configuração da Activity recriada. Essas dependências podem ser novas ou restauradas da configuração anterior. Por exemplo, rememberNavController() é usado no construtor NiaAppState e delega para rememberSaveable com o objetivo de preservar o estado em toda a recriação da Activity.
  • Tem referências a fontes de dados com escopo na interface: as referências ao navigationController, Resources e outros tipos de escopo de ciclo de vida parecidos podem ser armazenadas com segurança no NiaAppState porque elas compartilham o mesmo escopo de ciclo de vida.

Escolher entre um ViewModel e uma classe simples para um detentor de estado

Nas seções acima, escolher entre um ViewModel e um detentor de estado de classe simples se resume à lógica aplicada ao estado da interface e às fontes de dados em que a lógica opera.

Em resumo, o diagrama abaixo mostra a posição dos detentores de estado no pipeline de produção do estado da interface:

Fluxos de dados da camada de produção de dados para a camada da interface
Figure 6: State holders in the UI State production pipeline. As setas indicam o fluxo de dados.

Por fim, você precisa produzir o estado da interface usando os detentores de estado mais próximos de onde ele é consumido. Ou seja, mantenha o estado o mais baixo possível enquanto mantém a propriedade adequada. Caso você precise acesso à lógica de negócios e queira que o estado da interface sobreviva enquanto a tela puder ser acessada, mesmo após a recriação da Activity, um ViewModel é uma ótima opção para implementar um detentor de estado da lógica de negócios. Para a lógica da interface e estados de curta duração, uma classe simples com um ciclo de vida dependente apenas da interface é suficiente.

Detentores de estado são agrupáveis

Detentores de estado podem depender uns dos outros, desde que as dependências tenham ciclos de vida iguais ou mais curtos. Confira alguns exemplos:

  • Um detentor de estado de lógica da interface pode depender de outro detentor de estado de lógica da interface.
  • Um detentor de estado de tela pode depender de um detentor de estado de lógica da interface.

O snippet de código a seguir mostra como o DrawerState do Compose depende de outro detentor de estado interno, o SwipeableState, e como o detentor de estado de lógica da interface de um app pode depender do DrawerState.

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

Um exemplo de dependência que excede a duração de um detentor de estado é um detentor de estado de lógica da interface que dependa de um de tela. Esse comportamento diminuiria a possibilidade de reutilização do detentor de estado de curta duração e daria acesso a mais lógica e estado do que o necessário.

Se o detentor de estado de curta duração precisar de alguma informação de outro com escopo mais alto, transmita apenas as informações necessárias como parâmetro, em vez de transmitir a instância dele. Por exemplo, no snippet de código a seguir, a classe detentora do estado da lógica da interface recebe apenas o necessário como parâmetro do ViewModel, porque a instância dele não é transmitida como uma dependência.

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

O diagrama a seguir representa as dependências entre a interface e os diferentes detentores de estado do snippet de código anterior.

interface dependente do detentor de estado de lógica da interface e de tela.
Figure 7: UI depending on different state holders. As setas indicam dependências.

Exemplos

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