Seguire le best practice

Potresti riscontrare degli errori comuni in Compose. Questi errori possono generare codice che sembra funzionare bene, ma compromettere le prestazioni dell'interfaccia utente. Segui le best practice per ottimizzare la tua app su Compose.

Usa remember per ridurre al minimo i calcoli costosi

Le funzioni componibili possono essere eseguite molto spesso, con la stessa frequenza di ogni frame di un'animazione. Per questo motivo, nel corpo del componibile dovresti eseguire il minor numero possibile di calcoli.

Una tecnica importante è memorizzare i risultati dei calcoli in remember. In questo modo il calcolo viene eseguito una sola volta e puoi recuperare i risultati ogni volta che sono necessari.

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

@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 dei contatti viene riordinato di nuovo, anche se non è cambiato. Se l'utente scorre l'elenco, il componibile viene ricomposto ogni volta che appare una nuova riga.

Per risolvere il 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 il comparatore cambiano, l'elenco ordinato viene rigenerato. In caso contrario, il componibile può continuare a utilizzare l'elenco ordinato nella cache.

Usare i tasti di layout lazy

I layout lenti riutilizzano in modo efficiente gli elementi, rigenerandoli o ricomponendoli solo quando necessario. Tuttavia, puoi ottimizzare i layout lenti per la ricomposizione.

Supponiamo che un'operazione dell'utente provochi lo spostamento di un elemento nell'elenco. Ad esempio, supponi di mostrare 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, si è verificato un problema con questo codice. Supponiamo che la nota in basso cambi. È ora l'ultima nota modificata, quindi viene posizionata in cima all'elenco, mentre tutte le altre note vengono spostate in basso di un punto.

Senza il tuo aiuto, Compose non si rende conto che gli elementi non modificati vengono solo spostati nell'elenco. Compose ritiene invece che il vecchio "elemento 2" sia stato eliminato e che ne sia stato creato uno nuovo per l'elemento 3, l'elemento 4 e fino in fondo. Il risultato è che Compose ricompone ogni elemento nell'elenco, anche se solo uno è stato effettivamente modificato.

In questo caso, la soluzione consiste nel fornire chiavi elemento. Fornendo una chiave stabile per ogni elemento consenti a Compose di evitare ricomposizioni non necessarie. In questo caso, Compose può determinare che l'elemento che ora si trova nel punto 3 è lo stesso che si trovava nel punto 2. Poiché nessuno dei dati per quell'elemento è stato modificato, 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)
        }
    }
}

Usa derivedStateOf per limitare le ricomposizioni

L'utilizzo dello stato nelle composizioni comporta il rischio che, se lo stato cambia rapidamente, l'interfaccia utente potrebbe essere ricomposta più del necessario. Ad esempio, supponiamo che tu stia visualizzando un elenco scorrevole. Esaminerai 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 qui è che se l'utente scorre l'elenco, listState cambia continuamente quando l'utente trascina il dito. Ciò significa che l'elenco viene costantemente ricomposto. Tuttavia, in realtà non è necessario ricomporlo così spesso: non devi ricomporlo fino a quando un nuovo elemento non diventa visibile in basso. Ciò comporta molto calcolo extra e, di conseguenza, le prestazioni dell'interfaccia utente sono scadenti.

La soluzione consiste nell'utilizzare lo stato derivato. Lo stato derivato consente di indicare a Compose le modifiche di stato che in realtà dovrebbero attivare la ricomposizione. In questo caso, specifica che ti interessa quando viene modificato il primo elemento visibile. Quando questo valore dello stato cambia, l'interfaccia utente deve ricomporre, ma se l'utente non ha ancora fatto scorrere abbastanza lo stato per portare un nuovo elemento in cima, non deve ricomporlo.

val listState = rememberLazyListState()

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

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

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Rimanda le letture il più a lungo possibile

Quando è stato identificato un problema di prestazioni, il differimento delle letture dello stato può essere utile. Il differimento delle letture dello stato assicura che Compose esegua nuovamente il codice minimo possibile al momento della ricomposizione. Ad esempio, se la tua UI ha uno stato sollevato in alto nell'albero componibile e leggi lo stato in un elemento componibile secondario, puoi includere lo stato letto in una funzione lambda. In questo modo, la lettura avviene solo quando è effettivamente necessaria. Come riferimento, guarda l'implementazione nell'app di esempio Jetsnack. Jetsnack implementa un effetto simile a una barra degli strumenti compressa nella schermata dei dettagli. Per comprendere perché questa tecnica funziona, consulta il post del blog Jetpack Compose: Debugging Recomposition.

Per ottenere questo effetto, l'elemento componibile Title richiede l'offset di scorrimento per eseguire l'offset mediante 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 rende non valido l'ambito di ricomposizione padre più vicino. In questo caso, l'ambito più vicino è il componibile SnackDetail. Tieni presente che Box è una funzione incorporata, quindi non è un ambito di ricomposizione. Compose ricompone quindi SnackDetail e tutti i componibili all'interno di SnackDetail. Se modifichi il codice in modo che legga solo lo stato in cui lo utilizzi effettivamente, potresti ridurre il numero di elementi da ricomporre.

@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 è un lambda. Ciò significa che Title può ancora fare riferimento allo stato di sollevamento, 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 l'elemento componibile Title: Compose non deve più ricomporre l'intero Box.

Questo è un buon miglioramento, ma puoi fare di meglio. Crea dubbi se stai causando la ricomposizione solo per il re-layout o il nuovo disegno di un componibile. In questo caso, è sufficiente modificare l'offset dell'elemento componibile Title, operazione che può essere eseguita 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 prende l'offset come parametro. Passando alla versione lambda del modificatore, puoi assicurarti che la funzione legga lo stato di scorrimento nella fase del layout. Di conseguenza, quando lo stato di scorrimento cambia, Compose può saltare completamente la fase di composizione e passare direttamente alla fase del layout. Quando passi spesso variabili di stato che cambiano in modificatori, devi usare 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 passa rapidamente da un colore all'altro. Questo stato cambia quindi molto spesso. Il componibile legge questo stato nel modificatore di sfondo. Poiché il colore cambia a ogni fotogramma, il riquadro deve ricomporsi su ogni fotogramma.

Per migliorare questo risultato, 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 scritture all'indietro

Compose ha un presupposto fondamentale che non scriverai mai uno stato che è già stato letto. Quando esegui questa operazione, prende il nome di scrittura inversa e può causare la ricomposizione su ogni frame, all'infinito.

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

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(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 componibile dopo averlo letto nella riga precedente. Se esegui questo codice, noterai che dopo aver fatto clic sul pulsante, causando una ricomposizione, il contatore aumenta rapidamente in un loop infinito mentre Compose ricompone l'elemento componibile, vede uno stato letto che non è aggiornato e, di conseguenza, pianifica un'altra ricomposizione.

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

Risorse aggiuntive