Pensare in Compose

Jetpack Compose è un moderno toolkit per l'interfaccia utente dichiarativa per Android. Compose semplifica la scrittura e la manutenzione dell'interfaccia utente dell'app fornendo un'API dichiarativa che consente di eseguire il rendering dell'interfaccia utente dell'app senza modificare in modo imperativo le viste frontend. Questa terminologia richiede una spiegazione, ma le implicazioni sono importanti per il design dell'app.

Il paradigma della programmazione dichiarativa

In passato, una gerarchia delle visualizzazioni Android era rappresentabile come una struttura ad albero di widget UI. Poiché lo stato dell'app cambia a causa di elementi come le interazioni degli utenti, la gerarchia dell'interfaccia utente deve essere aggiornata per visualizzare i dati correnti. Il modo più comune per aggiornare la UI è esplorare l'albero utilizzando funzioni come findViewById() e cambiare 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 visualizzato in più posizioni, è facile dimenticare di aggiornare una delle visualizzazioni che lo mostrano. È inoltre facile creare 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'interfaccia utente. In generale, la complessità della manutenzione del software aumenta con il numero di visualizzazioni che richiedono l'aggiornamento.

Nel corso degli ultimi anni, l'intero settore ha iniziato a passare a un modello di UI dichiarativo, che semplifica notevolmente 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 visualizzazioni con stato. Compose è un framework di UI dichiarativo.

Un problema della rigenerazione dell'intero schermo è che potrebbe essere costoso in termini di tempo, potenza di calcolo e utilizzo della batteria. Per mitigare questo costo, Compose sceglie in modo intelligente quali parti della UI devono essere ridisegnate in un determinato momento. Ciò ha alcune implicazioni per il modo in cui progetti i componenti dell'interfaccia utente, come discusso in Ricomposizione.

Una funzione componibile semplice

Con Compose puoi creare l'interfaccia utente definendo un insieme di funzioni composibili che acquisiscono dati ed emettono elementi dell'interfaccia utente. Un semplice esempio è un widget Greeting che riceve un String ed emette un widget Text che mostra un messaggio di saluto.

Uno screenshot di uno smartphone che mostra il testo "Hello World" e il codice della semplice funzione Composable che genera l'interfaccia utente

Figura 1. Una semplice funzione componibile che passa dati e li utilizza per visualizzare un widget di testo sullo schermo.

Ecco alcuni aspetti interessanti di questa funzione:

  • La funzione è annotata con l'annotazione @Composable. Tutte le funzioni Composable devono avere questa annotazione, che informa il compilatore di Compose che questa funzione è destinata a convertire i dati in UI.

  • La funzione riceve i dati. Le funzioni componibili accettano parametri che consentono alla logica dell'app di descrivere l'UI. In questo caso, il nostro widget accetta un String per poter salutare l'utente per nome.

  • La funzione mostra il testo nell'interfaccia utente. Per farlo viene chiamato la funzione componibile Text(), che crea effettivamente l'elemento UI testuale. Le funzioni composable emettono la gerarchia dell'interfaccia utente chiamando altre funzioni composable.

  • La funzione non restituisce nulla. Le funzioni di composizione che emettono interfacce utente non devono necessariamente restituire nulla, perché descrivono lo stato dello schermo desiderato anziché creare widget dell'interfaccia utente.

  • 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'interfaccia utente senza effetti collaterali, come 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 cambiamento di paradigma dichiarativo

Con molti kit di strumenti per l'interfaccia utente orientati agli oggetti imperativi, l'interfaccia utente viene inizializzata mediante l'inizializzazione di una struttura ad albero di widget. Spesso lo fai gonfiando 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 stateless e non espongono funzioni setter o getter. Infatti, i widget non sono esposti come oggetti. Puoi aggiornare la UI chiamando la stessa funzione componibile con argomenti diversi. In questo modo è facile fornire lo stato a pattern di architettura come un ViewModel, come descritto nella Guida all'architettura delle app. Quindi, i componibili sono responsabili di trasformare l'attuale stato dell'applicazione in una UI ogni volta che i dati osservabili vengono aggiornati.

