Onde elevar o estado

Em um app do Compose, a elevação do estado da interface depende da exigência da lógica da interface ou da de negócios. Este documento descreve esses dois cenários principais.

Prática recomendada

Você precisa elevar o estado da interface para o menor ancestral comum entre todos os elementos combináveis que fazem leitura e gravação. Mantenha o estado mais próximo de onde ele é consumido. No proprietário do estado, exponha aos consumidores o estado imutável e os eventos para modificar o estado.

O menor ancestral comum também pode estar fora da composição. Por exemplo, ao elevar o estado em um ViewModel porque há uma lógica de negócios envolvida.

Esta página explica em detalhes essa prática recomendada e as ressalvas a serem consideradas.

Tipos de estado e lógica da interface

Confira abaixo as definições dos tipos de estado e lógica de interface usados ao longo deste documento.

Estado da interface

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

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.

Lógica da interface

Quando a lógica da interface precisa ler ou gravar o estado, é necessário definir o escopo do estado da interface, seguindo o ciclo de vida dela. Para isso, você precisa elevar o estado no nível correto em uma função combinável. Como alternativa, é possível fazer isso em uma classe simples de detentor de estado, também definida como escopo do ciclo de vida da interface.

Confira abaixo uma descrição das duas soluções e uma explicação sobre quando usar cada uma.

Elementos combináveis como proprietários do estado

Ter uma lógica da interface e um estado de elemento da interface entre os elementos combináveis é uma boa abordagem se ambos forem simples. É possível deixar o estado interno para um elemento combinável ou elevar conforme necessário.

Não é necessária elevação de estado

A elevação de estado nem sempre é obrigatória. O estado pode ser mantido em um elemento combinável quando nenhum outro elemento precisar controlá-lo. Neste snippet, há um elemento combinável que se expande e é recolhido ao ser tocado:

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

A variável showDetails é o estado interno desse elemento da interface. Ela só é lida e modificada nesse combinável, e a lógica aplicada a ela é muito simples. Portanto, elevar o estado nesse caso não traria muitos benefícios, então ele poderia ser deixado interno. Isso faz com que esse combinável seja o proprietário e a única fonte de verdade do estado expandido.

Elevação em elementos combináveis

Se você precisar compartilhar o estado do elemento da interface com outros elementos combináveis e aplicar a lógica dela a diferentes locais, eleve o nível na hierarquia da interface. Isso também torna os combináveis mais reutilizáveis e fáceis de testar.

O exemplo abaixo é um app de chat que implementa duas funcionalidades:

  • O botão JumpToBottom rola a lista de mensagens para a parte de baixo. O botão executa a lógica da interface no estado da lista.
  • A lista MessagesList rola para a parte de baixo depois que o usuário envia novas mensagens. O elemento UserInput executa a lógica da interface no estado da lista.
App de chat com o botão JumpToBottom e a rolagem para baixo quando há novas mensagens
Figura 1. App de chat com o botão JumpToBottom e rolagem para baixo quando há novas mensagens.

A hierarquia de composição é esta:

Árvore de elementos combináveis do chat
Figura 2. Árvore de elementos combináveis do chat.

O estado LazyColumn é elevado para a tela de conversa para que o app possa realizar a lógica da interface e ler o estado de todos os elementos combináveis que precisam dele:

Elevação do estado de LazyColumn para a ConversationScreen
Figura 3. Elevação do estado LazyColumn de LazyColumn para ConversationScreen.

Por fim, os elementos combináveis são:

Árvore de elementos combináveis do chat com LazyListState elevado para ConversationScreen
Figura 4. Árvore de elementos combináveis do chat com LazyListState elevado para ConversationScreen

O código é este:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

O LazyListState é elevado o mais alto possível para a lógica da interface que precisa ser aplicada. Como é inicializado em uma função combinável, ele é armazenado na composição, seguindo o ciclo de vida.

