Corrigir problemas de estabilidade

Ao se deparar com uma classe instável que causa problemas de desempenho, otimize a forma como o Compose a compara ou instancia. Este documento descreve várias técnicas que podem ser usadas para fazer isso.

Ativar a rejeição avançada

O modo de rejeição avançada é ativado por padrão a partir do Kotlin 2.0.20. O modo de rejeição avançada permite que elementos combináveis com parâmetros instáveis sejam ignorados comparando parâmetros instáveis usando a igualdade de instância (===), enquanto os parâmetros estáveis continuam sendo comparados usando a igualdade estrutural (Object.equals()).

Se você estiver usando uma versão do Kotlin anterior à 2.0.20, ative o modo de pulo forte primeiro. Se o modo de pulo forte já estiver ativado e um elemento combinável ainda estiver sendo recomposto desnecessariamente, verifique se novas instâncias de parâmetros instáveis estão sendo alocadas em cada passagem de recomposição.

Consulte Strong skipping para mais informações.

Tornar a classe imutável

Também é possível tornar uma classe instável completamente imutável para que o compilador possa inferir a estabilidade dela automaticamente.

  • Imutável: indica um tipo em que o valor de qualquer propriedade nunca pode mudar depois que uma instância desse tipo é criada, e todos os métodos são referencialmente transparentes.
    • Verifique se todas as propriedades da classe são val em vez de var e de tipos imutáveis.
    • Tipos primitivos, como String, Int e Float, são sempre imutáveis.
    • Os tipos data class simples com propriedades primitivas val ou String em módulos criados com o compilador do Compose já são inferidos como estáveis e comparados com .equals() automaticamente. Não é necessário fazer anotações com @Immutable.
    • Se a imutabilidade for impossível, use o estado do Compose para todas as propriedades mutáveis.
  • Stable: indica um tipo mutável. O ambiente de execução do Compose não fica ciente se e quando as propriedades públicas ou o comportamento do método do tipo geram resultados diferentes de uma invocação anterior.

Coletas e estabilidade

Um motivo comum para o Compose considerar uma classe instável são as coleções. Como observado na página Diagnosticar problemas de estabilidade, o compilador do Compose não pode ter certeza de que as interfaces de coleção, como List, Map e Set, são realmente imutáveis e, portanto, as marca como instáveis.

Considere esta classe do guia Diagnosticar problemas de estabilidade:

unstable class Snack {
  …
  unstable val tags: Set<String>
  …
}

Com o modo de rejeição avançada ativado (o padrão no Kotlin 2.0.20 e versões mais recentes), coleções e classes instáveis que contêm coleções já ignoram a recomposição usando a igualdade de instâncias (===) sempre que a mesma referência é transmitida.

Tenha cuidado antes de substituir kotlin.collections.List, Set ou Map por coleções imutáveis do Kotlinx (ImmutableList, ImmutableSet, ImmutableMap) ou adicionar regras @Immutable, @Stable ou stability_config.conf a modelos que contêm coleções. No modo de pulo forte, o List padrão pula usando a igualdade de referência (===), enquanto o ImmutableList e os modelos de coleção anotados executam comparações .equals() elemento por elemento em cada passagem de recomposição.

Para coleções grandes ou layouts lentos (LazyColumn, LazyRow, LazyGrid), verificar .equals() em cada item pode levar mais tempo do que a recomposição. Esse custo dobra se você transmitir uma propriedade ImmutableList de um data class para um elemento combinável filho:

data class SnackCategory(
    val name: String,
    val snacks: ImmutableList<Snack>
)

@Composable
fun CategorySection(category: SnackCategory) {
    Text(category.name)
    // Compose compares category.snacks twice: once in SnackCategory.equals()
    // for CategorySection, and again for HighlightedSnacks.
    HighlightedSnacks(snacks = category.snacks)
}

Em vez de forçar a igualdade estrutural em coleções, preserve a referência de instância da coleção em todas as emissões (por exemplo, armazenando em cache as transformações no ViewModel ou com remember) para que o Compose possa pular o uso da igualdade de instância (===). Considere as anotações ImmutableList ou de estabilidade em modelos que contêm coleções somente quando não for possível preservar a referência de instância da coleção e os comparativos de mercado confirmarem que as verificações de igualdade estrutural são mais rápidas do que a recomposição. Se você estiver usando uma versão do Kotlin anterior à 2.0.20, ative primeiro o modo de pulo forte.

Adicione as anotações Stable ou Immutable.

Uma maneira de resolver problemas de estabilidade para classes pequenas com verificações .equals() baratas é anotar classes instáveis com @Stable ou @Immutable.

Anotar uma classe é substituir o que o compilador inferiria sobre ela. Ele é semelhante ao !! operador no Kotlin. Tenha muito cuidado ao usar essas anotações. Substituir o comportamento do compilador pode levar a bugs imprevistos, como o elemento combinável não ser recomposto quando esperado.

Com o modo de pulo forte ativado, siga estas diretrizes ao decidir se quer adicionar @Stable ou @Immutable:

  • Não anote classes que já foram inferidas como estáveis: classes com propriedades val de tipos estáveis em módulos criados com o compilador do Compose já são inferidas como estáveis.
  • Anote apenas classes com verificações .equals() baratas quando as instâncias forem recriadas: adicione @Stable ou @Immutable quando o compilador não puder inferir a estabilidade (por exemplo, interfaces ou classes de módulos que não são do Compose) e uma fonte de dados alocar novas instâncias de objeto para itens inalterados em cada emissão, desde que a classe seja pequena e tenha uma verificação .equals() barata.
  • Tenha cuidado com modelos que contêm coleções: evite adicionar @Stable ou @Immutable a modelos de dados que contêm coleções grandes (List, Set, Map), especialmente para modelos usados em layouts lentos (LazyColumn, LazyRow, LazyGrid, Pager). Deixar esses modelos sem anotações permite que o Compose os compare usando a igualdade de instâncias (===) em vez de executar verificações estruturais .equals() em cada elemento.

