Fasi di Jetpack Compose

Come la maggior parte degli altri kit di strumenti per l'interfaccia utente, Compose esegue il rendering di un frame attraverso diverse fasi distinte. Se guardiamo al sistema View di Android, possiamo notare che è composto da tre fasi principali: misurazione, layout e disegno. Compose è molto simile, ma all'inizio ha un'altra fase importante chiamata composizione.

La composizione è descritta nella nostra documentazione di Compose, tra cui Pensare in Compose e State e Jetpack Compose.

Le tre fasi di un frame

Compose ha tre fasi principali:

  1. Composizione: quale interfaccia utente mostrare. Compose esegue funzioni componibili e crea una descrizione dell'interfaccia utente.
  2. Layout: dove posizionare l'interfaccia utente. Questa fase è composta da due passaggi: misurazione e posizionamento. Gli elementi di layout misurano e posizionano se stessi e eventuali elementi secondari in coordinate 2D per ogni nodo dell'albero del layout.
  3. Disegno: come viene visualizzato. Gli elementi dell'interfaccia utente vengono disegnati in una tela, in genere sullo schermo del dispositivo.
Un'immagine delle tre fasi in cui Compose trasforma i dati in interfaccia utente (in ordine: dati, composizione, layout, disegno, interfaccia utente).
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 sola direzione dalla composizione al layout al disegno per produrre un frame (noto anche come flusso di dati unidirezionale). BoxWithConstraints e LazyColumn e LazyRow sono eccezioni notevoli, in cui la composizione degli elementi secondari dipende dalla fase di layout del componente 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 composable se puoi riutilizzare un risultato precedente e l'interfaccia utente di Compose non riordina o ridisegna l'intero albero se non è necessario. Compose esegue solo il lavoro minimo necessario per aggiornare l'interfaccia utente. Questa ottimizzazione è possibile perché Compose monitora le letture dello stato all'interno delle diverse fasi.

Informazioni sulle fasi

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

Composizione

Nella fase di composizione, il runtime di Compose esegue le funzioni componibili e genera una struttura ad albero che rappresenta l'interfaccia utente. Questa struttura dell'interfaccia utente è composta 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 l'interfaccia utente creata nella fase di composizione.

Una sottosezione dell'albero di codice e dell'interfaccia utente ha il seguente aspetto:

Uno snippet di codice con cinque composabili e la relativa struttura dell'interfaccia utente, con i nodi secondari che si diramano dai nodi principali.
Figura 3. Una sottosezione di una struttura ad albero dell'interfaccia utente con il codice corrispondente.

In questi esempi, ogni funzione composable nel codice viene mappata a un singolo nodo di layout nella struttura ad albero dell'interfaccia utente. In esempi più complessi, i composabili possono contenere logica e controllo del flusso e produrre un albero diverso in base a stati diversi.

Layout

Nella fase di layout, Compose utilizza come input la struttura ad albero dell'interfaccia utente prodotta nella fase di composizione. La raccolta dei nodi di layout contiene tutte le informazioni necessarie per decidere le dimensioni e la posizione di ciascun nodo nello spazio 2D.

Figura 4. La misurazione e il posizionamento di ogni nodo del layout nell'albero dell'interfaccia utente durante la fase di layout.

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

  1. Misura i figli: un nodo misura i suoi figli, se presenti.
  2. Decide le proprie dimensioni: in base a queste misurazioni, un nodo decide autonomamente le proprie dimensioni.
  3. Posiziona i nodi secondari: ogni nodo secondario viene posizionato in base alla posizione di un nodo.

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

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

Ricorda la struttura dell'interfaccia utente della sezione precedente:

Uno snippet di codice con cinque composabili e la relativa struttura ad albero dell'interfaccia utente, con i 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. Il valore Image viene misurato. Non ha elementi secondari, quindi decide autonomamente la propria dimensione e la riporta a Row.
  3. Viene misurato il valore Column. Misura prima i propri elementi secondari (due composabili Text).
  4. Viene misurato il primo Text. Non ha elementi secondari, quindi decide autonomamente le proprie dimensioni e le comunica a Column.
    1. Viene misurato il secondo Text. Non ha elementi secondari, quindi decide autonomamente le proprie dimensioni e le comunica a Column.
  5. Column utilizza le misurazioni del contenitore per decidere le proprie dimensioni. Utilizza la larghezza massima dei figli e la somma dell'altezza dei figli.
  6. Column posiziona i suoi elementi secondari rispetto a se stesso, mettendoli uno sotto l'altro verticalmente.
  7. Row utilizza le misurazioni del contenitore per decidere le proprie dimensioni. Utilizza l'altezza massima dei figli e la somma delle larghezze dei figli. Poi posiziona i suoi figli.

