Fasi di Jetpack Compose

Come la maggior parte degli altri toolkit UI, Compose esegue il rendering di un frame attraverso diverse fasi distinte. Ad esempio, il sistema di visualizzazione Android prevede tre fasi principali: misurazione, layout e disegno. Compose è molto simile, ma ha un'importante fase aggiuntiva chiamata composizione all'inizio.

La documentazione di Compose descrive la composizione in Pensare in Compose e Stato e Jetpack Compose.

Le tre fasi di un frame

Compose prevede tre fasi principali:

  1. Composizione: quali elementi della UI mostrare. Compose esegue funzioni componibili e crea una descrizione della tua UI.
  2. Layout: dove posizionare l'interfaccia utente. Questa fase prevede due passaggi: misurazione e posizionamento. Gli elementi di layout misurano e posizionano se stessi e gli eventuali elementi secondari in coordinate 2D, per ogni nodo nell'albero del layout.
  3. Disegno: come viene visualizzato. Gli elementi UI vengono disegnati in un Canvas, di solito lo schermo di un dispositivo.
Le tre fasi in cui Compose trasforma i dati in UI (in ordine, dati, composizione, layout, disegno, UI).
Figura 1. Le tre fasi in cui Compose trasforma i dati in UI.

L'ordine di queste fasi è generalmente lo stesso, consentendo ai dati di fluire in una direzione dalla composizione al layout al disegno per produrre un frame (noto anche come flusso di dati unidirezionale). BoxWithConstraints, LazyColumn e LazyRow sono eccezioni degne di nota, in cui la composizione dei relativi elementi secondari dipende dalla fase di layout dell'elemento principale.

Concettualmente, ciascuna di queste fasi si verifica per ogni frame; tuttavia, per ottimizzare le prestazioni, Compose evita di ripetere il lavoro che calcolerebbe gli stessi risultati dagli stessi input in tutte queste fasi. Compose salta l'esecuzione di una funzione componibile se può riutilizzare un risultato precedente e l'interfaccia utente di Compose non esegue il layout o il disegno dell'intero albero se non è necessario. Compose esegue solo la quantità minima di lavoro necessaria per aggiornare la UI. Questa ottimizzazione è possibile perché Compose monitora le letture dello stato nelle diverse fasi.

Comprendi le fasi

Questa sezione descrive in modo più dettagliato come vengono eseguite le tre fasi di Compose per i componibili.

Composizione

Nella fase di composizione, il runtime di Compose esegue le funzioni componibili e genera una struttura ad albero che rappresenta la UI. Questo albero della UI è costituito da nodi di layout che contengono tutte le informazioni necessarie per le fasi successive, come mostrato nel seguente video:

Figura 2. L'albero che rappresenta la UI creata nella fase di composizione.

Una sottosezione dell'albero del codice e della UI è simile alla seguente:

Uno snippet di codice con cinque elementi componibili e l'albero UI risultante, con nodi secondari che si diramano dai nodi principali.
Figura 3. Una sottosezione di un albero UI con il codice corrispondente.

In questi esempi, ogni funzione componibile nel codice viene mappata a un singolo nodo di layout nell'albero della UI. In esempi più complessi, i componibili possono contenere logica e flusso di controllo e produrre un albero diverso in base a stati diversi.

Layout

Nella fase di layout, Compose utilizza l'albero della UI prodotto nella fase di composizione come input. La raccolta di nodi di layout contiene tutte le informazioni necessarie per decidere le dimensioni e la posizione di ogni nodo nello spazio 2D.

Figura 4. La misurazione e il posizionamento di ogni nodo di layout nell'albero della UI durante la fase di layout.

Durante la fase di layout, l'albero viene attraversato utilizzando il seguente algoritmo in tre passaggi:

  1. Misurazione degli elementi secondari: un nodo misura i suoi elementi secondari, se presenti.
  2. Decide own size: in base a queste misurazioni, un nodo decide le proprie dimensioni.
  3. Posiziona i nodi secondari: ogni nodo secondario viene posizionato in base alla posizione del nodo principale.

