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 di visualizzazioni Android poteva essere rappresentata come un albero di widget dell'interfaccia utente. 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 l'interfaccia utente è eseguire l'esplorazione dell'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 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.

Negli ultimi anni, l'intero settore ha iniziato a passare a un modello di UI dichiarativa, che semplifica notevolmente la progettazione 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.

Uno dei problemi 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'interfaccia utente 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 semplice funzione componibile

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 composable a cui vengono passati i dati e che 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 composable possono accettare parametri, che consentono alla logica dell'app di descrivere l'interfaccia utente. In questo caso, il nostro widget accetta un String per poter salutare l'utente per nome.

  • La funzione mostra il testo nell'interfaccia utente. A tale scopo, chiama la funzione composable Text(), che crea effettivamente l'elemento dell'interfaccia utente di testo. 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 senza 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 imperativo e orientato agli oggetti, 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 senza stato e non espongono funzioni di impostazione o recupero. Infatti, i widget non sono esposti come oggetti. Puoi aggiornare l'interfaccia utente chiamando la stessa funzione composable 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. I composabili sono quindi responsabili della trasformazione dello stato corrente dell'applicazione in un'interfaccia utente ogni volta che i dati osservabili vengono aggiornati.

Illustrazione del flusso di dati in un'interfaccia utente di Compose, dagli oggetti di alto livello ai figli.

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, poi li passa ai composabili e così via lungo la gerarchia.

Quando l'utente interagisce con l'interfaccia utente, 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. Di conseguenza, gli elementi dell'interfaccia utente vengono ridisegnati. Questo processo è chiamato ricompozione.

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 dell'interfaccia utente, attivando un evento. La logica dell'app risponde all'evento, quindi le funzioni composable vengono richiamate di nuovo 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 dell'interfaccia utente. Puoi utilizzare i cicli. 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 di UI imperativo, per modificare un widget, chiami un set sull'oggetto 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 discusso, la ricompozione dell'intero albero dell'interfaccia utente può essere computazionalmente costosa, poiché utilizza potenza di calcolo e 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. Invece, questo codice sposta le operazioni di lettura e 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 Componi:

  • La ricompozione salta il maggior numero possibile di funzioni e lambda componibili.
  • La ricostituzione è 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 componibili 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.

Salta il più possibile la ricompozione

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 potrebbe saltare l'esecuzione di un singolo composable di Button senza eseguire nessuno dei composabili sopra o sotto di esso nella struttura dell'interfaccia utente.

Ogni funzione componibile e lambda potrebbe 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) }))
}

Ciascuno di questi ambiti potrebbe essere l'unico da eseguire durante una ricostituzione. Compose potrebbe passare alla lambda Column senza eseguire nessuno dei relativi elementi principali quando header cambia. Inoltre, quando esegui Column, Componi 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 cambiati. 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, una funzione componibile potrebbe essere eseguita 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é questo codice non è a prova di thread sia perché rappresenta un effetto collaterale non consentito del lambda componibile.

Ecco un esempio che mostra un composable che mostra 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. Questo è 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 ha la possibilità di riconoscere che alcuni elementi dell'interfaccia utente hanno una priorità maggiore rispetto ad altri e di 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 utilizzare Compose e le funzioni componibili, consulta le seguenti risorse aggiuntive.

Video