Observe que o lazyListState é definido no método MessagesList, com o valor padrão de rememberLazyListState(). Esse é um padrão comum no Compose. Isso torna os elementos combináveis mais reutilizáveis e flexíveis. Em seguida, use o elemento combinável em diferentes partes do app que talvez não precisem controlar o estado. Geralmente, esse é o caso ao testar ou visualizar um elemento combinável. É exatamente assim que LazyColumn define o próprio estado.

O menor ancestral comum de LazyListState é ConversationScreen.
Figura 5. O menor ancestral comum de LazyListState é ConversationScreen.

Classe de detentor de estado simples como proprietária do estado

Quando uma função combinável contém uma lógica de interface complexa que envolve um ou vários campos de estado de um elemento da interface, ela precisa delegar essa responsabilidade aos detentores de estado, como uma classe simples. Isso faz com que a lógica da função combinável seja mais testável em isolamento e reduz a complexidade. Essa abordagem favorece o princípio de separação de conceitos (link em inglês): o elemento combinável é responsável por emitir elementos da interface, e o detentor do estado contém a lógica da interface e o estado de elemento da interface.

As classes simples do detentor de estado fornecem funções convenientes aos autores da chamada da função combinável, para que eles não precisem criar essa lógica por conta própria.

Essas classes simples são criadas e lembradas na composição. Como seguem o ciclo de vida do elemento combinável, elas podem usar tipos fornecidos pela biblioteca do Compose, como rememberNavController() ou rememberLazyListState().

Um exemplo disso é a classe detentora de estado simples do LazyListState, implementada no Compose para controlar a complexidade da interface de LazyColumn ou LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

O LazyListState encapsula o estado da LazyColumn que armazena a scrollPosition desse elemento de interface. Ele também expõe métodos para modificar a posição de rolagem, por exemplo, rolando até um determinado item.

Como você pode ver, incrementar as responsabilidades de um elemento combinável aumenta a necessidade de um detentor de estado. As responsabilidades podem estar na lógica da interface ou apenas na quantidade de estados para gerenciar.

Outro padrão comum é usar uma classe detentora de estado simples para lidar com a complexidade das funções combináveis raiz no app. É possível usar essa classe para encapsular o estado no nível do app, como o estado de navegação e o dimensionamento da tela. Uma descrição completa desse processo pode ser encontrada na lógica da interface e na página do detentor de estado.

Lógica de negócios

Se os elementos combináveis e as classes dos detentores de estado simples forem responsáveis pela lógica da interface e pelo estado do elemento da interface, um detentor de estado no nível da tela vai ser responsável por estas tarefas:

  • Fornecer acesso à lógica de negócios do aplicativo, que normalmente é colocada em outras camadas da hierarquia, como as camadas de negócios e de dados.
  • Preparar os dados do aplicativo para a apresentação em uma tela específica, que se torna o estado da interface na tela.

ViewModels como proprietários do estado

Os benefícios dos ViewModels AAC no desenvolvimento Android faz com que sejam adequados para fornecer acesso à lógica de negócios e preparar os dados do aplicativo para serem mostrados na tela.

Quando você eleva o estado da interface em ViewModel, ele é movido para fora da composição.

O estado elevado para o ViewModel é armazenado fora da composição
Figura 6. O estado elevado para o ViewModel é armazenado fora da composição.

Os ViewModels não são armazenados como parte da composição. Eles são fornecidos pelo framework e têm escopo definido como um ViewModelStoreOwner, que pode ser uma atividade, um fragmento, um gráfico de navegação ou um destino de um gráfico de navegação. Para mais informações sobre escopos do ViewModel, consulte a documentação.

O ViewModel é a fonte da verdade e o menor ancestral comum do estado da interface.

Estado da interface na tela

De acordo com as definições acima, o estado da interface da tela é produzido aplicando regras de negócios. Como o detentor de estado no nível da tela é responsável por isso, o estado da interface da tela normalmente é elevado no detentor do estado da tela, nesse caso, um ViewModel.

Considere o ConversationViewModel de um app de chat e como ele expõe o estado e os eventos da interface da tela para modificá-lo:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

