Fasi di Jetpack Compose

Come la maggior parte degli altri toolkit dell'interfaccia utente, Compose esegue il rendering di un frame attraverso diverse fasi distinte. Il sistema Android View ha tre fasi principali: misurazione, layout e disegno. La scrittura è molto simile, ma presenta una fase aggiuntiva importante chiamata composizione all'inizio.

La composizione è descritta nei nostri documenti di Compose, tra cui Thinking in Compose e State e Jetpack Compose.

Le tre fasi di un frame

La composizione si compone di tre fasi principali:

  1. Composizione: UI UI da mostrare. Compose esegue funzioni componibili e crea una descrizione dell'interfaccia utente.
  2. Layout: dove posizionare l'interfaccia utente. Questa fase prevede due passaggi: misurazione e posizionamento. Gli elementi di layout misurano e posizionano se stessi, insieme a tutti gli elementi secondari nelle coordinate 2D, per ciascun nodo nell'albero del layout.
  3. Disegno: come esegue il rendering. Gli elementi UI sono disegnati in Canvas, solitamente la schermata di un dispositivo.
Un'immagine delle 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 il flusso di dati 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 degli elementi secondari dipende dalla fase del layout dell'elemento principale.

Puoi tranquillamente supporre che queste tre fasi avvengano virtualmente per ogni frame, ma ai fini delle prestazioni, Compose evita di dover ripetere lavori che comporterebbero gli stessi risultati dagli stessi input in tutte queste fasi. Scrivi salta eseguendo una funzione componibile se può riutilizzare un risultato precedente e la UI di Compose non rielabora il layout né ridisegna l'intero albero se non è necessario. Compose esegue solo la quantità minima di lavoro richiesta per aggiornare l'UI. Questa ottimizzazione è possibile perché Compose tiene traccia delle letture dello stato all'interno delle diverse fasi.

Comprendere le fasi

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

Composizione

Nella fase di composizione, il runtime di Compose esegue funzioni componibili e restituisce una struttura ad albero che rappresenta la UI. Questa struttura ad albero dell'interfaccia utente è composta da nodi di layout contenenti tutte le informazioni necessarie per le fasi successive, come mostrato nel seguente video:

Figura 2. L'albero che rappresenta l'interfaccia utente creata in fase di composizione.

Una sottosezione del codice e della struttura ad albero dell'interfaccia utente è simile alla seguente:

Uno snippet di codice con cinque elementi componibili e la conseguente 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 componibile nel codice viene mappata a un singolo nodo di layout nella struttura ad albero dell'interfaccia utente. In esempi più complessi, gli elementi componibili possono contenere logica e flusso di controllo e produrre un albero diverso in base agli stati diversi.

Layout

Nella fase di layout, Compose utilizza come input l'albero dell'interfaccia utente prodotto nella fase di composizione. La raccolta di 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 di layout nella struttura ad albero dell'interfaccia utente durante la fase di layout.

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

  1. Misura gli elementi secondari: un nodo misura gli eventuali elementi secondari.
  2. Stabilisci la dimensione personalizzata: in base a queste misurazioni, un nodo decide la propria dimensione.
  3. Posiziona elementi figlio: ogni nodo figlio viene posizionato in relazione alla posizione di un nodo.

Al termine di questa fase, ciascun nodo di layout contiene:

  • Una larghezza e un'altezza assegnate
  • Una coordinata x, y in cui deve essere tracciata

Ricorda la struttura dell'interfaccia utente della sezione precedente:

Uno snippet di codice con cinque elementi componibili e l'albero dell'interfaccia utente risultante, con i nodi figlio che si diramano dai nodi principali

Per questo albero, l'algoritmo funziona come segue:

  1. Row misura i suoi elementi secondari, Image e Column.
  2. Viene misurato il Image. Non ha elementi secondari, quindi decide le proprie dimensioni e riporta le dimensioni in Row.
  3. Viene misurata la metrica Column. Misura prima i propri figli (due Text componibili).
  4. Viene misurato il primo Text. Non ha elementi secondari, quindi decide le proprie dimensioni e ne segnala le dimensioni a Column.
    1. Il secondo Text viene misurato. Non ha figli, quindi ne stabilisce le dimensioni e lo segnala al Column.
  5. Column utilizza le misure secondarie per decidere la propria dimensione. Utilizza la larghezza massima dell'asset secondario e la somma dell'altezza degli elementi secondari.
  6. Column posiziona i figli rispetto a se stesso, mettendoli sotto l'altro verticalmente.
  7. Row utilizza le misure secondarie per decidere la propria dimensione. Utilizza l'altezza massima dell'asset secondario e la somma delle larghezze degli elementi secondari. Poi posiziona i propri figli.

