Segui le best practice

Potresti riscontrare errori comuni di Compose. Questi errori potrebbero darti un codice che sembra funzionare abbastanza bene, ma che può compromettere le prestazioni della UI. Segui le best practice per ottimizzare la tua app su Compose.

Utilizza remember per ridurre al minimo i calcoli dispendiosi

Le funzioni componibili possono essere eseguite molto spesso, anche per ogni frame di un'animazione. Per questo motivo, devi eseguire il minor numero possibile di calcoli nel corpo del composable.

Una tecnica importante consiste nel memorizzare i risultati dei calcoli con remember. In questo modo, il calcolo viene eseguito una sola volta e puoi recuperare i risultati ogni volta che ne hai bisogno.

Ad esempio, ecco un codice che mostra un elenco ordinato di nomi, ma esegue l'ordinamento in un modo molto oneroso:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

Ogni volta che ContactsList viene ricomposto, l'intero elenco contatti viene ordinato di nuovo, anche se non è cambiato. Se l'utente scorre l'elenco, il composable viene ricomposto ogni volta che viene visualizzata una nuova riga.

Per risolvere questo problema, ordina l'elenco al di fuori di LazyColumn e memorizza l'elenco ordinato con remember:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

Ora l'elenco viene ordinato una volta, quando ContactList viene composto per la prima volta. Se i contatti o l'elemento di confronto cambiano, l'elenco ordinato viene rigenerato. In caso contrario, il composable può continuare a utilizzare l'elenco ordinato memorizzato nella cache.

Passa sempre come chiavi tutti gli input a cui viene fatto riferimento all'interno del blocco di calcolo remember, in modo che il valore memorizzato nella cache venga aggiornato quando questi input cambiano. Poiché remember memorizza le chiavi e il risultato memorizzato nella cache nella tabella degli slot e confronta le chiavi utilizzando Object.equals() in ogni passaggio di ricomposizione, utilizza remember solo quando memorizzi nella cache calcoli onerosi o allocazioni di oggetti pesanti (come formattatori di date, shader o ordinamento di elenchi) o quando l'identità dell'oggetto deve essere preservata.

Fai attenzione quando passi una raccolta di grandi dimensioni o un oggetto con un'implementazione equals() costosa come chiave a remember per un calcolo economico, poiché il confronto della chiave può costare più dell'esecuzione diretta del calcolo:

@Composable
fun ContactHeader(contacts: List<Contact>) {
    // Avoid: Comparing the `contacts` list with .equals() on recomposition
    // is more expensive than reading contacts.size or contacts.firstOrNull() directly.
    val firstContactName = remember(contacts) {
        contacts.firstOrNull()?.name.orEmpty()
    }
    Text(firstContactName)
}

Utilizza chiavi di layout lazy e tipi di contenuti

I layout lazy riutilizzano in modo efficiente gli elementi, rigenerandoli o ricomponendoli solo quando è necessario. Tuttavia, puoi contribuire a ottimizzare i layout lazy per la ricomposizione e il riutilizzo degli slot.

Supponiamo che un'operazione dell'utente causi lo spostamento di un elemento nell'elenco. Ad esempio, supponiamo che tu mostri un elenco di note ordinate in base all'ora di modifica, con la nota modificata più di recente in alto.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

Tuttavia, questo codice presenta un problema. Supponiamo che la nota in basso venga modificata. Ora è la nota modificata più di recente, quindi viene visualizzata in cima all'elenco e tutte le altre note scendono di una posizione.

Senza il tuo aiuto, Compose non si rende conto che gli elementi invariati vengono semplicemente spostati nell'elenco. Invece, Compose pensa che il vecchio "elemento 2" sia stato eliminato e che ne sia stato creato uno nuovo per l'elemento 3, l'elemento 4 e così via. Il risultato è che Compose ricompone ogni elemento dell'elenco, anche se solo uno è effettivamente cambiato.

La soluzione è fornire chiavi degli elementi. Fornendo una chiave stabile per ogni elemento, Compose evita ricomposizioni non necessarie. In questo caso, Compose può determinare che l'elemento ora nella posizione 3 è lo stesso che si trovava nella posizione 2. Poiché nessuno dei dati di quell'elemento è cambiato, Compose non deve ricomporlo.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

