Pensare in Compose

Jetpack Compose è un toolkit UI dichiarativo moderno per Android. Compose semplifica la scrittura e la manutenzione dell'UI dell'app fornendo un'API dichiarativa che ti consente di eseguire il rendering dell'UI dell'app senza modificare in modo imperativo le visualizzazioni del frontend. Questa terminologia richiede alcune spiegazioni, ma le implicazioni sono importanti per la progettazione dell'app.

Il paradigma di programmazione dichiarativa

Storicamente, una gerarchia di oggetti View Android è rappresentabile come un albero di widget UI. Man mano che lo stato dell'app cambia a causa di elementi come le interazioni dell'utente, la gerarchia dell'UI deve essere aggiornata per visualizzare i dati attuali. Il modo più comune per aggiornare l'UI è attraversare l'albero utilizzando funzioni come findViewById() e modificare i nodi chiamando metodi come button.setText(String), container.addChild(View), o img.setImageBitmap(Bitmap). Questi metodi modificano lo stato interno del widget.

La manipolazione manuale delle visualizzazioni aumenta la probabilità di errori. Se un dato viene sottoposto a rendering in più posizioni, potresti dimenticare di aggiornare una delle visualizzazioni che lo mostra. Ciò può anche portare a stati illegali, quando due aggiornamenti entrano in conflitto in modo imprevisto. Ad esempio, un aggiornamento potrebbe tentare di impostare un valore di un nodo appena rimosso dall'UI. In generale, la complessità della manutenzione del software aumenta con il numero di visualizzazioni che richiedono l'aggiornamento.

Negli ultimi anni, l'intero settore ha iniziato a passare a un modello UI dichiarativo. Questo modello semplifica l'ingegneria associata alla creazione e all'aggiornamento delle interfacce utente. La tecnica funziona rigenerando concettualmente l'intero schermo da zero, quindi applicando solo le modifiche necessarie. Questo approccio evita la complessità dell'aggiornamento manuale di una gerarchia di oggetti View con stato. Compose è un framework UI dichiarativo.

Una delle sfide della rigenerazione dell'intero schermo è che è potenzialmente costosa in termini di tempo, potenza di calcolo e utilizzo della batteria. Per ridurre questo costo, Compose sceglie in modo intelligente quali parti dell'UI devono essere ridisegnate in un determinato momento. Questo ha alcune implicazioni sul modo in cui progetti i componenti dell'UI, come descritto in Ricomposizione.

Esempio di funzione componibile

Utilizzando Compose, puoi creare l'interfaccia utente definendo un insieme di funzioni componibili che accettano dati ed emettono elementi UI. Un esempio è un widget Greeting, che accetta una String ed emette un widget Text che visualizza un messaggio di saluto.

Uno smartphone che mostra il testo Hello World e il codice della funzione componibile che genera questa
  UI.
Figura 1. Una funzione componibile a cui vengono passati i dati e che li utilizza per eseguire il rendering di un widget di testo sullo schermo.

Alcune cose degne di nota su questa funzione:

  • Annotazione: la funzione è annotata con l'annotazione @Composable. Tutte le funzioni componibili devono avere questa annotazione. Questa annotazione informa il compilatore Compose che questa funzione è destinata a convertire i dati in UI.
  • Input di dati: la funzione accetta i dati. Le funzioni componibili possono accettare parametri, che consentono alla logica dell'app di descrivere l'UI. In questo caso, il nostro widget accetta una String in modo che possa salutare l'utente per nome.
  • Visualizzazione dell'UI: la funzione visualizza il testo nell'UI. Per farlo, chiama la funzione componibile Text(), che crea effettivamente l'elemento UI di testo. Le funzioni componibili emettono la gerarchia dell'UI chiamando altre funzioni componibili.
  • Nessun valore restituito: la funzione non restituisce nulla. Le funzioni Compose che emettono l'UI non devono restituire nulla, perché descrivono lo stato dello schermo di destinazione anziché costruire widget UI.
  • Proprietà: questa funzione è veloce, idempotente e priva di effetti collaterali.

    • La funzione si comporta allo stesso modo quando viene chiamata più volte con lo stesso argomento e non utilizza altri valori come variabili globali o chiamate a random().
    • La funzione descrive l'UI senza effetti collaterali, ad esempio la modifica di proprietà o variabili globali.

    In generale, tutte le funzioni componibili devono essere scritte con queste proprietà, per i motivi descritti in Ricomposizione.