O snippet abaixo mostra um exemplo mínimo de uma classe de dados simples (sem propriedades de coleção) anotada como imutável quando definida em um módulo em que o compilador não pode inferir a estabilidade:

@Immutable
data class SnackSummary(
    val id: Long,
    val name: String,
    val price: Long
)

Se você usar a anotação @Immutable ou @Stable, o compilador do Compose vai marcar a classe SnackSummary como estável e compará-la usando Object.equals().

Classes anotadas em coleções

Considere um elemento combinável que inclui um parâmetro do tipo List<Snack>:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
)

Mesmo que Snack seja estável ou anotado com @Immutable, o compilador do Compose ainda vai marcar o parâmetro snacks em HighlightedSnacks como unstable porque List é uma interface.

Com o modo de rejeição avançada ativado, HighlightedSnacks ainda é marcado como skippable e ignora a recomposição usando a igualdade de instância (===) quando a mesma instância de List é transmitida. É possível tornar o parâmetro snacks stable envolvendo List<Snack> em uma classe wrapper @Immutable, substituindo List<Snack> por ImmutableList<Snack> ou adicionando kotlin.collections.* ao arquivo de configuração de estabilidade.

No entanto, isso faz com que o Compose execute uma comparação de .equals() elemento por elemento em cada passagem de recomposição. Use essas abordagens apenas quando não for possível preservar a referência da instância da lista e os comparativos de mercado mostrarem um benefício.

Arquivo de configuração de estabilidade

A partir do Compose Compiler 1.5.5, um arquivo de configuração de classes que serão consideradas estáveis pode ser fornecido no tempo de compilação. Isso permite considerar classes que você não controla, como classes de biblioteca padrão, como LocalDateTime, como estáveis.

O arquivo de configuração é um arquivo de texto simples com uma classe por linha. Comentários, caracteres curinga simples e duplos são aceitos.

Exemplo de configuração:

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer stable
com.datalayer.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

Para ativar esse recurso, transmita o caminho do arquivo de configuração para o bloco de opções composeCompiler da configuração do plug-in do Gradle do compilador do Compose.

composeCompiler {
  stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

Como o compilador do Compose é executado em cada módulo do projeto separadamente, é possível fornecer configurações diferentes para módulos diferentes, se necessário. Como alternativa, tenha uma configuração no nível raiz do projeto e transmita esse caminho para cada módulo.

Vários módulos

Outro problema comum envolve a arquitetura de vários módulos. O compilador do Compose só pode inferir se uma classe é estável se todos os tipos não primitivos que ela referencia estiverem explicitamente marcados como estáveis ou em um módulo que também tenha sido criado com o compilador do Compose.

Se a camada de dados estiver em um módulo separado da camada de interface, que é a abordagem recomendada, esse pode ser um problema que você vai encontrar. No modo de pulo forte, as classes de módulos que não são do Compose são tratadas como instáveis e ainda pulam a recomposição usando a igualdade de instância (===) quando a mesma instância é transmitida. No entanto, se a camada de dados aloca com frequência novas instâncias de modelos pequenos e simples com valores idênticos em cada emissão, é possível ativar a igualdade estrutural (Object.equals()).

Solução

Para resolver esse problema, siga um destes procedimentos:

  1. Adicione classes pequenas e simples com verificações .equals() de baixo custo ao seu arquivo de configuração do compilador.
  2. Ative o compilador do Compose nos módulos da camada de dados ou marque as classes com @Stable ou @Immutable, quando apropriado.
    • Isso envolve adicionar uma dependência do Compose à camada de dados. No entanto, ela é apenas a dependência do ambiente de execução do Compose, não do Compose-UI.
  3. No módulo de IU, envolva as classes da camada de dados em classes de wrapper específicas da IU.

O mesmo problema também ocorre ao usar bibliotecas externas que não usam o compilador do Compose.

Nem todo elemento combinável precisa ser pulável

Ao corrigir problemas de estabilidade, não tente tornar todos os combináveis puláveis nem forçar a igualdade estrutural em todos os parâmetros. Ignorar um elemento combinável não é livre de custos: cada parâmetro de um elemento combinável que pode ser ignorado é armazenado pelo ambiente de execução do Compose como se fosse lembrado, e o Compose precisa comparar cada parâmetro com o valor anterior sempre que a função é recomposta para determinar se ela precisa ser ignorada. Tentar forçar a omissão em todos os lugares pode levar a uma otimização prematura que gera mais sobrecarga do que economia.

Há muitas situações em que forçar a estabilidade estrutural não traz nenhum benefício real e pode prejudicar o desempenho ou a manutenção. Exemplo:

  • Um elemento que não é recomposto com frequência ou nunca é recomposto.
  • Um elemento combinável que chama apenas elementos combináveis que podem ser pulados.
  • Um elemento que pode ser composto com um grande número de parâmetros ou modelos de coleção com implementações de .equals() caras. Nesse caso, o custo de verificar se algum parâmetro mudou pode ser maior do que o custo de uma recomposição barata.

Não se baseie apenas nas contagens de recomposição. Foque em tornar os elementos combináveis leves para execução, adiando as leituras de estado que mudam rapidamente para as fases de layout ou desenho e verificando as mudanças com comparativos de mercado. Quando um elemento combinável é pulável, ele adiciona uma pequena sobrecarga que pode não valer a pena. Você pode até mesmo anotar o elemento combinável como não reiniciável ou não pulável nos casos em que determinar que ser reiniciável ou pulável é mais trabalhoso do que vale a pena.