Illustrazione del flusso di dati in una UI di Compose, dagli oggetti di alto livello fino ai relativi elementi secondari.

Figura 2. La logica dell'app fornisce i dati alla funzione composable di primo livello. Questa funzione utilizza i dati per descrivere l'interfaccia utente chiamando altri composabili e passando i dati appropriati a questi composabili e così via lungo la gerarchia.

Quando l'utente interagisce con la UI, quest'ultima 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 composable vengono richiamate di nuovo con i nuovi dati. Questo comporta la rielaborazione degli elementi dell'interfaccia utente e questo processo è chiamato ricomposizione.

Illustrazione di come gli elementi dell'interfaccia utente rispondono all'interazione attivando eventi gestiti dalla logica dell'app.

Figura 3. L'utente ha interagito con un elemento UI, determinando l'attivazione di un evento. La logica dell'app risponde all'evento, quindi le funzioni componibili vengono richiamate automaticamente con nuovi parametri, se necessario.

Contenuti dinamici

Poiché le funzioni composable sono scritte in Kotlin anziché in XML, possono essere tanto dinamiche quanto qualsiasi altro codice Kotlin. Ad esempio, supponiamo che tu voglia creare un'interfaccia utente che saluti un elenco di utenti:

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

Questa funzione riceve 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 usare i loop. Puoi chiamare funzioni di supporto. Hai la piena flessibilità del linguaggio sottostante. Questa potenza e flessibilità è uno dei vantaggi principali di Jetpack Compose.

Ricomposizione

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

Ad esempio, considera questa funzione composable che mostra 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 funzione lambda con la funzione Text per mostrare il nuovo valore. Questo processo è chiamato ricompozione. Le altre funzioni che non dipendono dal valore non vengono ricompoposte.

Come abbiamo già detto, la ricomposizione dell'intero albero della UI può essere costosa dal punto di vista del calcolo, con conseguente consumo di potenza di calcolo e di durata della batteria. Compose risolve questo problema con questa ricompozione intelligente.

La ricompozione è il processo di richiamo delle funzioni composable di nuovo quando cambiano gli input. Questo accade quando cambiano gli input della funzione. Quando Compose si ricomponie in base a nuovi input, chiama solo le funzioni o le lambda che potrebbero essere cambiate e ignora il resto. Ignorando tutte le funzioni o le lambda che non hanno parametri modificati, Compose può eseguire la ricompozione in modo efficiente.

Non fare mai affidamento sugli effetti collaterali dell'esecuzione di funzioni componibili, poiché la ricompozione di una funzione potrebbe essere saltata. In questo caso, gli utenti potrebbero riscontrare comportamenti strani e imprevedibili nella tua app. Un effetto collaterale è qualsiasi modifica visibile al resto dell'app. Ad esempio, queste azioni sono tutte effetti collaterali pericolosi:

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

Le funzioni composable potrebbero essere eseguite di nuovo ogni frame, ad esempio durante il rendering di un'animazione. Le funzioni composable devono essere rapide per evitare esitazioni durante le animazioni. Se devi eseguire operazioni complesse, come la lettura dalle preferenze condivise, eseguile in una coroutine in background e passa il valore risultante alla funzione composable come parametro.

Ad esempio, questo codice crea un composable per aggiornare un valore in SharedPreferences. Il composable non deve leggere o scrivere dalle preferenze condivise. Questo codice sposta invece la lettura e la scrittura in un ViewModel in una coroutine di 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 Componi:

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

Le sezioni seguenti illustrano come creare funzioni componibili per supportare la ricompozione. In ogni caso, la best practice è mantenere le funzioni composable rapide, idempotenti e senza effetti collaterali.

La ricomposizione salta il più possibile

Quando parti dell'interfaccia utente non sono valide, Compose fa del suo meglio per ricomporre solo le parti che devono essere aggiornate. Ciò significa che può passare alla riesecuzione del componente componibile di un singolo Button senza eseguire nessuno degli elementi componibili sopra o sotto nella struttura dell'interfaccia utente.

