Jetpack Compose è un moderno toolkit UI dichiarativo 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 del frontend. Questa terminologia richiede una spiegazione, ma le implicazioni sono importanti per la progettazione della tua app.
Il paradigma di programmazione dichiarativa
In passato, una gerarchia di oggetti View di Android poteva essere rappresentata come un albero di widget UI. Man mano che lo stato dell'app cambia a causa di fattori come le interazioni dell'utente, la gerarchia della UI deve essere aggiornata per visualizzare i dati correnti.
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 dimenticare di aggiornare una delle visualizzazioni che lo mostrano. Ciò può anche portare a stati non validi, quando due aggiornamenti entrano in conflitto in modo imprevisto. 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, che semplifica la creazione e l'aggiornamento delle interfacce utente.
La tecnica funziona rigenerando concettualmente l'intero schermo da zero e applicando solo le modifiche necessarie. Questo approccio evita la complessità dell'aggiornamento manuale di una gerarchia di oggetti View stateful. Compose è un framework UI dichiarativo.
Una difficoltà 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. Questo ha alcune implicazioni sul modo in cui progetti i componenti della UI, come descritto in Ricomposizione.
Un esempio di funzione componibile
Utilizzando Compose, puoi creare la tua interfaccia utente definendo un insieme di funzioni componibili che acquisiscono dati e generano elementi UI. Un esempio è un widget Greeting, che accetta un String ed emette un widget Text che mostra un messaggio di saluto.
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 informa il compilatore Compose che questa funzione è destinata a convertire i dati in UI. - Input dei 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
Stringper poter salutare l'utente per nome. - Visualizzazione UI:la funzione visualizza il testo nell'interfaccia utente. Per farlo, chiama la funzione componibile
Text(), che crea effettivamente l'elemento UI di testo. Le funzioni componibili emettono la gerarchia della UI chiamando altre funzioni componibili. - Nessun valore restituito:la funzione non restituisce nulla. Le funzioni di composizione che emettono l'interfaccia utente non devono restituire nulla, perché descrivono lo stato della schermata di destinazione anziché creare widget dell'interfaccia utente.
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 la UI 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 discussi in Ricomposizione.
- 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
Il cambiamento di paradigma dichiarativo
Con molti toolkit UI imperativi orientati agli oggetti, l'UI viene inizializzata creando un'istanza di un albero di widget. Spesso questa operazione viene eseguita tramite l'inflating di un file di layout XML. Ogni widget mantiene il proprio stato interno ed espone i 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. In effetti, i widget non vengono esposti come oggetti.
Puoi aggiornare la UI chiamando la stessa funzione componibile con argomenti diversi. In questo modo, è più semplice fornire lo stato ai pattern architetturali come un ViewModel, come descritto nella Guida all'architettura delle app. In seguito, i componibili sono responsabili della trasformazione dello stato corrente dell'applicazione in un'interfaccia utente ogni volta che i dati osservabili vengono aggiornati.
Quando l'utente interagisce con la UI, quest'ultima genera eventi come onClick.
Questi eventi dovrebbero inviare una notifica alla 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.
Contenuti dinamici
Poiché le funzioni componibili sono scritte in Kotlin anziché in XML, possono essere dinamiche come qualsiasi altro codice Kotlin. Ad esempio, supponiamo che tu voglia creare una 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 particolare elemento UI. Puoi utilizzare i loop. Puoi chiamare funzioni helper. Hai la massima flessibilità del linguaggio di base.
Questa potenza e flessibilità è uno dei vantaggi principali di Jetpack Compose.
Ricomposizione
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 i nuovi dati. In questo modo, la funzione viene ricomposta, ovvero i widget emessi dalla funzione vengono ridisegnati, se necessario, con i nuovi dati. Il framework 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.
Compose chiama di nuovo la funzione 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 della UI può essere un'operazione dispendiosa dal punto di vista computazionale, che consuma potenza di calcolo e batteria. Compose risolve questo problema con la ricomposizione intelligente.
La ricomposizione è il processo di chiamata di nuovo delle funzioni componibili quando gli input cambiano. Quando Compose esegue la ricomposizione in base ai nuovi input, chiama solo le funzioni o le lambda che potrebbero essere cambiate e ignora il resto. Saltando le funzioni o le 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 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 osservabile in
ViewModel - Aggiornamento delle preferenze condivise
Le funzioni componibili potrebbero essere eseguite di nuovo anche a ogni frame, ad esempio durante il rendering di un'animazione. Le funzioni componibili devono essere veloci per evitare scatti durante le animazioni. Se devi eseguire operazioni costose, come la lettura da preferenze condivise, eseguile in una coroutine in background e passa il risultato del valore alla funzione componibile come parametro.
Ad esempio, questo codice crea un composable per aggiornare un valore in
SharedPreferences. Il composable non deve leggere o scrivere direttamente dalle preferenze condivise. Questo codice, invece, 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 alcuni aspetti da tenere presente quando utilizzi Compose:
- 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 di frequente, anche per 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 prive di effetti collaterali.
La ricomposizione salta il più possibile
Quando 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 la riesecuzione del composable di un singolo Button senza eseguire nessuno dei composable più in alto o più in basso nell'albero della UI.
Ogni funzione componibile e lambda potrebbe essere ricomposta autonomamente. 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 passare alla funzione lambda Column senza eseguire nessuno dei suoi 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, tutte le funzioni componibili o le espressioni lambda 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 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 ignora l'albero della UI della ricomposizione. Se hai effetti collaterali che dipendono dalla visualizzazione della UI, l'effetto collaterale verrà applicato anche se la composizione viene annullata. Ciò può causare uno stato incoerente dell'app.
Verifica che tutte le funzioni componibili e le lambda siano idempotenti e prive di effetti collaterali per gestire la ricomposizione ottimistica.
Le funzioni componibili potrebbero essere eseguite abbastanza di frequente
In alcuni casi, una funzione componibile potrebbe essere eseguita per ogni frame di un'animazione della UI. Se la funzione esegue operazioni costose, come la lettura dallo spazio sul dispositivo, può causare jank dell'interfaccia utente.
Ad esempio, se il widget provasse a 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 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 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 dell'interfaccia utente.
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é questo codice non è thread-safe sia perché si tratta di un effetto collaterale non consentito dell'espressione lambda componibile.
Ecco un esempio che mostra 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. 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 essere ogni frame di un'animazione o quando l'elenco viene aggiornato. In entrambi i casi, la UI mostrerà il conteggio errato. Per questo motivo, scritture come questa non sono supportate in Compose. Vietando queste scritture, consentiamo al framework di cambiare thread per eseguire lambda componibili.
Le funzioni componibili possono essere eseguite in qualsiasi ordine
Se guardi il codice di una funzione componibile, potresti pensare che venga eseguito nell'ordine in cui appare. Ma non è necessariamente 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 di 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. Ogni funzione 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
Consigliati per te
- Nota: il testo del link viene visualizzato quando JavaScript è disattivato
- Kotlin per Jetpack Compose
- Stato e Jetpack Compose
- Jetpack Compose architectural layering