Al termine di questa fase, ogni nodo del layout ha:

  • Larghezza e altezza assegnate
  • Una coordinata x, y in cui deve essere disegnato

Ricorda l'albero della UI della sezione precedente:

Uno snippet di codice con cinque componibili e l'albero della UI risultante, con nodi secondari che si diramano dai nodi principali

Per questo albero, l'algoritmo funziona nel seguente modo:

  1. Row misura i suoi elementi secondari, Image e Column.
  2. La Image viene misurata. Non ha elementi secondari, quindi decide le proprie dimensioni e le comunica a Row.
  3. Successivamente viene misurato il Column. Misura prima i propri elementi secondari (due componibili Text).
  4. Viene misurato il primo Text. Non ha elementi secondari, quindi decide le proprie dimensioni e le comunica a Column.
    1. Viene misurato il secondo Text. Non ha elementi secondari, quindi decide le proprie dimensioni e le comunica all'elemento Column.
  5. Column utilizza le misurazioni degli elementi secondari per decidere le proprie dimensioni. Utilizza la larghezza massima degli elementi secondari e la somma dell'altezza degli elementi secondari.
  6. Column posiziona i suoi elementi secondari rispetto a se stesso, mettendoli uno sotto l'altro in verticale.
  7. Row utilizza le misurazioni degli elementi secondari per decidere le proprie dimensioni. Utilizza l'altezza massima degli elementi secondari e la somma delle larghezze degli elementi secondari. Poi posiziona i suoi elementi secondari.

Tieni presente che ogni nodo è stato visitato una sola volta. Il runtime di Compose richiede un solo passaggio attraverso l'albero della UI per misurare e posizionare tutti i nodi, il che migliora le prestazioni. Quando il numero di nodi nell'albero aumenta, il tempo impiegato per attraversarlo aumenta in modo lineare. Al contrario, se ogni nodo viene visitato più volte, il tempo di attraversamento aumenta in modo esponenziale.

Disegno

Nella fase di disegno, l'albero viene nuovamente attraversato dall'alto verso il basso e ogni nodo si disegna a sua volta sullo schermo.

Figura 5. La fase di disegno disegna i pixel sullo schermo.

Utilizzando l'esempio precedente, i contenuti dell'albero vengono disegnati nel seguente modo:

  1. Row disegna i contenuti che potrebbe avere, ad esempio un colore di sfondo.
  2. Image si disegna da solo.
  3. Column si disegna da solo.
  4. Il primo e il secondo Text si disegnano da soli.

Figura 6. Un albero della UI e la sua rappresentazione disegnata.

Letture dello stato

Quando leggi il value di un snapshot state durante una delle fasi elencate in precedenza, Compose monitora automaticamente ciò che stava facendo quando ha letto il value. Questo monitoraggio consente a Compose di eseguire nuovamente il lettore quando il valore value dello stato cambia ed è alla base dell'osservabilità dello stato in Compose.

In genere, crei lo stato utilizzando mutableStateOf() e poi vi accedi in uno dei due modi seguenti: accedendo direttamente alla proprietà value o utilizzando un delegato della proprietà Kotlin. Puoi scoprire di più in Stato nei componibili. Ai fini di questa guida, per "lettura dello stato" si intende uno di questi metodi di accesso equivalenti.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

Dietro le quinte del delegato della proprietà, le funzioni "getter" e "setter" vengono utilizzate per accedere e aggiornare il value dello stato. Queste funzioni getter e setter vengono richiamate solo quando fai riferimento alla proprietà come valore e non quando viene creata, motivo per cui i due modi descritti in precedenza sono equivalenti.

Ogni blocco di codice che può essere eseguito nuovamente quando cambia uno stato di lettura è un ambito di riavvio. Compose tiene traccia delle modifiche dello stato value e riavvia gli ambiti in fasi diverse.

Letture dello stato in fasi

Come accennato in precedenza, Compose prevede tre fasi principali e tiene traccia dello stato letto in ciascuna di esse. In questo modo, Compose può notificare solo le fasi specifiche che devono eseguire il lavoro per ogni elemento interessato della UI.

