Segui le best practice

Potresti riscontrare problemi comuni di Compose. Questi errori potrebbero generare codice che sembra funzionare abbastanza bene, ma che può compromettere il rendimento della UI. Segui le best practice per ottimizzare l'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, dovresti eseguire il minor numero possibile di calcoli nel corpo del componibile.

Una tecnica importante consiste nell'archiviare 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 di nomi ordinato, ma l'ordinamento viene eseguito in modo molto dispendioso:

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

Per risolvere questo problema, ordina l'elenco al di fuori di LazyColumn e archivialo 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 sola 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 memorizzato nella cache.

Utilizza le chiavi di layout lazy

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.

Supponiamo che un'operazione dell'utente sposti un elemento nell'elenco. Ad esempio, supponiamo 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, questo codice presenta un problema. Supponiamo che la nota in basso venga modificata. Ora è la nota modificata più di recente, quindi viene spostata in cima all'elenco e tutte le altre note vengono spostate di una posizione verso il basso.

Senza il tuo aiuto, Compose non si rende conto che gli elementi invariati vengono semplicemente spostati nell'elenco. Compose pensa 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 così via. Il risultato è che Compose ricompone ogni elemento dell'elenco, anche se solo uno di essi è stato effettivamente modificato.

La soluzione in questo caso è fornire le chiavi degli elementi. Fornire una chiave stabile per ogni elemento consente a Compose di evitare ricomposizioni non necessarie. In questo caso, Compose può determinare che l'elemento ora nella posizione 3 è lo stesso elemento che si trovava nella posizione 2. Poiché nessuno dei dati di questo 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)
        }
    }
}

Utilizza derivedStateOf per limitare le ricomposizioni

Uno dei rischi dell'utilizzo dello stato nelle composizioni è che, se lo stato cambia rapidamente, la UI potrebbe essere ricomposta più del necessario. Ad esempio, supponiamo di visualizzare un elenco scorrevole. Esaminiamo lo stato dell'elenco per vedere quale elemento è il primo elemento visibile nell'elenco:

val listState = rememberLazyListState()

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

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Il problema in questo caso è che, se l'utente scorre l'elenco, listState cambia continuamente mentre l'utente trascina il dito. Ciò significa che l'elenco viene ricomposto continuamente. Tuttavia, non è necessario ricomporlo così spesso: non è necessario ricomporlo finché un nuovo elemento non diventa visibile in basso. Si tratta quindi di molti calcoli aggiuntivi, che compromettono il rendimento della UI.

La soluzione è utilizzare lo stato derivato. 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 quando cambia il primo elemento visibile. Quando il valore dello stato cambia, la UI deve essere ricomposta, ma se l'utente non ha ancora scorre abbastanza da 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()
}

Posticipa le letture il più a lungo possibile

Quando viene identificato un problema di prestazioni, può essere utile posticipare le letture dello stato. Il posticipo delle letture dello stato garantisce che Compose esegua nuovamente il codice minimo possibile durante la ricomposizione. Ad esempio, se la UI ha uno stato che viene sollevato in alto nell'albero dei componibili e leggi lo stato in un componibile figlio, puoi racchiudere la lettura dello stato in una funzione lambda. In questo modo, la lettura viene eseguita 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 post del blog Jetpack Compose: Debugging Recomposition.

Per ottenere questo effetto, il componibile Title ha bisogno dell'offset di scorrimento per spostarsi 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 del parent più vicino. In questo caso, l'ambito più vicino è il componibile SnackDetail. Tieni presente che Box è una funzione inline, 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 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 una lambda. Ciò significa che Title può ancora fare riferimento allo stato sollevato, 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.

Questo è un buon miglioramento, ma puoi fare di meglio. Dovresti sospettare se stai causando la ricomposizione solo per riorganizzare o ridisegnare un componibile. In questo caso, tutto ciò che stai facendo è modificare l'offset del componibile 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 le variabili di stato che cambiano frequentemente ai modificatori, dovresti utilizzare le versioni lambda dei modificatori, se possibile.

Ecco un altro esempio di questo approccio. Questo codice non è ancora stato 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)
)

Qui, il colore di sfondo della casella cambia rapidamente tra due colori. Questo stato cambia quindi molto frequentemente. Il componibile legge quindi questo stato nel modificatore di sfondo. Di conseguenza, la casella deve essere ricomposta su ogni frame, poiché il colore cambia su 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 presuppone che non scriverai mai in uno stato che è già stato letto. Quando lo fai, si parla di scrittura all'indietro 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 { 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 componibile 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 man mano che Compose ricompone questo componibile, vede una lettura dello stato non aggiornata e quindi pianifica un'altra ricomposizione.

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

Risorse aggiuntive