Ogni funzione componibile e lambda possono ricomporsi da sola. Ecco un esempio che mostra come la ricompozione 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 passare alla lambda Column senza eseguire nessuno dei relativi elementi principali quando header cambia. Inoltre, durante l'esecuzione di Column, Compose potrebbe scegliere di saltare gli elementi di LazyColumn se names non è cambiato.

Anche in questo caso, l'esecuzione di tutte le funzioni composable o lambda non deve avere effetti collaterali. Quando devi eseguire un effetto collaterale, attivalo da un callback.

La ricostituzione è ottimistica

La ricompozione inizia ogni volta che Compose ritiene che i parametri di un composable possano essere stati modificati. La ricompozione è ottimistica,il che significa che Compose si aspetta di completare la ricompozione prima che i parametri cambino di nuovo. Se un parametro viene modificato prima del termine della ricomposizone, Compose potrebbe annullarla e riavviarla con il nuovo parametro.

Quando la ricomposizone viene annullata, Compose ignora la struttura ad albero dell'interfaccia utente della ricomposizone. Se sono presenti effetti collaterali che dipendono dalla visualizzazione dell'interfaccia utente, questi verranno applicati anche se la composizione viene annullata. Ciò può portare a uno stato incoerente dell'app.

Assicurati che tutte le funzioni composable e i lambda siano idempotenti e privi di effetti collaterali per gestire la ricompozione ottimistica.

Le funzioni componibili potrebbero essere eseguite con una certa frequenza

In alcuni casi, potrebbe essere eseguita una funzione componibile per ogni frame di un'animazione dell'interfaccia utente. Se la funzione esegue operazioni costose, come la lettura dall'archiviazione del dispositivo, può causare arresti anomali dell'interfaccia utente.

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

Se la funzione componibile ha bisogno di dati, deve definire i parametri per i dati. Puoi quindi spostare il lavoro dispendioso in un altro thread, al di fuori della composizione, e passare i dati a Scrivi utilizzando mutableStateOf o LiveData.

Le funzioni componibili potrebbero essere eseguite in parallelo

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

Questa ottimizzazione significa che una funzione componibile potrebbe essere eseguita in un pool di thread in background. Se una funzione componibile chiama una funzione su un ViewModel, Compose potrebbe chiamare la funzione da più thread contemporaneamente.

Per garantire il corretto funzionamento dell'applicazione, tutte le funzioni composable non devono avere effetti collaterali. Attiva invece gli effetti collaterali dei callback come onClick che vengono eseguiti sempre nel thread dell'interfaccia utente.

Quando viene invocata una funzione composable, l'invocazione potrebbe avvenire su un thread diverso da quello del chiamante. Ciò significa che il codice che modifica le variabili in un lambda componibile deve essere evitato, sia perché tale codice non è sicuro per i thread, sia perché è un effetto collaterale non consentito del lambda componibile.

Di seguito è riportato un esempio di un 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 non ha effetti collaterali e trasforma l'elenco di input in UI. Si tratta di un ottimo codice per visualizzare un piccolo elenco. Tuttavia, se la funzione scrive in una variabile locale, questo codice non sarà corretto o thread-safe:

@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 ricomposizio. ad esempio ogni frame di un'animazione o quando l'elenco viene aggiornato. In entrambi i casi, l'interfaccia utente mostrerà il conteggio errato. Per questo motivo, le scritture come questa non sono supportate in Compose. Proibendo queste scritture, consentiamo al framework di cambiare i thread per eseguire lambda composibili.

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 possono essere eseguite in qualsiasi ordine. Compose può riconoscere la priorità di alcuni elementi dell'interfaccia utente rispetto ad altri e disegnarli per primi.

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

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

Le chiamate a StartScreen, MiddleScreen e EndScreen possono 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() ne tragga vantaggio. Al contrario, ognuna di queste funzioni deve essere autosufficiente.

Scopri di più

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

Video