Tieni presente che ogni nodo è stato visitato una sola volta. Il runtime di Compose richiede un solo passaggio nella struttura ad albero dell'interfaccia utente per misurare e posizionare tutti i nodi, il che migliora il rendimento. Quando il numero di nodi nell'albero aumenta, il tempo impiegato per attraversarlo aumenta in modo lineare. Al contrario, se ogni nodo è stato visitato più volte, il tempo di attraversamento aumenta in modo esponenziale.

Disegno

Nella fase di disegno, l'albero viene percorso di nuovo dall'alto verso il basso e ogni nodo viene disegnato sullo schermo a turno.

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 tutti i contenuti che potrebbe avere, ad esempio un colore di sfondo.
  2. Il simbolo Image si disegna da solo.
  3. Il simbolo Column si disegna da solo.
  4. Il primo e il secondo Text si disegnano rispettivamente.

Figura 6. Un albero dell'interfaccia utente e la relativa rappresentazione disegnata.

Letture dello stato

Quando leggi il valore di uno stato snapshot durante una delle fasi elencate sopra, Compose monitora automaticamente le operazioni in corso al momento della lettura del valore. Questo monitoraggio consente a Compose di eseguire nuovamente il lettore quando il valore dello stato cambia ed è la base dell'osservabilità dello stato in Compose.

Lo stato viene comunemente creato utilizzando mutableStateOf() e poi acceduto in uno di due modi: accedendo direttamente alla proprietà mutableStateOf() oppure utilizzando un delegato della proprietà Kotlin.value Scopri di più in Stato nei composabili. Ai fini di questa guida, una "lettura dello stato" si riferisce a 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)
)

Sotto il cofano del property delegate, 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 sopra indicati sono equivalenti.

Ogni blocco di codice che può essere eseguito di nuovo quando cambia uno stato di lettura è un ambito di riavvio. Compose tiene traccia delle modifiche ai valori di stato e degli scopi di riavvio in fasi diverse.

Letture dello stato a fasi

Come accennato in precedenza, in Compose sono presenti tre fasi principali e Compose 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 dell'interfaccia utente.

Esaminiamo ogni fase e descriviamo cosa succede quando viene letto un valore dello stato 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 dello stato cambia, il recomposer pianifica le repliche di tutte le funzioni composable che leggono quel valore dello stato. Tieni presente che il runtime potrebbe decidere di saltare alcune o tutte le funzioni composable se gli input non sono cambiati. Per ulteriori informazioni, consulta la sezione Saltare se gli input non sono stati modificati.

A seconda del risultato della composizione, Compose UI 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 misura lambda passata al composable Layout, al metodo MeasureScope.measure dell'interfaccia LayoutModifier e così via. Il passaggio di posizionamento esegue il blocco di posizionamento della funzione layout, il blocco lambda di Modifier.offset { … } e così via.

Le letture dello stato durante ciascuno di questi passaggi influiscono sul layout e potenzialmente sulla fase di disegno. Quando il valore dello stato cambia, la UI di Compose pianifica la fase di layout. Esegue anche la fase di disegno se le dimensioni o la posizione sono cambiate.

Per essere più precisi, il passaggio di misurazione e il passaggio di posizionamento hanno ambiti di riavvio distinti, il che significa che le letture dello stato nel passaggio di posizionamento non richiamano nuovamente il passaggio di misurazione precedente. Tuttavia, questi due passaggi sono spesso correlati, pertanto uno stato letto nel passaggio di posizionamento può influire su altri ambiti di riavvio che appartengono al passaggio di misurazione.

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: disegno

Le letture dello stato durante il codice di disegno influiscono sulla fase di disegno. Esempi comuni includono Canvas(), Modifier.drawBehind e Modifier.drawWithContent. Quando il valore dello stato cambia, Compose UI 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)
}

Ottimizzazione delle letture dello stato

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

Vediamo un esempio. Qui abbiamo un Image() che utilizza il modificatore offset per compensare la posizione del layout finale, creando un effetto parallasse quando l'utente scorre.

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 il rendimento non è ottimale. Come scritto, il codice legge il valore dello stato firstVisibleItemScrollOffset e lo passa alla funzione Modifier.offset(offset: Dp). Man mano che l'utente scorre, il valore firstVisibleItemScrollOffset cambia. Come sappiamo, Compose monitora qualsiasi lettura dello stato in modo da poter riavviare (richiamare di nuovo) il codice di lettura, che nel nostro esempio è il contenuto del Box.

