Pensare in Compose

Jetpack Compose è un moderno toolkit che dichiara l'UI dichiarativa per Android. Compose semplifica la scrittura e la gestione dell'interfaccia utente dell'app fornendo un'API dichiarativa che consente di eseguire il rendering dell'interfaccia utente dell'app senza modificare imperativamente le visualizzazioni del frontend. Questa terminologia richiede alcune spiegazioni, ma le implicazioni sono importanti per la progettazione della tua app.

Il paradigma della programmazione dichiarativa

Storicamente, una gerarchia di visualizzazioni Android è rappresentabile come una struttura di widget dell'interfaccia utente. Poiché lo stato dell'app cambia a causa di fattori come le interazioni degli utenti, la gerarchia dell'interfaccia utente deve essere aggiornata per visualizzare i dati attuali. Il modo più comune di aggiornare l'UI è eseguire un'esplorazione ad albero utilizzando funzioni come findViewById() e cambiare i nodi richiamando 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à che si verifichino errori. Se un dato viene visualizzato in più punti, è facile dimenticarsi di aggiornare una delle visualizzazioni che lo mostra. Inoltre, è facile creare stati illegali, quando due aggiornamenti sono in conflitto in modo inaspettato. Ad esempio, un aggiornamento potrebbe cercare di impostare un valore per un nodo appena rimosso dalla UI. In generale, la complessità di manutenzione del software cresce con il numero di visualizzazioni che richiedono un aggiornamento.

Negli 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, applicando solo le modifiche necessarie. Questo approccio evita la complessità dell'aggiornamento manuale di una gerarchia di viste stateful. Compose è un framework dichiarativo dell'interfaccia utente.

La rigenerazione dell'intero schermo rappresenta un problema potenzialmente costoso in termini di tempo, potenza di calcolo e utilizzo della batteria. Per mitigare questo costo, Compose sceglie in modo intelligente quali parti dell'interfaccia utente devono essere ridisegnate in qualsiasi momento. Ciò ha alcune implicazioni sulla progettazione dei componenti dell'interfaccia utente, come spiegato nella sezione Ricomposizione.

Una funzione componibile semplice

Con Compose, puoi creare la tua interfaccia utente definendo un insieme di funzioni componibili che acquisiscono dati ed emettono elementi UI. Un esempio semplice è un widget Greeting, che prende un String ed emette un widget Text che mostra un messaggio di saluto.

Uno screenshot di un telefono che mostra il testo

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

Alcuni aspetti degni di nota di questa funzione:

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

  • La funzione acquisisce dati. Le funzioni componibili accettano parametri che permettono alla logica dell'app di descrivere l'interfaccia utente. In questo caso, il nostro widget accetta un String e può salutare l'utente per nome.

  • La funzione visualizza del testo nell'interfaccia utente. Per farlo, chiama la funzione componibile Text(), che crea effettivamente l'elemento testuale UI. Le funzioni componibili emettono una gerarchia dell'interfaccia utente chiamando altre funzioni componibili.

  • La funzione non restituisce nulla. Le funzioni di scrittura che emettono UI non devono restituire nulla perché descrivono lo stato desiderato della schermata anziché creare widget UI.

  • 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, ad esempio variabili globali o chiamate a random().
    • La funzione descrive l'interfaccia utente senza effetti collaterali, come la modifica delle proprietà o delle variabili globali.

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

Il cambio di paradigma dichiarativo

Con molti toolkit imperativi dell'interfaccia utente orientati agli oggetti, è possibile inizializzare l'interfaccia utente creando una struttura di widget. Spesso puoi farlo 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 l'interfaccia utente chiamando la stessa funzione componibile con argomenti diversi. In questo modo è facile fornire stato ai pattern architetturali quali un ViewModel, come descritto nella Guida all'architettura delle app. Quindi, i componenti componibili sono responsabili della trasformazione dell'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 dati alla funzione componibile di primo livello. Questa funzione utilizza i dati per descrivere l'interfaccia utente richiamando altri componibili e trasmette i dati appropriati a questi elementi e ai livelli inferiori della gerarchia.

Quando l'utente interagisce con l'interfaccia utente, quest'ultima genera eventi come onClick. Questi eventi dovrebbero inviare una notifica alla logica dell'app, che può quindi modificarne lo stato. Quando lo stato cambia, le funzioni componibili vengono richiamate di nuovo con i nuovi dati. Questo fa sì che gli elementi dell'interfaccia utente vengano ridisegnati, questo processo è chiamato ricomposizione.

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

Figura 3. L'utente ha interagito con un elemento UI, attivando 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, supponi di voler creare una UI che accolga un elenco di utenti:

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

Questa funzione prende 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 vuoi mostrare un particolare elemento dell'interfaccia utente. Puoi usare i loop. Puoi chiamare le funzioni helper. Hai tutta la flessibilità della lingua di base. Questa potenza e flessibilità sono uno dei vantaggi principali di Jetpack Compose.

Ricomposizione