Il cambio di paradigma dichiarativo

Con molti toolkit UI imperativi orientati agli oggetti, inizializzi l'UI istanziando un albero di widget. Spesso lo fai espandendo un file di layout XML. Ogni widget mantiene il proprio stato interno ed espone metodi getter e setter che consentono alla logica dell'app di interagire con il widget.

Nell'approccio dichiarativo di Compose, i widget sono relativamente senza stato e non espongono funzioni setter o getter. Infatti, i widget non sono esposti come oggetti. Aggiorni l'UI chiamando la stessa funzione componibile con argomenti diversi. In questo modo è più semplice fornire lo stato a pattern architetturali come un ViewModel, come descritto nella Guida all'architettura dell'app. Quindi, i tuoi elementi componibili sono responsabili della trasformazione dello stato attuale dell'applicazione in un'UI ogni volta che i dati osservabili vengono aggiornati.

Illustrazione del flusso di dati in un'interfaccia utente Compose, dagli oggetti di livello superiore a quelli secondari.
Figura 2. La logica dell'app fornisce i dati alla funzione componibile di primo livello. Questa funzione utilizza i dati per descrivere l'UI chiamando altri elementi componibili e passa i dati appropriati a questi elementi componibili e così via nella gerarchia.

Quando l'utente interagisce con l'UI, l'UI genera eventi come onClick. Questi eventi devono notificare la logica dell'app, che può quindi modificare lo stato dell'app. Quando lo stato cambia, le funzioni componibili vengono chiamate di nuovo con i nuovi dati. In questo modo, gli elementi UI vengono ridisegnati. Questo processo è chiamato ricomposizione.

Illustrazione di come gli elementi UI rispondono all'interazione, attivando eventi gestiti dalla logica dell'app.
Figura 3. L'utente ha interagito con un elemento UI, causando l'attivazione di un evento. La logica dell'app risponde all'evento, quindi le funzioni componibili vengono chiamate automaticamente di nuovo con nuovi parametri, se necessario.

Contenuti dinamici

Poiché le funzioni componibili sono scritte in Kotlin anziché in XML, possono essere dinamiche come qualsiasi altro codice Kotlin. Ad esempio, supponiamo di voler creare un'UI che saluti un elenco di utenti:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

Questa funzione accetta un elenco di nomi e genera un saluto per ogni utente. Le funzioni componibili possono essere piuttosto sofisticate. Puoi utilizzare le istruzioni if per decidere se mostrare un determinato elemento UI. Puoi utilizzare i loop. Puoi chiamare le funzioni di assistenza. Hai la piena flessibilità del linguaggio sottostante. Questa potenza e flessibilità sono uno dei vantaggi principali di Jetpack Compose.

Ricomposizione

In un modello UI imperativo, per modificare un widget, chiami un setter sul widget per modificarne lo stato interno. In Compose, chiami di nuovo la funzione componibile con nuovi dati. In questo modo, la funzione viene ricomposta: i widget emessi dalla funzione vengono ridisegnati, se necessario, con nuovi dati. Il framework Compose può ricomporre in modo intelligente solo i componenti modificati.

Ad esempio, considera questa funzione componibile che visualizza un pulsante:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

Ogni volta che si fa clic sul pulsante, il chiamante aggiorna il valore di clicks. Compose chiama di nuovo la lambda con la funzione Text per mostrare il nuovo valore. Questo processo è chiamato ricomposizione. Le altre funzioni che non dipendono dal valore non vengono ricomposte.

Come abbiamo detto, la ricomposizione dell'intero albero dell'UI può essere costosa dal punto di vista computazionale, in quanto utilizza potenza di calcolo e durata della batteria. Compose risolve questo problema con questa ricomposizione intelligente.

