Trabalhando com o Compose

O Jetpack Compose é um kit de ferramentas moderno e declarativo de IU para Android. Com o Compose, fica mais fácil criar e manter a IU do app, fornecendo uma API declarativa que permite renderizar a IU do app sem modificar de forma imperativa as visualizações do front-end. Essa terminologia precisa ser explicada, mas as implicações são importantes para o design do seu app.

O paradigma de programação declarativa

Historicamente, uma hierarquia de visualização do Android é representada como uma árvore de widgets de IU. À medida que o estado do app muda devido a fatores como interações do usuário, é necessário atualizar a hierarquia da IU para exibir os dados atuais. A maneira mais comum de atualizar a IU é percorrer a árvore usando funções como findViewById() e mudar os nós chamando métodos como button.setText(String), container.addChild(View) ou img.setImageBitmap(Bitmap). Esses métodos alteram o estado interno do widget.

Manipular as visualizações manualmente aumenta a probabilidade de erros. Caso algum dado seja renderizado em vários lugares, é fácil esquecer de atualizar uma das visualizações que o exibem. Também é fácil criar estados ilegais, quando duas atualizações entram em conflito de maneira inesperada. Por exemplo, uma atualização pode tentar definir o valor de um nó que acabou de ser removido da IU. Em geral, a complexidade da manutenção do software aumenta com o número de visualizações que precisam ser atualizadas.

Nos últimos anos, todo o setor começou a adotar um modelo de IU declarativo, o que simplifica bastante a engenharia associada à criação e à atualização de interfaces do usuário. A técnica consiste em gerar de forma conceitual toda a tela do zero novamente e aplicar apenas as mudanças necessárias. Essa abordagem evita a complexidade de atualizar manualmente uma hierarquia de visualizações com estado. O Compose é um framework de IU declarativo.

Um desafio de gerar a tela inteira novamente é que há um custo potencialmente alto em termos de tempo, energia computacional e uso da bateria. Para diminuir esse custo, o Compose escolhe quais partes da IU precisam ser redesenhadas em um momento específico. Isso tem algumas implicações na forma como você cria os componentes da sua IU, conforme discutido em Recomposição.

Uma função simples que pode ser composta

Com o Compose, é possível criar a interface do usuário definindo um conjunto de funções que podem ser compostas que recebem dados e emitem elementos da IU. Um exemplo simples é o widget Greeting, que recebe um String e emite um widget Text, que exibe uma mensagem de saudação.

Captura de tela de um smartphone mostrando o texto "Hello World" e o código da função simples que pode ser composta que gera essa IU

Figura 1. Função simples que pode ser composta e que é transmitida para renderizar um widget de texto na tela.

Alguns detalhes importantes sobre essa função:

  • A função é anotada com a anotação @Composable. Todas as funções que podem ser compostas precisam ter essa anotação. Ela informa ao compilador do Compose que a função é usada para converter dados em IU.

  • A função recebe dados. As funções que podem ser compostas aceitam parâmetros, o que permite que a lógica do app descreva a IU. Nesse caso, nosso widget aceita uma String para que ele possa cumprimentar o usuário pelo nome.

  • A função exibe texto na IU. Isso é feito chamando a função que pode ser composta Text(), que cria o elemento da IU de texto. Essas funções que podem ser compostas emitem a hierarquia da IU chamando outras funções desse tipo.

  • A função não retorna nada. As funções do Compose que emitem a IU não precisam retornar nada, porque descrevem o estado desejado da tela em vez de construir widgets de IU.

  • Essa função é rápida, idempotente e não tem efeitos colaterais.

    • A função se comporta da mesma maneira quando chamada várias vezes com o mesmo argumento e não usa outros valores, como variáveis globais ou chamadas para random().
    • A função descreve a IU sem efeitos colaterais, como modificação de propriedades ou variáveis globais.

    Em geral, todas as funções que podem ser compostas precisam ser escritas com essas propriedades, pelos motivos discutidos em Recomposição.

A mudança no paradigma declarativo

Em vários kits de ferramentas de IU imperativos orientados a objetos, a IU é inicializada instanciando uma árvore de widgets. Isso geralmente é feito inflando um arquivo de layout XML. Cada widget mantém o próprio estado interno e expõe os métodos getter e setter, que permitem que a lógica do app interaja com o widget.

Na abordagem declarativa do Compose, os widgets são relativamente sem estado e não expõem as funções setter ou getter. Na verdade, os widgets não são expostos como objetos. Para atualizar a IU, chame com argumentos diferentes a mesma função que pode ser composta. Isso facilita o fornecimento de estado para padrões de arquitetura, como um ViewModel, conforme descrito no Guia para a arquitetura do app. Dessa forma, as funções que podem ser compostas ficam responsáveis por transformar o estado atual do aplicativo em uma IU sempre que os dados observáveis são atualizados.

Ilustração do fluxo de dados em uma IU do Compose, de objetos de alto nível até os filhos deles.