Os elementos combináveis consomem o estado da interface da tela elevado no ViewModel. Você precisa injetar a instância de ViewModel nos elementos combináveis no nível da tela para fornecer acesso à lógica de negócios.

Confira abaixo um exemplo de ViewModel usado em um elemento combinável da tela. Aqui, o combinável ConversationScreen() consome o estado da interface da tela elevado no ViewModel:

@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)
    /* ... */
}

Detalhamento de propriedade

"Detalhamento de propriedade" se refere à transmissão de dados por vários componentes filhos aninhados para o local em que são lidos.

Um exemplo típico de onde o detalhamento da propriedade pode aparecer no Compose é ao injetar o detentor de estado no nível da tela no nível superior e transmitir o estado e os eventos para elementos combináveis filhos. Isso também pode gerar uma sobrecarga de assinaturas de funções combináveis.

Embora a exposição de eventos como parâmetros lambda individuais possa sobrecarregar a assinatura da função, ela maximiza a visibilidade das responsabilidades das funções combináveis. Você pode conferir facilmente o que elas fazem.

O detalhamento de propriedade é preferível em relação à criação de classes de wrapper para encapsular estado e eventos em um só lugar, porque isso reduz a visibilidade das responsabilidades de composição. Como você não tem classes de wrapper, também é mais provável que você transmita os elementos combináveis apenas aos parâmetros necessários, o que é uma prática recomendada.

A mesma prática recomendada vai ser aplicada se esses eventos forem de navegação. Saiba mais sobre isso na documentação de navegação.

Se você identificou um problema de desempenho, também pode optar por adiar a leitura do estado. Consulte os documentos de desempenho para saber mais.

Estado do elemento da interface

Você vai poder elevar o estado do elemento da interface para o detentor de estado no nível da tela se houver uma lógica de negócios que precise ler ou gravar esse elemento.

Continuando com o exemplo de um app de chat, ele mostra sugestões de usuário em um chat em grupo quando o usuário digita @ e uma dica. Essas sugestões são provenientes da camada de dados, e a lógica para calcular uma lista de sugestões de usuário é considerada a lógica de negócios. O recurso vai ser parecido com este:

Recurso que mostra sugestões de usuário em um chat em grupo quando o usuário digita &quot;@&quot; e uma dica
Figura 7. Recurso que mostra sugestões de usuário em um chat em grupo quando o usuário digita @ e uma dica.

O ViewModel que implementa esse recurso ficaria assim:

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessage é uma variável que armazena o estado TextField. Sempre que o usuário digita uma nova entrada, o app chama a lógica de negócios para produzir suggestions.

suggestions é o estado da interface da tela e é consumido pela interface do Compose pela coleta do StateFlow (link em inglês).

Ressalva

Para alguns estados do elemento da interface do Compose, a elevação para o ViewModel pode exigir considerações especiais. Por exemplo, alguns detentores de estado de elementos da interface do Compose expõem métodos para modificar o estado. Alguns deles podem ser funções de suspensão que acionam animações. Essas funções de suspensão podem gerar exceções quando você as chama de um CoroutineScope (link em inglês) sem escopo para a composição.

Digamos que o conteúdo da gaveta de apps seja dinâmico e que ele precise ser buscado e atualizado na camada de dados depois que for fechado. Você precisa elevar o estado da gaveta para ViewModel para chamar a interface e a lógica de negócios nesse elemento do proprietário do estado.

No entanto, chamar do DrawerState usando o viewModelScope da interface do Compose gera uma exceção de execução do tipo IllegalStateException (link em inglês) com a mensagem "um MonotonicFrameClock não está disponível neste CoroutineContext”" (link em inglês).

Para corrigir isso, use um CoroutineScope com escopo para a composição. Ele fornece um MonotonicFrameClock no CoroutineContext que é necessário para que as funções de suspensão funcionem.

Para corrigir essa falha, troque o CoroutineContext da corrotina no ViewModel para um que tenha escopo para a composição. Ele pode ser assim:

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

Saiba mais

Para saber mais sobre o estado e Jetpack Compose, consulte os recursos abaixo.

Exemplos

Codelabs

Vídeos