Risolvi i problemi di stabilità

Quando ti trovi di fronte a una classe instabile che causa problemi di prestazioni, devi ottimizzare il modo in cui Compose la confronta o ne crea un'istanza. Questo documento illustra diverse tecniche che puoi utilizzare per farlo.

Attiva lo skipping avanzato

La modalità di strong skipping è attiva per impostazione predefinita a partire da Kotlin 2.0.20. La modalità di strong skipping consente di saltare gli elementi componibili con parametri instabili confrontando i parametri instabili utilizzando l'uguaglianza delle istanze (===), mentre i parametri stabili continuano a essere confrontati utilizzando l'uguaglianza strutturale (Object.equals()).

Se utilizzi una versione di Kotlin precedente alla 2.0.20, attiva prima la modalità di skipping avanzato. Se la modalità di strong skipping è già attiva e un composable continua a essere ricomposto inutilmente, controlla se vengono allocate nuove istanze di parametri instabili a ogni passaggio di ricomposizione.

Per saperne di più, consulta Strong skipping.

Rendi la classe immutabile

Puoi anche rendere una classe instabile completamente immutabile in modo che il compilatore possa dedurre automaticamente la sua stabilità.

  • Immutabile: indica un tipo in cui il valore di qualsiasi proprietà non può mai cambiare dopo la creazione di un'istanza di quel tipo e tutti i metodi sono referenzialmente trasparenti.
    • Assicurati che tutte le proprietà della classe siano val anziché var e di tipi immutabili.
    • I tipi primitivi come String, Int e Float sono sempre immutabili.
    • I tipi data class semplici con proprietà primitive val o String nei moduli creati con il compilatore Compose vengono già dedotti come stabili e confrontati automaticamente con .equals(). Non è necessario annotarli con @Immutable.
    • Se l'immutabilità è impossibile, devi utilizzare lo stato di Compose per tutte le proprietà mutabili.
  • Stable: indica un tipo modificabile. Il runtime di Compose non viene a sapere se e quando una delle proprietà pubbliche o un comportamento dei metodi del tipo produrrebbe risultati diversi da una chiamata precedente.

Raccolte e stabilità

Un motivo comune per cui Compose considera una classe instabile sono le raccolte. Come indicato nella pagina Diagnostica i problemi di stabilità, il compilatore Compose non può essere completamente sicuro che le interfacce di raccolta come List, Map e Set siano veramente immutabili e pertanto le contrassegna come instabili.

Considera questa classe della guida Diagnostica i problemi di stabilità:

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

Con la modalità di salto avanzata attivata (l'impostazione predefinita in Kotlin 2.0.20 e versioni successive), le raccolte instabili e le classi contenenti raccolte saltano già la ricomposizione utilizzando l'uguaglianza delle istanze (===) ogni volta che viene passato lo stesso riferimento.

Fai attenzione prima di sostituire kotlin.collections.List, Set o Map con Kotlinx Immutable Collections (ImmutableList, ImmutableSet, ImmutableMap) o di aggiungere regole @Immutable, @Stable o stability_config.conf ai modelli che contengono raccolte. In modalità di skipping avanzato, List standard salta utilizzando l'uguaglianza di riferimento (===), mentre ImmutableList e i modelli di raccolta annotati eseguono confronti .equals() elemento per elemento in ogni passaggio di ricomposizione.

Per raccolte di grandi dimensioni o layout lazy (LazyColumn, LazyRow, LazyGrid), il controllo di .equals() su ogni elemento può richiedere più tempo della ricomposizione. Questo costo raddoppia se passi una proprietà ImmutableList da un composable data class a un composable secondario:

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)
}