La ricomposizione è il processo di richiamo delle funzioni componibili quando gli input cambiano. Quando Compose ricompone in base ai nuovi input, chiama solo le funzioni o le lambda che potrebbero essere cambiate e salta il resto. Saltando le funzioni o le lambda con parametri invariati, Compose ricompone in modo efficiente.

Non fare mai affidamento sugli effetti collaterali dell'esecuzione delle funzioni componibili, poiché la ricomposizione di una funzione potrebbe essere saltata. In caso contrario, gli utenti potrebbero riscontrare un comportamento strano e imprevedibile nella tua app. Un effetto collaterale è qualsiasi modifica visibile al resto dell'app. Ad esempio, queste azioni sono tutti effetti collaterali pericolosi:

  • Scrittura in una proprietà di un oggetto condiviso
  • Aggiornamento di un elemento osservabile in ViewModel
  • Aggiornamento delle preferenze condivise

Le funzioni componibili potrebbero essere rieseguite con una frequenza massima di ogni frame, ad esempio durante il rendering di un'animazione. Le funzioni componibili devono essere veloci per evitare il jank durante le animazioni. Se devi eseguire operazioni costose, come la lettura dalle preferenze condivise, esegui questa operazione in una coroutine in background e passa il valore risultante alla funzione componibile come parametro.

Ad esempio, questo codice crea un elemento componibile per aggiornare un valore in SharedPreferences. L'elemento componibile non deve leggere o scrivere dalle preferenze condivise. Al contrario, questo codice sposta la lettura e la scrittura in un ViewModel in una coroutine in background. La logica dell'app passa il valore corrente con un callback per attivare un aggiornamento.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

Questo documento illustra una serie di aspetti da tenere presenti quando utilizzi Compose:

  • La ricomposizione salta il maggior numero possibile di funzioni e lambda componibili.
  • La ricomposizione è ottimistica e può essere annullata.
  • Una funzione componibile potrebbe essere eseguita abbastanza spesso, fino a ogni frame di un'animazione.
  • Le funzioni componibili possono essere eseguite in parallelo.
  • Le funzioni componibili possono essere eseguite in qualsiasi ordine.

Le sezioni seguenti illustrano come creare funzioni componibili per supportare la ricomposizione. In ogni caso, la best practice consiste nel mantenere le funzioni componibili veloci, idempotenti e senza effetti collaterali.

La ricomposizione salta il più possibile

Quando le parti dell'UI non sono valide, Compose fa del suo meglio per ricomporre solo le parti che devono essere aggiornate. Ciò significa che potrebbe saltare la riesecuzione dell'elemento componibile di un singolo Button senza eseguire nessuno degli elementi componibili più in alto o più in basso nell'albero dell'UI.

Ogni funzione e lambda componibile potrebbe essere ricomposta da sola. L'esempio seguente mostra come la ricomposizione può saltare alcuni elementi durante il rendering di un elenco:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        HorizontalDivider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Ognuno di questi ambiti potrebbe essere l'unica cosa da eseguire durante una ricomposizione. Compose potrebbe saltare la lambda Column senza eseguire nessuno dei relativi genitori quando header cambia. E durante l'esecuzione di Column, Compose potrebbe scegliere di saltare gli elementi di LazyColumn's se names non è cambiato.

Anche in questo caso, tutte le funzioni o lambda componibili devono essere prive di effetti collaterali. Quando devi eseguire un effetto collaterale, attivalo da un callback.

La ricomposizione è ottimistica

La ricomposizione inizia ogni volta che Compose ritiene che i parametri di un elemento componibile potrebbero essere cambiati. La ricomposizione è ottimistica,il che significa che Compose prevede di completare la ricomposizione prima che i parametri cambino di nuovo. Se un parametro cambia prima del completamento della ricomposizione, Compose potrebbe annullare la ricomposizione e riavviarla con il nuovo parametro.

Quando la ricomposizione viene annullata, Compose scarta l'albero dell'UI dalla ricomposizione. Se hai effetti collaterali che dipendono dalla visualizzazione dell'UI, l'effetto collaterale verrà applicato anche se la composizione viene annullata. Ciò può portare a uno stato dell'app incoerente.

