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
eset
da classeTextView
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 é oScaffoldState
do elemento combinávelScaffold
.
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.
A hierarquia de composição é esta:
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:
Por fim, os elementos combináveis são:
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
LazyColumn
define o próprio estado.
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.
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:
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
Recomendados para você
- Observação: o texto do link aparece quando o JavaScript está desativado.
- Salvar o estado da interface no Compose
- Listas e grades
- Como arquitetar a interface do Compose