Anziché forzare l'uguaglianza strutturale sulle raccolte, conserva il riferimento all'istanza della raccolta tra le emissioni (ad esempio, memorizzando nella cache le trasformazioni nel tuo ViewModel o con remember) in modo che Compose possa saltare l'utilizzo dell'uguaglianza delle istanze (===). Prendi in considerazione ImmutableList o le annotazioni di stabilità sui modelli che contengono raccolte solo quando non puoi conservare il riferimento all'istanza della raccolta e i benchmark confermano che i controlli di uguaglianza strutturale sono più veloci della ricomposizione. Se utilizzi una versione di Kotlin precedente alla 2.0.20, attiva prima la modalità di skipping avanzata.

Aggiungi annotazioni con Stable o Immutable

Un possibile percorso per risolvere i problemi di stabilità per le classi di piccole dimensioni con controlli .equals() economici è annotare le classi instabili con @Stable o @Immutable.

L'annotazione di una classe esegue l'override di ciò che il compilatore dedurrebbe altrimenti sulla classe. È simile all'operatore !! in Kotlin. Devi prestare molta attenzione a come utilizzi queste annotazioni. L'override del comportamento del compilatore potrebbe causare bug imprevisti, ad esempio il composable potrebbe non ricomporsi quando previsto.

Con la modalità di salto aggressiva attiva, segui queste linee guida per decidere se aggiungere @Stable o @Immutable:

  • Non annotare le classi che sono già dedotte come stabili: le classi con proprietà val di tipi stabili nei moduli creati con il compilatore Compose sono già dedotte come stabili.
  • Annota le classi con controlli .equals() economici solo quando le istanze vengono ricreate: aggiungi @Stable o @Immutable quando il compilatore non può dedurre la stabilità (ad esempio, interfacce o classi di moduli non Compose) e un'origine dati alloca nuove istanze di oggetti per elementi invariati a ogni emissione, a condizione che la classe sia piccola e abbia un controllo .equals() economico.
  • Fai attenzione ai modelli contenenti raccolte: evita di aggiungere @Stable o @Immutable ai modelli di dati che contengono raccolte di grandi dimensioni (List, Set, Map), in particolare per i modelli utilizzati nei layout lazy (LazyColumn, LazyRow, LazyGrid, Pager). Se lasci questi modelli senza annotazioni, Compose può confrontarli utilizzando l'uguaglianza delle istanze (===) anziché eseguire controlli strutturali .equals() su ogni elemento.

Lo snippet seguente fornisce un esempio minimo di una classe di dati flat (senza proprietà di raccolta) annotata come immutabile se definita in un modulo in cui il compilatore non può dedurre la stabilità:

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

Indipendentemente dal fatto che utilizzi l'annotazione @Immutable o @Stable, il compilatore Compose contrassegna la classe SnackSummary come stabile e la confronta utilizzando Object.equals().

Classi annotate nelle raccolte

Prendi in considerazione un componibile che include un parametro di 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
)

Anche se Snack è stabile o annotato con @Immutable, il compilatore Compose contrassegna comunque il parametro snacks in HighlightedSnacks come unstable perché List è un'interfaccia.

Con la modalità di skipping avanzata attivata, HighlightedSnacks è ancora contrassegnato come skippable e salta la ricomposizione utilizzando l'uguaglianza delle istanze (===) quando viene passata la stessa istanza List. Puoi rendere il parametro snacks stable inserendo List<Snack> in una classe wrapper @Immutable, sostituendo List<Snack> con ImmutableList<Snack> o aggiungendo kotlin.collections.* al tuo file di configurazione della stabilità.

Tuttavia, in questo modo Compose esegue un confronto elemento per elemento .equals() in ogni passaggio di ricomposizione. Utilizza questi approcci solo quando non puoi conservare il riferimento all'istanza dell'elenco e i benchmark mostrano un vantaggio.

File di configurazione della stabilità

A partire dal compilatore Compose 1.5.5, è possibile fornire un file di configurazione delle classi da considerare stabili in tempo di compilazione. In questo modo, le classi che non controlli, come quelle della libreria standard come LocalDateTime, vengono considerate stabili.