Tieni presente che ciascun 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 le prestazioni. Quando il numero di nodi nell'albero aumenta, il tempo trascorso per attraversarlo aumenta in modo lineare. Al contrario, se ciascun nodo è stato 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 sullo schermo a sua volta.

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

Utilizzando l'esempio precedente, i contenuti dell'albero sono tracciati nel seguente modo:

  1. L'elemento Row disegna qualsiasi contenuto che potrebbe avere, ad esempio un colore di sfondo.
  2. Image disegna se stesso.
  3. Column disegna se stesso.
  4. Il primo e il secondo Text si tracciano rispettivamente.

Figura 6. Una struttura ad albero dell'interfaccia utente e la sua rappresentazione disegnata.

Letture dello stato

Quando leggi il valore di uno stato di snapshot durante una delle fasi elencate sopra, Compose tiene automaticamente traccia di ciò che stava facendo quando il valore è stato letto. Questo monitoraggio consente a Compose di rieseguire il lettore quando il valore dello stato cambia e costituisce la base dell'osservabilità dello stato in Compose.

Lo stato viene in genere creato utilizzando mutableStateOf(), a cui si accede in due modi: tramite l'accesso diretto alla proprietà value o in alternativa utilizzando un delegato della proprietà Kotlin. Puoi scoprire di più in merito in Stato in 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)
)

Nell'ambito dell'delegato della proprietà, le funzioni "getter" e "setter" vengono utilizzate per accedere e aggiornare lo stato value. Queste funzioni getter e setter vengono richiamate solo quando si fa riferimento alla proprietà come valore e non quando viene creata, motivo per cui i due modi descritti sopra sono equivalenti.

Ogni blocco di codice che può essere rieseguito quando cambia lo stato di lettura è un ambito di riavvio. Compose tiene traccia delle modifiche ai valori di stato e riavvia gli ambiti in fasi diverse.

Lettura dello stato per fasi

Come accennato in precedenza, ci sono tre fasi principali in Compose, e Compose tiene traccia dello stato letto all'interno di ognuna. In questo modo Compose può inviare notifiche solo alle fasi specifiche che devono eseguire operazioni per ogni elemento interessato della UI.

Analizziamo ogni fase e descriviamo cosa succede quando viene letto un valore di 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 ricompositore pianifica le repliche di tutte le funzioni componibili che leggono quel valore di stato. Tieni presente che il runtime potrebbe decidere di ignorare alcune o tutte le funzioni componibili se gli input non sono cambiati. Per saperne di più, consulta Ignorare se gli input non sono cambiati.

A seconda del risultato della composizione, l'interfaccia utente di Compose esegue le fasi di layout e disegno. Potresti saltare queste fasi se i contenuti rimangono gli stessi, 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 del layout prevede due passaggi: misurazione e posizionamento. Il passaggio di misurazione esegue la misura lambda passata al componibile Layout, il 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, l'interfaccia utente di Compose pianifica la fase del layout. Esegue anche la fase di disegno se le dimensioni o la posizione sono cambiate.

Per essere più precisi, la fase di misurazione e quella di posizionamento hanno ambiti di riavvio separati, il che significa che lo stato letto nella fase di posizionamento non richiama nuovamente la fase di misurazione precedente. Tuttavia, questi due passaggi sono spesso intrecciati, quindi uno stato letto nella fase di posizionamento può influire su altri ambiti di riavvio che appartengono alla fase 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 disegno del codice influiscono sulla fase di disegno. Gli esempi comuni includono Canvas(), Modifier.drawBehind e Modifier.drawWithContent. Quando il valore dello stato 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)
}

Ottimizzazione letture stato