Le sezioni seguenti descrivono ogni fase e cosa succede quando un valore di stato viene letto al suo interno.

Fase 1: composizione

Le letture dello stato all'interno di una funzione @Composable o di un blocco lambda influiscono sulla composizione e potenzialmente sulle fasi successive. Quando il valore value dello stato cambia, il ricompositore pianifica le nuove esecuzioni di tutte le funzioni componibili che leggono il valore value dello stato. Tieni presente che il runtime potrebbe decidere di saltare alcune o tutte le funzioni componibili se gli input non sono cambiati. Per saperne di più, consulta Saltare l'esecuzione se gli input non sono cambiati.

A seconda del risultato della composizione, la UI di Compose esegue le fasi di layout e disegno. Potrebbe saltare queste fasi se i contenuti rimangono invariati e le dimensioni e il layout non cambiano.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

Fase 2: layout

La fase di layout è composta da due passaggi: misurazione e posizionamento. Il passaggio di misurazione esegue la lambda di misurazione passata al componibile Layout, il metodo MeasureScope.measure dell'interfaccia LayoutModifier e altro ancora. Il passaggio di posizionamento esegue il blocco di posizionamento della funzione layout, il blocco lambda di Modifier.offset { … } e funzioni simili.

Le letture dello stato durante ciascuno di questi passaggi influiscono sul layout e potenzialmente sulla fase di disegno. Quando lo stato di value cambia, l'interfaccia utente di Compose pianifica la fase di layout. Esegue anche la fase di disegno se le dimensioni o la posizione sono cambiate.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Fase 3: estrazione

Le letture dello stato durante il codice di disegno influiscono sulla fase di disegno. Esempi comuni includono Canvas(), Modifier.drawBehind e Modifier.drawWithContent. Quando lo stato di value cambia, l'interfaccia utente di Compose esegue solo la fase di disegno.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

Diagramma che mostra che una lettura dello stato durante la fase di disegno attiva solo la nuova esecuzione della fase di disegno.

Ottimizza le letture dello stato

Poiché Compose esegue il monitoraggio della lettura dello stato localizzato, puoi ridurre al minimo la quantità di lavoro eseguito leggendo ogni stato in una fase appropriata.

Considera l'esempio seguente. Questo esempio contiene un Image() che utilizza il modificatore offset per compensare la posizione finale del layout, creando un effetto di parallasse durante lo scorrimento da parte dell'utente.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

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

Questo codice funziona, ma genera prestazioni non ottimali. Come scritto, il codice legge il valore value dello stato firstVisibleItemScrollOffset e lo passa alla funzione Modifier.offset(offset: Dp). Man mano che l'utente scorre, il valore di value di firstVisibleItemScrollOffset cambierà. Come hai imparato, Compose monitora tutte le letture dello stato in modo da poter riavviare (richiamare) il codice di lettura, che in questo esempio è il contenuto di Box.

Questo è un esempio di lettura di uno stato nella fase di composizione. Questo non è necessariamente un aspetto negativo e, di fatto, è la base della ricomposizione, che consente alle modifiche dei dati di generare una nuova UI.

Punto chiave: questo esempio non è ottimale perché ogni evento di scorrimento comporta la rivalutazione, la misurazione, il layout e infine il disegno dell'intero contenuto componibile. Attivi la fase di composizione a ogni scorrimento, anche se i contenuti mostrati non sono cambiati, ma è cambiata solo la loro posizione. Puoi ottimizzare la lettura dello stato per attivare nuovamente solo la fase di layout.

Offset con lambda

È disponibile un'altra versione del modificatore di offset: Modifier.offset(offset: Density.() -> IntOffset).

Questa versione accetta un parametro lambda, in cui l'offset risultante viene restituito dal blocco lambda. Aggiorna il codice per utilizzarlo:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

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