Figura 2. A lógica do app fornece dados para a função de nível superior que pode ser composta. Essa função usa os dados para descrever a IU chamando outros elementos que podem ser compostos e transmitindo os dados apropriados para eles e para a hierarquia abaixo.

Quando o usuário interage com a IU, ela gera eventos como onClick. Esses eventos precisam notificar a lógica do app, que poderá alterar o estado dele. Quando o estado muda, as funções que podem ser compostas são chamadas novamente com os novos dados. Isso faz com que os elementos da IU sejam redesenhados. Esse processo é chamado de recomposição.

Ilustração de como os elementos da IU respondem à interação, acionando eventos que são processados pela lógica do app.

Figura 3. O usuário interagiu com um elemento da IU, acionando um evento. A lógica do app responde ao evento e, em seguida, as funções que podem ser compostas são chamadas automaticamente com novos parâmetros, se necessário.

Conteúdo dinâmico

Como as funções que podem ser compostas são criadas em Kotlin em vez de XML, elas podem ser tão dinâmicas quanto qualquer outro código Kotlin. Por exemplo, suponha que você queira criar uma IU que cumprimenta uma lista de usuários:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

Essa função recebe uma lista de nomes e gera uma saudação para cada usuário. As funções que podem ser compostas podem ser bastante sofisticadas. Use as instruções if para decidir se quer exibir determinado elemento da IU. Você pode usar repetições. É possível chamar funções auxiliares. Você tem a flexibilidade total da linguagem subjacente. Esse poder e essa flexibilidade são algumas das principais vantagens do Jetpack Compose.

Recomposição

Em um modelo de IU imperativo, para alterar um widget, você chama um setter no widget para alterar o estado interno dele. No Compose, você chama novamente a função que pode ser composta, com novos dados. Isso faz com que a função seja recomposta. Os widgets emitidos pela função são redesenhados, se necessário, com os novos dados. O framework do Compose pode recompor de maneira inteligente apenas os componentes alterados.

Por exemplo, considere esta função que pode ser composta que exibe um botão:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

Toda vez que o botão é clicado, o autor da chamada atualiza o valor de clicks. O Compose chama o lambda com a função Text novamente para mostrar o novo valor. Esse processo é chamado de recomposição. Outras funções que não dependem do valor não são recompostas.

Como discutido anteriormente, a recomposição de toda a árvore da IU pode ser cara em termos computacionais, já que consome energia de computação e diminui a duração da bateria. O Compose resolve esse problema com esta recomposição inteligente.

A recomposição é o processo de chamar novamente as funções que podem ser compostas quando as entradas são alteradas. Isso acontece quando as entradas da função mudam. Quando o Compose faz a recomposição com base nas novas entradas, ele chama apenas as funções ou lambdas que podem ter mudado e ignora o resto. Ao ignorar todas as funções ou lambdas que não tiveram parâmetros alterados, o Compose pode fazer a recomposição com eficiência.

Nunca dependa dos efeitos colaterais da execução de funções que podem ser compostas, já que a recomposição de uma função pode ser ignorada. Se você fizer isso, os usuários poderão ver comportamentos estranhos e imprevisíveis no app. Um efeito colateral é qualquer alteração visível para o resto do app. Por exemplo, todas estas ações são efeitos colaterais perigosos:

  • Gravar em uma propriedade de um objeto compartilhado
  • Atualizar um elemento observável em ViewModel.
  • Atualizar preferências compartilhadas.

As funções que podem ser compostas podem ser executadas novamente em todos os frames, como quando uma animação é renderizada. As funções que podem ser compostas precisam ser rápidas para evitar instabilidade nas animações. Se você precisar executar operações de alto custo, como ler preferências compartilhadas, faça isso em uma corrotina em segundo plano e transmita o resultado do valor como um parâmetro para a função que pode ser composta.

Como exemplo, este código cria uma função que pode ser composta para atualizar um valor em SharedPreferences. A função que pode ser composta não pode ler ou gravar a partir das preferências compartilhadas. Em vez disso, esse código move a leitura e a gravação para um ViewModel em uma corrotina em segundo plano. A lógica do app transmite o valor atual com um callback para acionar uma atualização.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

Há uma série de pontos a serem considerados ao programar no Compose:

  • As funções que podem ser compostas podem ser executadas em qualquer ordem.
  • As funções que podem ser compostas podem ser executadas em paralelo.
  • A recomposição ignora o maior número possível de funções e lambdas.
  • A recomposição é otimista e pode ser cancelada.
  • Uma função que pode ser composta pode ser executada em todos os frames de uma animação.

As seções a seguir abordarão como criar funções que podem ser compostas para oferecer compatibilidade com a recomposição. Em todos os casos, a prática recomendada é usar funções que podem ser compostas rápidas, idempotentes e sem efeitos colaterais.

As funções que podem ser compostas podem ser executadas em qualquer ordem