Man mano che 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 elemento Image() che utilizza il modificatore di offset per compensare la posizione finale del layout, creando un effetto parallasse mentre 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 genera prestazioni non ottimali. 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 cambierà. Come sappiamo, Compose tiene traccia di qualsiasi lettura dello stato in modo da poter riavviare (richiamare nuovamente) il codice di lettura, che nel nostro esempio è il contenuto di Box.

Questo è un esempio di stato letto all'interno della fase di composizione. Questo non è necessariamente un aspetto negativo, poiché è la base della ricomposizione, consentendo alle modifiche dei dati di generare una nuova UI.

In questo esempio non è ottimale, perché ogni evento di scorrimento fa sì che l'intero contenuto componibile venga rivalutato e poi anche misurato, distribuito e finalmente disegnato. Stiamo attivando la fase di composizione a ogni scorrimento anche se quello che mostriamo non è cambiato, ma soltanto dove viene mostrato. Possiamo ottimizzare la lettura dello stato per riattivare solo la fase del layout.

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

Questa versione utilizza un parametro lambda, in cui l'offset risultante viene restituito dal blocco lambda. Aggiorniamo il nostro 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é questa strategia ha un rendimento migliore? Il blocco lambda fornito al modificatore viene richiamato durante la fase di layout (nello specifico, durante la fase di posizionamento della fase di layout), il che significa che lo stato firstVisibleItemScrollOffset non viene più letto durante la composizione. Poiché Compose monitora quando viene letto lo stato, questa modifica significa che se il valore firstVisibleItemScrollOffset cambia, Compose dovrà riavviare solo le fasi di layout e disegno.

Questo esempio si basa sui diversi modificatori di offset per 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 la quantità minima di lavoro.

Naturalmente, spesso è assolutamente necessario leggere gli stati nella fase di composizione. Anche in questo caso, ci sono casi in cui possiamo ridurre al minimo il numero di ricomposizioni filtrando le modifiche di stato. Per ulteriori informazioni, consulta derivedStateOf: conversione di uno o più oggetti di stato in un altro stato.

Ciclo di ricomposizione (dipendenza di fase ciclica)

In precedenza abbiamo accennato al fatto che le fasi di Compose vengono sempre richiamate nello stesso ordine e che non c'è modo di tornare indietro nello stesso frame. Tuttavia, ciò non impedisce alle app di entrare in cicli 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() }
        )
    )
}

In questo caso abbiamo (malamente) implementato una colonna verticale, con l'immagine in alto e il testo sotto. Usiamo Modifier.onSizeChanged() per conoscere la dimensione risolta dell'immagine, quindi usiamo Modifier.padding() sul testo per spostarla verso il basso. La conversione non naturale da Px a Dp indica già che il codice presenta qualche problema.

Il problema in questo esempio è che non si arriva al layout "finale" all'interno di un singolo frame. Il codice fa affidamento su più frame, il che comporta operazioni non necessarie e determina, all'utente, uno spostamento dell'UI sullo schermo.

Analizziamo ciascun frame per capire 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). Poi, segue la fase del layout e viene chiamato il callback per il modificatore onSizeChanged. Questo è il momento in cui imageHeightPx viene aggiornato all'altezza effettiva dell'immagine. Scrivi pianifica la ricomposizione per il frame successivo. In fase di disegno, il testo viene visualizzato con una spaziatura interna pari a 0 poiché la modifica del valore non è ancora stata riportata.

Scrivi quindi avvia il secondo frame pianificato dalla modifica del valore di imageHeightPx. Lo stato viene letto nel blocco di contenuti di Box e richiamato nella fase di composizione. Questa volta, al testo viene fornita una spaziatura interna corrispondente all'altezza dell'immagine. Nella fase di layout, il codice imposta di nuovo il valore di imageHeightPx, ma non viene pianificata alcuna ricomposizione poiché il valore rimane lo stesso.

Alla fine si ottiene la spaziatura interna desiderata nel testo, ma non è consigliabile impiegare un frame in più per riportare il valore a una fase diversa e si ottiene la produzione di un frame con contenuti in sovrapposizione.

Questo esempio può sembrare artificioso, ma fai attenzione a questo schema 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)
  • Potenziale ripetizione

La correzione per l'esempio riportato sopra è l'utilizzo delle 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 richiede la scrittura di un layout personalizzato. Per ulteriori informazioni, consulta la guida Layout personalizzati.

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