Perché questo metodo è più efficiente? Il blocco lambda che fornisci al modificatore viene richiamato durante la fase di layout (in particolare, durante il passaggio di posizionamento della fase di layout), il che significa che lo stato firstVisibleItemScrollOffset non viene più letto durante la composizione. Poiché Compose monitora quando lo stato viene letto, questa modifica significa che se il valore value di firstVisibleItemScrollOffset cambia, Compose deve solo riavviare le fasi di layout e disegno.

Naturalmente, spesso è assolutamente necessario leggere gli stati nella fase di composizione. Tuttavia, esistono casi in cui puoi ridurre al minimo il numero di ricomposizioni filtrando le modifiche dello stato. Per saperne di più, consulta derivedStateOf: converti uno o più oggetti di stato in un altro stato.

Loop di ricomposizione (dipendenza ciclica delle fasi)

In precedenza in questa guida è stato menzionato che le fasi di Compose vengono sempre richiamate nello stesso ordine e che non è possibile tornare indietro nello stesso frame. Tuttavia, ciò non impedisce alle app di entrare in loop di composizione in frame diversi. Considera questo esempio:

Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

Questo esempio implementa una colonna verticale, con l'immagine in alto e il testo sotto. Utilizza Modifier.onSizeChanged() per ottenere le dimensioni risolte dell'immagine, quindi utilizza Modifier.padding() sul testo per spostarlo verso il basso. La conversione innaturale da Px a Dp indica già che il codice presenta un problema.

Il problema di questo esempio è che il codice non arriva al layout "finale " all'interno di un singolo frame. Il codice si basa sull'esecuzione di più frame, il che comporta un lavoro non necessario e fa sì che l'UI salti sullo schermo per l'utente.

Composizione del primo frame

Durante la fase di composizione del primo frame, imageHeightPx è inizialmente 0. Di conseguenza, il codice fornisce il testo con Modifier.padding(top = 0). La fase di layout successiva richiama il callback del modificatore onSizeChanged, che aggiorna imageHeightPx all'altezza effettiva dell'immagine. Compose pianifica quindi una ricomposizione per il frame successivo. Tuttavia, durante la fase di disegno corrente, il testo viene visualizzato con un padding di 0, poiché il valore imageHeightPx aggiornato non è ancora applicato.

Composizione del secondo frame

Compose avvia il secondo frame, attivato dalla modifica del valore di imageHeightPx. Nella fase di composizione di questo frame, lo stato viene letto all'interno del blocco di contenuti Box. Il testo è ora dotato di spaziatura interna che corrisponde esattamente all'altezza dell'immagine. Durante la fase di layout, imageHeightPx viene impostato di nuovo; tuttavia, non è pianificata alcuna ulteriore ricomposizione perché il valore rimane coerente.

Diagramma che mostra un loop di ricomposizione in cui una modifica delle dimensioni nella fase di layout attiva una ricomposizione, che a sua volta causa un nuovo layout.

Questo esempio può sembrare artificioso, ma fai attenzione a questo pattern generale:

  • Modifier.onSizeChanged(), onGloballyPositioned() o altre operazioni di layout
  • Aggiorna uno stato
  • Utilizza questo stato come input per un modificatore di layout (padding(), height() o simile)
  • Potenzialmente ripetere

La correzione per l'esempio precedente consiste nell'utilizzare le primitive di layout appropriate. L'esempio precedente può essere implementato con un Column(), ma potresti avere un esempio più complesso che richiede un layout personalizzato, che dovrà essere scritto. Per saperne di più, consulta la guida ai layout personalizzati.

Il principio generale è avere un'unica Single Source Of Truth per più elementi UI che devono essere misurati e posizionati l'uno rispetto all'altro. L'utilizzo di una primitiva di layout appropriata o la creazione di un layout personalizzato significa che l'elemento principale condiviso minimo funge da fonte attendibile in grado di coordinare la relazione tra più elementi. L'introduzione di uno stato dinamico viola questo principio.

Per ulteriori informazioni sui loop di ricomposizione e su come evitare di scrivere nello stato in più fasi, consulta Scritture in ordine inverso in Compose.