Pensare in Compose

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

Il paradigma di programmazione dichiarativa

Storicamente, una gerarchia di visualizzazione Android è stata rappresentabile come un albero di widget UI. Man mano che lo stato dell'app cambia a causa di fattori come le interazioni degli utenti, la gerarchia della UI deve essere aggiornata per visualizzare i dati attuali. Il modo più comune per aggiornare la 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 visualizzato in più posizioni, potresti dimenticarti di aggiornare una delle visualizzazioni che lo mostrano. Ciò può anche portare a stati illegali, quando due aggiornamenti sono in conflitto in modo inatteso. Ad esempio, un aggiornamento potrebbe tentare di impostare un valore di un nodo appena rimosso dalla 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 di UI dichiarativa. 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 visualizzazione 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 della UI devono essere ridisegnate in un determinato momento. Ciò ha alcune implicazioni sul modo in cui progetti i componenti dell'interfaccia utente, come descritto in Ricompilazione.

Un esempio di funzione componibile

Utilizzando Compose, puoi creare la tua interfaccia utente definendo un insieme di funzioni componibili che accettano dati ed emettono elementi UI. Un esempio è un widget Greeting, che accetta un String ed emette un widget Text che mostra 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 visualizzare un widget di testo sullo schermo.

Alcuni aspetti degni di nota di questa funzione:

  • Annotazione:la funzione è annotata con l'annotazione @Composable. Tutte le funzioni componibili devono avere questa annotazione. Questa annotazione comunica al compilatore Compose che questa funzione ha lo scopo di 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 la UI. In questo caso, il nostro widget accetta un String, in modo da poter salutare l'utente per nome.
  • Visualizzazione UI:la funzione visualizza il testo nell'interfaccia utente. A questo scopo, chiama la funzione componibile Text(), che crea effettivamente l'elemento dell'interfaccia utente di testo. Le funzioni componibili emettono la gerarchia dell'interfaccia utente chiamando altre funzioni componibili.
  • Nessun valore restituito:la funzione non restituisce nulla. Le funzioni di Compose che emettono 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'interfaccia utente 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 illustrati in Ricompilazione.

Il cambiamento di paradigma dichiarativo

Con molti toolkit UI orientati agli oggetti imperativi, inizializzi la UI istanziando un albero di widget. Spesso, questa operazione viene eseguita tramite l'inflazione di 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 vengono esposti come oggetti. Aggiorni la 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 delle app. Poi, i tuoi composable sono responsabili della trasformazione dello stato attuale dell'applicazione in un'interfaccia utente ogni volta che i dati osservabili vengono aggiornati.

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

Quando l'utente interagisce con la UI, questa 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 dell'interfaccia utente 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 richiamate automaticamente 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'interfaccia utente 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 particolare elemento dell'interfaccia utente. Puoi utilizzare i loop. Puoi chiamare le funzioni helper. Hai la piena flessibilità del linguaggio sottostante. Questa potenza e flessibilità sono uno dei vantaggi principali di Jetpack Compose.

Ricompilazione

In un modello di 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 mostra un pulsante:

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

Ogni volta che viene fatto 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, ricomporre l'intero albero della UI può essere costoso dal punto di vista computazionale, il che consuma potenza di calcolo e 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 a nuovi input, chiama solo le funzioni o le espressioni lambda che potrebbero essere cambiate e ignora il resto. Saltando funzioni o espressioni lambda con parametri invariati, Compose esegue la ricomposizione in modo efficiente.

Non fare mai affidamento sugli effetti collaterali dell'esecuzione di funzioni componibili, poiché la ricomposizione di una funzione potrebbe essere ignorata. In questo caso, 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 osservabile in ViewModel
  • Aggiornamento delle preferenze condivise

Le funzioni componibili potrebbero essere eseguite nuovamente con una frequenza di un frame, ad esempio durante il rendering di un'animazione. Le funzioni componibili devono essere veloci per evitare problemi durante le animazioni. Se devi eseguire operazioni costose, come la lettura dalle preferenze condivise, esegui l'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. 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 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 Scrivi:

  • La ricomposizione salta il maggior numero possibile di funzioni componibili e lambda.
  • La ricomposizione è ottimistica e potrebbe essere annullata.
  • Una funzione componibile potrebbe essere eseguita abbastanza spesso, anche 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 spiegheranno 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 alcune parti della UI non sono valide, Compose fa del suo meglio per ricomporre solo le parti che devono essere aggiornate. Ciò significa che potrebbe saltare per eseguire nuovamente un singolo Button's composable senza eseguire nessuno dei composable superiori o inferiori nell'albero dell'interfaccia utente.

Ogni funzione componibile e lambda potrebbe ricomporsi da sola. Il seguente esempio 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) }))
}

Ciascuno di questi ambiti potrebbe essere l'unica cosa da eseguire durante una ricomposizione. Compose potrebbe passare alla lambda Column senza eseguire nessuno dei relativi genitori quando header cambia. Quando esegue Column, Compose potrebbe scegliere di ignorare gli elementi di LazyColumn se names non è cambiato.

Anche in questo caso, tutte le funzioni componibili o le espressioni lambda non devono avere 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 composable 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 termine della ricomposizione, Compose potrebbe annullare la ricomposizione e riavviarla con il nuovo parametro.

Quando la ricomposizione viene annullata, Compose scarta l'albero della UI dalla ricomposizione. Se hai effetti collaterali che dipendono dalla visualizzazione dell'interfaccia utente, 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 componibili e le espressioni lambda siano idempotenti e prive di effetti collaterali per gestire la ricomposizione 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 dallo spazio di archiviazione del dispositivo, può causare problemi di prestazioni dell'interfaccia utente.

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

Se una funzione componibile ha bisogno di dati, definisci i parametri per questi dati. Puoi spostare il lavoro dispendioso 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 può sfruttare più core ed eseguire le funzioni componibili non sullo schermo con una priorità inferiore.

Questa ottimizzazione comporterebbe l'esecuzione di una funzione componibile all'interno di un pool di thread in background. Se una funzione componibile chiama una funzione su un ViewModel, Compose potrebbe chiamare quella funzione da più thread contemporaneamente.

Per verificare che l'applicazione si comporti correttamente, tutte le funzioni componibili non devono avere effetti collaterali. Attiva invece gli effetti collaterali dai callback come onClick che vengono sempre eseguiti sul thread UI.

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

Ecco un esempio che mostra un composable 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. 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 fotogramma di un'animazione o di un aggiornamento dell'elenco. In entrambi i casi, l'interfaccia utente mostrerà un conteggio errato. Per questo motivo, scritture come questa non sono supportate in Compose; vietando queste scritture, consentiamo al framework di modificare i thread per eseguire le espressioni 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. ma non è garantito che sia vero. 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 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 a schede:

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

Le chiamate a StartScreen, MiddleScreen e EndScreen potrebbero avvenire in qualsiasi ordine. Ciò significa che non puoi, ad esempio, fare in modo che StartScreen() imposti una variabile globale (un effetto collaterale) e che MiddleScreen() sfrutti questa modifica. Al contrario, ciascuna 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