Quando lavori con i layout lazy (LazyColumn, LazyRow, LazyGrid), tieni presente anche le seguenti linee guida:

  • Utilizza chiavi univoche salvabili in Bundle: le chiavi devono essere univoche, deterministiche e salvabili in un Bundle Android perché i layout lazy utilizzano rememberSaveable internamente. Preferisci tipi primitivi come Int, Long o String e non utilizzare mai indici di elenco o hashCode() come chiavi.
  • Specifica sempre contentType per gli elenchi eterogenei: passa contentType = { it.type } a items o itemsIndexed in modo che Compose riutilizzi gli slot di composizione solo tra gli elementi che condividono la stessa struttura visiva durante lo scorrimento.
  • Utilizza itemsIndexed anziché indexOf: evita di chiamare list.indexOf(item) all'interno di un blocco items, che trasforma il layout in una scansione O(N²); utilizza invece itemsIndexed.
  • Esegui l'hoisting dello stato condiviso e delle letture di CompositionLocal: raccogli le istanze Flow condivise e leggi i valori di CompositionLocal (come LocalContext.current o LocalDensity.current) una volta nel composable principale sopra il layout lazy e passa i valori risolti ai composable degli elementi.
  • Utilizza Row o Column per elenchi piccoli e limitati: per raccolte statiche o limitate di piccole dimensioni (ad esempio da 1 a 10 elementi), un Row o Column standard con forEach evita l'overhead di virtualizzazione lazy e precaricamento.

Utilizza computedStateOf o derivedStateOf per limitare le ricomposizioni

Un rischio dell'utilizzo dello stato nelle composizioni è che, se lo stato cambia rapidamente, la UI potrebbe essere ricomposta più del necessario. Ad esempio, supponiamo che tu stia visualizzando un elenco scorrevole. Esamina lo stato dell'elenco per vedere qual è il primo elemento visibile nell'elenco:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Il problema è che, se l'utente scorre l'elenco, listState cambia continuamente mentre l'utente trascina il dito. Ciò significa che l'elenco viene costantemente ricomposto. Tuttavia, non è necessario ricomporlo così spesso: non è necessario ricomporlo finché non diventa visibile un nuovo elemento in alto. Questo comporta un'enorme quantità di calcoli aggiuntivi, che influiscono negativamente sulle prestazioni della UI.

La soluzione è utilizzare lo stato derivato (o computedStateOf in Compose Runtime 1.13 e versioni successive). Lo stato derivato ti consente di indicare a Compose quali modifiche dello stato devono effettivamente attivare la ricomposizione. In questo caso, specifica che ti interessa sapere quando cambia il primo elemento visibile. Quando il valore di quello stato cambia, la UI deve essere ricomposta, ma se l'utente non ha ancora fatto scorrere abbastanza per portare un nuovo elemento in alto, non è necessario ricomporla.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Scegli l'approccio di derivazione dello stato appropriato in base alla versione di Compose e al costo di calcolo:

  • remember { computedStateOf { ... } } (Compose Runtime 1.13 e versioni successive): rivaluta il calcolo a ogni lettura senza memorizzare nella cache il risultato e invalida la composizione solo quando l'output calcolato cambia. Poiché evita il sovraccarico di contabilità della cache e di write-back di invalidamento, preferisci remember { computedStateOf { listState.firstVisibleItemIndex > 0 } } per i controlli delle condizioni di base e le derivazioni leggere basate su stati in rapida evoluzione (come gli offset di scorrimento).
  • remember { derivedStateOf { ... } }: memorizza nella cache il risultato del calcolo in più letture. Utilizza derivedStateOf nelle versioni di Compose precedenti alla 1.13 o quando il calcolo stesso è oneroso dal punto di vista computazionale e viene letto più volte durante una fase di composizione. Non passare i valori dello stato dell'istantanea letti all'interno di derivedStateOf (ad esempio listState.firstVisibleItemIndex) come chiavi remember, in quanto ciò ricrea DerivedState a ogni modifica dello stato e impedisce l'omissione.
  • Getter Kotlin semplici (get()) e letture dirette delle proprietà: preferisci un getter personalizzato semplice per espressioni booleane banali in cui non è necessaria l'attenuazione dell'annullamento della convalida e leggi direttamente la raccolta .size anziché aggregarla in derivedStateOf. Evita di utilizzare produceState con snapshotFlow per le derivazioni di stato sincrone, in quanto aggiunge un overhead non necessario per l'invio di coroutine.

Rimanda le letture il più a lungo possibile