Il file di configurazione è un file di testo normale con una classe per riga. Sono supportati i commenti e i caratteri jolly singoli e doppi.

Esempio di configurazione:

// 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<*,_>

Per attivare questa funzionalità, passa il percorso del file di configurazione al blocco di opzioni composeCompiler della configurazione del plug-in Gradle del compilatore Compose.

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

Poiché il compilatore Compose viene eseguito separatamente su ogni modulo del progetto, puoi fornire configurazioni diverse a moduli diversi, se necessario. In alternativa, puoi avere una configurazione a livello di root del progetto e passare il percorso a ogni modulo.

Più moduli

Un altro problema comune riguarda l'architettura multimodulo. Il compilatore Compose può dedurre se una classe è stabile solo se tutti i tipi non primitivi a cui fa riferimento sono contrassegnati esplicitamente come stabili o si trovano in un modulo creato anche con il compilatore Compose.

Se il livello dati si trova in un modulo separato rispetto al livello UI, che è l'approccio consigliato, questo potrebbe essere un problema che riscontri. In modalità di skipping forte, le classi dei moduli non Compose vengono trattate come instabili e continuano a saltare la ricomposizione utilizzando l'uguaglianza delle istanze (===) quando viene passata la stessa istanza. Tuttavia, se il tuo livello dati alloca spesso nuove istanze di modelli piccoli e piatti con valori identici a ogni emissione, puoi attivare l'uguaglianza strutturale (Object.equals()).

Soluzione

Per risolvere il problema, puoi adottare uno dei seguenti approcci:

  1. Aggiungi classi piccole e piatte con controlli .equals() economici al file di configurazione del compilatore.
  2. Attiva il compilatore Compose sui moduli del livello dati o aggiungi i tag @Stable o @Immutable alle classi, a seconda dei casi.
    • Ciò comporta l'aggiunta di una dipendenza di Compose al livello dati. Tuttavia, è solo la dipendenza per il runtime di Compose e non per Compose-UI.
  3. All'interno del modulo UI, inserisci le classi del livello dati in classi wrapper specifiche per l'UI.

Lo stesso problema si verifica anche quando si utilizzano librerie esterne che non utilizzano il compilatore Compose.

Non tutti i componibili devono essere ignorabili

Quando cerchi di risolvere i problemi di stabilità, non dovresti tentare di rendere ogni componibile ignorabile o di forzare l'uguaglianza strutturale su ogni parametro. La possibilità di saltare un componibile non è priva di costi: ogni parametro di un componibile ignorabile viene archiviato dal runtime di Compose come se fosse memorizzato e Compose deve confrontare ogni parametro con il suo valore precedente ogni volta che la funzione viene ricomposta per determinare se deve essere ignorata. Tentare di forzare l'omissione ovunque può portare a un'ottimizzazione prematura che introduce più overhead di quanto ne risparmi.

Esistono molte situazioni in cui forzare la stabilità strutturale non offre alcun vantaggio reale e può peggiorare le prestazioni o la manutenibilità. Ad esempio:

  • Un composable che non viene ricomposto spesso o non viene ricomposto affatto.
  • Un composable che di per sé chiama solo composable ignorabili.
  • Un composable con un numero elevato di parametri o modelli di raccolta con implementazioni .equals() costose. In questo caso, il costo del controllo per verificare se un parametro è cambiato può superare il costo di una ricomposizione economica.

Non fare troppo affidamento solo sui conteggi delle ricomposizioni. Concentrati sulla creazione di composabili leggeri da eseguire, rimandando le letture dello stato in rapida evoluzione alle fasi di layout o disegno e verificando le modifiche con i benchmark. Quando un composable è ignorabile, aggiunge un piccolo overhead che potrebbe non valere la pena. Puoi anche annotare il composable come non riavviabile o non ignorabile nei casi in cui ritieni che la riavviabilità o l'ignorabilità comportino un overhead eccessivo.