Se você vir uma função que pode ser composta no código, poderá supor que o código é executado na ordem em que ele aparece. No entanto, isso não é necessariamente verdade. Se uma função que pode ser composta contiver chamadas para outras funções desse tipo, elas poderão ser executadas em qualquer ordem. O Compose tem a opção de reconhecer que alguns elementos da IU têm prioridade mais alta que outros e desenhá-los primeiro.

Por exemplo, suponha que você tenha um código como este para desenhar três telas em um layout de guias:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

As chamadas para StartScreen, MiddleScreen e EndScreen podem acontecer em qualquer ordem. Isso significa que não é possível, por exemplo, deixar que StartScreen() defina uma variável global (um efeito colateral) e usar MiddleScreen() para aproveitar essa mudança. Em vez disso, cada uma dessas funções precisa ser autossuficiente.

As funções que podem ser compostas podem ser executadas em paralelo

O Compose pode otimizar a recomposição executando funções que podem ser compostas em paralelo. Isso permite que o Compose use vários núcleos e execute funções que podem ser compostas que não estão na tela com uma prioridade mais baixa.

Essa otimização significa que uma função que pode ser composta pode ser executada em um conjunto de linhas de execução em segundo plano. Se uma função que pode ser composta chamar uma função em um ViewModel, o Compose poderá chamar essa função de várias linhas de execução ao mesmo tempo.

Para garantir que seu aplicativo se comporte corretamente, todas as funções que podem ser compostas não podem ter efeitos colaterais. Em vez disso, acione efeitos colaterais com callbacks, como onClick, que são sempre executados na linha de execução de IU.

Quando uma função que pode ser composta é invocada, a invocação pode ocorrer em uma linha de execução diferente da linha do autor da chamada. Isso significa que o código que modifica variáveis em um lambda que pode ser composto precisa ser evitado, porque esse código não é seguro para linhas de execução e é um efeito colateral não permitido do lambda.

Veja um exemplo que mostra uma função que pode ser composta que exibe uma lista e a contagem dela:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

Esse código não tem efeitos colaterais e transforma a lista de entrada na IU. Esse é um código excelente para exibir uma lista pequena. No entanto, se a função gravar em uma variável local, esse código não será seguro para linhas de execução nem estará correto:

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

Nesse exemplo, items é modificado a cada recomposição. Isso pode ser em todos os frames de uma animação ou quando a lista é atualizada. De qualquer forma, a IU exibirá a contagem errada. Por isso, gravações como essa não são compatíveis com o Compose. Com a proibição dessas gravações, permitimos que o framework mude linhas de execução para executar lambdas que podem ser compostos.

A recomposição ignora o máximo possível

Quando partes da IU são inválidas, o Compose faz o melhor possível para recompor apenas aquelas que precisam ser atualizadas. Isso significa que ele pode fazer a re-execução de um único elemento que pode ser composto de um botão sem executar nenhum dos elementos desse tipo acima ou abaixo na árvore da IU.

Todas as funções e lambdas que podem ser compostos podem fazer a própria recomposição. Veja um exemplo que demonstra como a recomposição pode ignorar alguns elementos ao renderizar uma lista:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.h5)
        Divider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Cada um desses escopos pode ser o único elemento a ser executado durante uma recomposição. O Compose poderá pular para o lambda Column sem executar nenhum dos pais quando o header mudar. E, ao executar Column, o Compose poderá escolher ignorar LazyColumnItems se names não mudar.

Novamente, a execução de todas as funções ou lambdas que podem ser compostos não podem ter efeitos colaterais. Quando for necessário executar um efeito colateral, acione-o a partir de um callback.

A recomposição é otimista

A recomposição começa sempre que o Compose percebe que os parâmetros de um elemento que pode ser composto podem ter mudado. A recomposição é otimista, o que significa que o Compose espera finalizá-la antes que os parâmetros sejam alterados novamente. Se um parâmetro for alterado antes da recomposição terminar, o Compose poderá cancelar a recomposição e reiniciá-la com o novo parâmetro.

Quando a recomposição é cancelada, o Compose descarta a árvore da IU da recomposição. Se houver efeitos colaterais que dependam da IU exibida, o efeito colateral será aplicado mesmo que a composição seja cancelada. Isso pode causar um estado inconsistente do app.

Verifique se todas as funções e lambdas que podem ser compostos são idempotentes e se não têm efeitos colaterais para lidar com a reposição otimista.

As funções que podem ser compostas podem ser executadas com muita frequência

Em alguns casos, uma função que pode ser composta pode ser executada em todos os frames de uma animação de IU. Se a função executar operações caras, como ler o armazenamento do dispositivo, ela poderá causar instabilidade na IU.

Por exemplo, se o widget tentasse ler as configurações do dispositivo, ele poderia lê-las centenas de vezes por segundo, com efeitos desastrosos no desempenho do app.

Se a função que pode ser composta precisar de dados, ela definirá parâmetros para eles. Em seguida, é possível mover o trabalho que gera mais custos para outra linha de execução, fora da composição, e transmitir os dados para o Compose usando mutableStateOf ou LiveData.