In un modello di UI imperativo, per modificare un widget, devi chiamare un setter sul widget per modificarne lo stato interno. In Compose, richiami 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 di Compose può ricomporre in modo intelligente solo i componenti che sono stati 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. Scrivi chiama di nuovo il 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 visto, la ricomposizione dell'intero albero dell'interfaccia utente può richiedere molte risorse di calcolo, in quanto comporta l'utilizzo di potenza di calcolo e durata della batteria. Compose risolve il problema con questa ricomposizione intelligente.

La ricomposizione è il processo di richiamata di nuovo le funzioni componibili quando gli input cambiano. Questo accade quando gli input della funzione cambiano. Quando Compose si ricompone in base ai nuovi input, chiama solo le funzioni o i lambda che potrebbero essere cambiati e ignora il resto. Ignorando tutte le funzioni o le funzioni lambda che non hanno parametri modificati, Compose può ricomporsi in modo efficiente.

Non dipendere mai dagli effetti collaterali dell'esecuzione delle funzioni componibili, poiché la ricomposizione 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 elemento osservabile in ViewModel
  • Aggiornamento delle preferenze condivise

Le funzioni componibili possono essere rieseguite con la frequenza 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, eseguilo in una coroutine in background e passa il risultato del valore alla funzione componibile come parametro.

Ad esempio, questo codice crea un componibile per aggiornare un valore in SharedPreferences. L'elemento componibile non deve leggere o scrivere a partire da preferenze condivise. Questo codice sposta invece la lettura e la scrittura in un ViewModel in una coroutine in background. La logica dell'app trasmette 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:

  • Le funzioni componibili possono essere eseguite in qualsiasi ordine.
  • Le funzioni componibili possono essere eseguite in parallelo.
  • 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 spesso, la stessa frequenza di ogni frame di un'animazione.

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

Le funzioni componibili possono essere eseguite in qualsiasi ordine

Se esamini il codice di una funzione componibile, puoi presumere che il codice venga eseguito nell'ordine in cui appare. Ma questo non è necessariamente vero. Se una funzione componibile contiene chiamate ad altre funzioni componibili, queste potrebbero essere eseguite in qualsiasi ordine. Compose ha la possibilità di riconoscere alcuni elementi UI con priorità maggiore di altri e di disegnarli per primi.

Ad esempio, supponi di avere un codice simile al seguente 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. Questo significa, ad esempio, che StartScreen() non può impostare una variabile globale (un effetto collaterale) e consentire a MiddleScreen() di sfruttare questa modifica. Ognuna di queste funzioni deve invece essere indipendente.

Le funzioni componibili possono essere eseguite in parallelo

Scrittura può ottimizzare la ricomposizione eseguendo funzioni componibili in parallelo. Questo consente a Compose di sfruttare più core e di eseguire funzioni componibili non sullo schermo con una priorità inferiore.

Questa ottimizzazione significa 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 richiamare la funzione da più thread contemporaneamente.

Per assicurarti che l'applicazione funzioni correttamente, tutte le funzioni componibili non dovrebbero avere effetti collaterali. Attiva invece gli effetti collaterali da callback come onClick, che vengono sempre eseguiti nel thread della UI.

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

Ecco un esempio di un componibile in cui sono visualizzati 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 per i thread:

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

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

In questo esempio, items viene modificato a ogni ricomposizione. ad esempio ogni fotogramma di un'animazione o l'aggiornamento dell'elenco. In ogni caso, l'interfaccia utente mostrerà il conteggio sbagliato. Per questo motivo, le scritture come questa non sono supportate in Scrivi. Se proibisci queste scritture, consentiamo al framework di modificare i thread per eseguire lambda componibili.

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 può saltare il passaggio per eseguire nuovamente l'elemento componibile di un singolo Button senza eseguire nessuno dei componibili sopra o sotto il componibile nella struttura ad albero dell'interfaccia utente.

Ogni funzione componibile e lambda potrebbero ricomporsi da sole. Ecco un esempio che 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)
        Divider()

        // 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. La scrittura potrebbe passare al lambda Column senza eseguire nessuno dei suoi elementi padre quando cambia il valore header. 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 componibili o lambda non dovrebbe avere effetti collaterali. Quando devi eseguire un effetto collaterale, attivalo con un callback.

La ricomposizione è ottimista

La ricomposizione inizia ogni volta che Compose ritiene che i parametri di un componibile possano essere stati modificati. La ricomposizione è ottimistica,il che significa che Compose prevede di completare la ricomposizione prima che i parametri vengano modificati di nuovo. Se un parametro cambia prima che la ricomposizione finisca, Compose potrebbe annullare la ricomposizione e riavviarla con il nuovo parametro.

Quando la ricomposizione viene annullata, Compose ignora la struttura dell'interfaccia utente dalla ricomposizione. Se sono presenti effetti collaterali che dipendono dalla UI visualizzata, l'effetto collaterale verrà applicato anche se la composizione viene annullata. Ciò può causare uno stato dell'app incoerente.

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

Le funzioni componibili potrebbero essere eseguite abbastanza spesso

In alcuni casi, può essere eseguita una funzione componibile 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 jank dell'interfaccia utente.

Ad esempio, se il widget provasse a leggere le impostazioni del dispositivo, potrebbe leggerle 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 costoso in un altro thread, al di fuori della composizione, e passare i dati a Compose utilizzando mutableStateOf o LiveData.

Scopri di più

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

Video