Una volta identificato un problema di prestazioni, può essere utile posticipare le letture dello stato. Il rinvio delle letture dello stato garantisce che Compose esegua nuovamente il minor codice possibile durante la ricomposizione. Ad esempio, se la tua UI ha uno stato che viene sollevato in alto nell'albero componibile e leggi lo stato in un componibile secondario, puoi aggregare la lettura dello stato in una funzione lambda. In questo modo, la lettura avviene solo quando è effettivamente necessaria. Per riferimento, consulta l'implementazione nell'app di esempio Jetsnack. Jetsnack implementa un effetto simile a una barra degli strumenti comprimibile nella schermata dei dettagli. Per capire perché questa tecnica funziona, consulta il blog post Jetpack Compose: Debugging Recomposition.

Per ottenere questo effetto, l'elemento componibile Title deve conoscere l'offset di scorrimento per compensarsi utilizzando un Modifier. Ecco una versione semplificata del codice Jetsnack prima dell'ottimizzazione:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Quando lo stato di scorrimento cambia, Compose invalida l'ambito di ricomposizione principale più vicino. In questo caso, l'ambito più vicino è il composable SnackDetail. Tieni presente che Box è una funzione in linea, quindi non è un ambito di ricomposizione. Quindi Compose ricompone SnackDetail e tutti i componibili all'interno di SnackDetail. Se modifichi il codice in modo da leggere lo stato solo dove lo utilizzi effettivamente, puoi ridurre il numero di elementi che devono essere ricomposti.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Il parametro di scorrimento ora è una funzione lambda. Ciò significa che Title può ancora fare riferimento allo stato sottoposto a hoisting, ma il valore viene letto solo all'interno di Title, dove è effettivamente necessario. Di conseguenza, quando il valore di scorrimento cambia, l'ambito di ricomposizione più vicino è ora il componibile Title. Compose non deve più ricomporre l'intero Box.

È un buon miglioramento, ma puoi fare di meglio. Dovresti insospettirti se stai causando la ricomposizione solo per modificare il layout o ridisegnare un elemento componibile. In questo caso, stai solo modificando l'offset del composable Title, che potrebbe essere eseguito nella fase di layout.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
        // ...
    }
}

In precedenza, il codice utilizzava Modifier.offset(x: Dp, y: Dp), che accetta l'offset come parametro. Passando alla versione lambda del modificatore, puoi assicurarti che la funzione legga lo stato di scorrimento nella fase di layout. Di conseguenza, quando lo stato di scorrimento cambia, Compose può saltare completamente la fase di composizione e passare direttamente alla fase di layout. Quando passi variabili di stato che cambiano di frequente ai modificatori, devi utilizzare le versioni lambda dei modificatori, se possibile.

Ecco un altro esempio di questo approccio. Questo codice non è stato ancora ottimizzato:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

In questo caso, il colore di sfondo della casella cambia rapidamente tra due colori. Questo stato cambia quindi molto di frequente. Il composable legge quindi questo stato nel modificatore di sfondo. Di conseguenza, la casella deve essere ricomposta in ogni frame, poiché il colore cambia in ogni frame.

Per migliorare questo aspetto, utilizza un modificatore basato su lambda, in questo caso drawBehind. Ciò significa che lo stato del colore viene letto solo durante la fase di disegno. Di conseguenza, Compose può saltare completamente le fasi di composizione e layout: quando il colore cambia, Compose passa direttamente alla fase di disegno.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

Evita le scritture all'indietro

Compose si basa sul presupposto fondamentale che non scriverai mai in uno stato che è già stato letto. In questo caso, si parla di scrittura inversa e può causare la ricomposizione di ogni frame, all'infinito.

Il seguente composable mostra un esempio di questo tipo di errore.

@Composable
fun BadComposable() {
    var count by remember { mutableIntStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

Questo codice aggiorna il conteggio alla fine del composable dopo averlo letto nella riga precedente. Se esegui questo codice, vedrai che dopo aver fatto clic sul pulsante, che causa una ricomposizione, il contatore aumenta rapidamente in un loop infinito mentre Compose ricompone questo Composable, vede una lettura dello stato non aggiornata e quindi pianifica un'altra ricomposizione.

Puoi evitare del tutto le scritture a ritroso non scrivendo mai nello stato in Composition. Se possibile, scrivi sempre nello stato in risposta a un evento e in una funzione lambda come nell'esempio onClick precedente.

Per una guida approfondita sulle scritture in ordine inverso, sui loop di ricomposizione e sulla coordinazione delle fasi, consulta Scritture in ordine inverso in Compose.

Risorse aggiuntive