Verifica che tutte le funzioni e le lambda componibili siano idempotenti e prive di effetti collaterali per gestire la ricomposizione ottimistica.

Le funzioni componibili potrebbero essere eseguite abbastanza spesso

In alcuni casi, una funzione componibile potrebbe essere eseguita per ogni frame di un'animazione dell'UI. Se la funzione esegue operazioni costose, come la lettura dallo spazio sul dispositivo, può causare jank dell'UI.

Ad esempio, se il widget tentasse di leggere le impostazioni del dispositivo, potrebbe potenzialmente leggere queste impostazioni centinaia di volte al secondo, con effetti disastrosi sulle prestazioni dell'app.

Se una funzione componibile richiede dati, definisci i parametri per questi dati. Puoi quindi spostare il lavoro costoso in un altro thread, al di fuori della composizione, e passare il valore risultante alla funzione componibile come parametro utilizzando mutableStateOf o LiveData.

Le funzioni componibili potrebbero essere eseguite in parallelo

Compose potrebbe ottimizzare la ricomposizione eseguendo le funzioni componibili in parallelo. In questo modo, Compose potrebbe sfruttare più core ed eseguire le funzioni componibili non sullo schermo con una priorità inferiore.

Questa ottimizzazione significherebbe che una funzione componibile potrebbe essere eseguita all'interno di un pool di thread in background. Se una funzione componibile chiama una funzione su un ViewModel, Compose potrebbe chiamare questa funzione da più thread contemporaneamente.

Per verificare che l'applicazione si comporti correttamente, tutte le funzioni componibili non devono avere effetti collaterali. Al contrario, attiva gli effetti collaterali da callback come onClick che vengono sempre eseguiti sul thread dell'interfaccia utente.

Quando viene chiamata una funzione componibile, la chiamata potrebbe avvenire su un thread diverso dal chiamante. Ciò significa che il codice che modifica le variabili in una lambda componibile deve essere evitato, sia perché questo codice non è thread-safe sia perché è un effetto collaterale non consentito della lambda componibile.

Ecco un esempio che mostra un elemento componibile che visualizza un elenco e il relativo conteggio:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

Questo codice è privo di effetti collaterali e trasforma l'elenco di input in UI. Questo è un ottimo codice per visualizzare un piccolo elenco. Tuttavia, se la funzione scrive in una variabile locale, questo codice non sarà thread-safe o corretto:

@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

In questo esempio, items viene modificato a ogni ricomposizione. Potrebbe trattarsi di ogni frame di un'animazione o quando l'elenco viene aggiornato. In ogni caso, l'UI visualizzerà il conteggio errato. Per questo motivo, le scritture di questo tipo non sono supportate in Compose. Proibendo queste scritture, consentiamo al framework di cambiare i thread per eseguire le lambda componibili.

Le funzioni componibili possono essere eseguite in qualsiasi ordine

Se esamini il codice di una funzione componibile, potresti presumere che il codice venga eseguito nell'ordine in cui viene visualizzato. Tuttavia, non è garantito che sia così. Se una funzione componibile contiene chiamate ad altre funzioni componibili, queste funzioni potrebbero essere eseguite in qualsiasi ordine. Compose ha la possibilità di riconoscere che alcuni elementi UI hanno una priorità più alta rispetto ad altri e di disegnarli per primi.

Ad esempio, supponiamo di avere un codice simile per disegnare tre schermate in un layout a schede:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

Le chiamate a StartScreen, MiddleScreen e EndScreen potrebbero avvenire in qualsiasi ordine. Ciò significa che, ad esempio, non puoi fare in modo che StartScreen() imposti una variabile globale (un effetto collaterale) e che MiddleScreen() sfrutti questa modifica. Al contrario, ognuna di queste funzioni deve essere autonoma.

Scopri di più

Per scoprire di più su come pensare in Compose e sulle funzioni componibili, consulta le seguenti risorse aggiuntive.

Video