Questo è un esempio di stato letto nella fase di composizione. Questo non è necessariamente un male, anzi è la base della ricostituzione, che consente di emettere una nuova UI in base alle modifiche dei dati.

In questo esempio, però, non è ottimale, perché ogni evento di scorrimento comporta la rivalutazione dell'intero contenuto composable, nonché la sua misurazione, impaginazione e infine disegno. Attiviamo la fase di composizione a ogni scorrimento anche se ciò che mostriamo non è cambiato, ma solo dove viene mostrato. Possiamo ottimizzare la lettura dello stato in modo da riattivare solo la fase di layout.

È 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. Aggiorniamo 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é ha un rendimento migliore? Il blocco lambda che forniamo al modificatore viene invocato durante la fase di layout (in particolare, durante il passaggio di posizionamento della fase di layout), il che significa che il nostro stato firstVisibleItemScrollOffset non viene più letto durante la composizione. Poiché Compose tiene traccia della lettura dello stato, questa modifica significa che se il valore firstVisibleItemScrollOffset cambia, Compose deve solo riavviare le fasi di layout e disegno.

Questo esempio si basa sui diversi modificatori di offset per poter ottimizzare il codice risultante, ma l'idea generale è vera: prova a localizzare le letture dello stato nella fase più bassa possibile, consentendo a Compose di eseguire il minimo lavoro.

Naturalmente, spesso è assolutamente necessario leggere gli stati nella fase di composizione. Tuttavia, in alcuni casi possiamo ridurre al minimo il numero di ricostruzioni filtrando le modifiche dello stato. Per ulteriori informazioni, consulta derivedStateOf: converti uno o più oggetti stato in un altro stato.

Ciclo di ricostituzione (dipendenza dalla fase ciclica)

In precedenza abbiamo accennato al fatto 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 { mutableStateOf(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() }
        )
    )
}

Qui abbiamo implementato (male) una colonna verticale, con l'immagine in alto e il testo sotto. Utilizziamo Modifier.onSizeChanged() per conoscere le dimensioni risolte dell'immagine e poi Modifier.padding() sul testo per spostarlo verso il basso. La conversione non naturale da Px a Dp indica già che il codice presenta un problema.

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

Esaminiamo ogni fotogramma per vedere cosa succede:

Nella fase di composizione del primo frame, imageHeightPx ha un valore pari a 0 e il testo viene fornito con Modifier.padding(top = 0). Segue la fase di layout e viene chiamato il callback per il modificatore onSizeChanged. In questo caso, imageHeightPx viene aggiornato in base all'altezza effettiva dell'immagine. Componi la ricomposizione delle pianificazioni per il fotogramma successivo. Nella fase di disegno, il testo viene visualizzato con un'area interna pari a 0, poiché la modifica del valore non è ancora stata applicata.

Componi avvia quindi il secondo frame pianificato dalla variazione del valore di imageHeightPx. Lo stato viene letto nel blocco dei contenuti della casella e viene invocato nella fase di composizione. Questa volta, al testo viene fornito un padding corrispondente all'altezza dell'immagine. Nella fase di layout, il codice imposta di nuovo il valore di imageHeightPx, ma non è pianificata alcuna ricompozione poiché il valore rimane invariato.

Alla fine, otteniamo il padding desiderato sul testo, ma non è ottimale impiegare un frame aggiuntivo per ritrasmettere il valore del padding a una fase diversa e si produrrà un frame con contenuti sovrapposti.

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

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

La correzione per l'esempio riportato sopra consiste nell'utilizzare le primitive di layout appropriate. L'esempio riportato sopra può essere implementato con un semplice Column(), ma potresti avere un esempio più complesso che richiede qualcosa di personalizzato, il che comporterà la scrittura di un layout personalizzato. Per ulteriori informazioni, consulta la guida Layout personalizzati.

Il principio generale è avere un'unica fonte attendibile per più elementi dell'interfaccia utente che devono essere misurati e posizionati in base l'uno all'altro. L'utilizzo di un elemento di layout appropriato o la creazione di un layout personalizzato significa che l'elemento principale condiviso minimo funge da fonte attendibile che può coordinare la relazione tra più elementi. L'introduzione di uno stato dinamico viola questo principio.