Software Engineer
Jetpack Compose semplifica la progettazione di interfacce utente di alta qualità, ma può essere difficile individuare i problemi che influiscono sulle prestazioni della UI. Questi tipi di problemi possono causare problemi che gli utenti notano, come la latenza di avvio e i frame persi che appaiono come jank di scorrimento o animazioni discontinue.
In questo talk presenteremo gli strumenti per scoprire cosa influisce sulle prestazioni della UI, concentrandoci su Perfetto e sul tracciamento della (ri)composizione. Poi esploreremo gli anti-pattern specifici che potresti utilizzare e che ti costano segretamente, come trovarli nel tuo codice e con quali pattern positivi puoi sostituirli. Al termine di questo intervento, capirai come pensare in Compose con una mentalità orientata alle prestazioni e come profilare, analizzare e verificare le ottimizzazioni delle prestazioni nella tua app.
Trascrizione
Ciao.
Ciao,
Spero che tu abbia trascorso una buona pausa.
Spero che tu abbia preso il caffè, che abbia la pelle idratata e tutto il resto.
Oggi approfondiremo un po' l'argomento.
Beh, sì, iniziamo.
Oggi approfondiamo un po' l'argomento.
Mi chiamo Andrei Shikov e oggi condivido il palco con Andrew Bailey.
Entrambi lavoriamo nel team di runtime del compilatore Compose, ma ci occupiamo anche di molte altre cose, tra cui le prestazioni.
Il tema del discorso di oggi è rispondere alle vostre domande, domande sulle prestazioni, perché Jetpack Compose si è affermato come toolkit UI predefinito per lo sviluppo Android.
Ora molte persone si chiedono come possiamo assicurarci che la nostra UI di Compose sia il più efficiente possibile.
Quindi oggi, come ho già detto, faremo un approfondimento.
Inoltre, presenteremo alcuni strumenti per aiutarti a superare i tuoi limiti in Compose.
Per prima cosa, vorrei parlare del motivo per cui le prestazioni sono importanti per noi.
Quando pensi alla qualità e alla creazione di app di qualità, di solito pensi alle animazioni, a un ricco insieme di funzionalità e, a volte, le prestazioni vengono trascurate, ma la percezione degli utenti dipende molto dalle prestazioni della tua app.
Quindi, se le animazioni si bloccano, la percezione cala immediatamente.
Quindi tutte le metriche e i numeri che menzioneremo oggi sono in realtà metriche proxy che abbiamo selezionato per rappresentare l'opinione degli utenti sull'app, su tutte le animazioni e su tutto ciò che accade sullo schermo.
Quindi, fondamentalmente, ottimizzare l'app significa ottimizzarla per un'esperienza migliore.
Quindi, questo è solo un approccio mentale che voglio che tu abbia, mentre approfondiamo molti argomenti oggi.
Ora, mentre inizi a esaminare il rendimento della tua app, voglio che tu faccia tre cose veloci, tre cose molto rapide.
Innanzitutto, se misuri le prestazioni, lo fai in modalità di rilascio, quindi non abbiamo molte informazioni abilitate al debug e tutto questo rallenta la nostra app.
In secondo luogo, attiva R8 o altri minificatori.
Proprio quest'anno abbiamo introdotto un'ottimizzazione che raddoppia le prestazioni delle coroutine e facciamo anche molte altre cose.
Ci affidiamo molto a R8 per l'ottimizzazione di Compose.
Infine, soprattutto per le metriche di avvio, assicurati di utilizzare il profilo di riferimento.
In questo modo, le classi che carichi all'avvio vengono precompilate e precaricate all'avvio dell'app.
Come puoi vedere, si tratta di una differenza di 6 volte rispetto alla build abilitata al debug dell'app.
E, man mano che misuri tutti questi aspetti, si sommeranno agli altri risultati positivi che stai ottenendo all'interno del tuo codice.
Ora, hai fatto tutto questo, ma l'app continua a presentare un leggero ritardo o altri problemi di prestazioni. È qui che entra in gioco questo ciclo di prestazioni.
Vuoi iniziare a esaminare alcune metriche su alcuni scenari specifici.
Ad esempio, supponiamo che l'app impieghi troppo tempo per caricarsi, quindi potresti voler reimpostarla, avviarla come un normale Avvio app e controllare quanto tempo impiega l'app per arrivare alla prima schermata di dati.
O forse l'app si blocca un po', quindi scorri e ti assicuri che questo scorrimento sia ripetibile e stabile.
Quindi, l'ambiente stabile in questa slide è molto importante, perché quando esegui le ottimizzazioni in un secondo momento, vuoi assicurarti di vedere miglioramenti nelle metriche e di poter confermare che queste ottimizzazioni funzionino effettivamente come previsto.
I benchmark sono molto utili per questo, perché acquisiscono già molti dati, eseguono molte iterazioni per assicurarsi che non ci sia troppo rumore nel dispositivo ed eseguono anche molte configurazioni del dispositivo che ti aiutano.
Inoltre, i benchmark sono anche scenari ripetibili per impostazione predefinita, ed è questo l'obiettivo che vogliamo raggiungere.
Tuttavia, se vuoi, puoi farlo anche con un gruppo di script.
Infine, profila, migliora qualcosa nella tua app, esamina i dati e ripeti l'operazione, magari con metriche di scenari diversi in cui la tua app ha difficoltà.
Mentre esamini i dati, il primo strumento che presentiamo oggi e che devi conoscere molto bene sono le tracce di Perfetto.
Le tracce di Perfetto registrano tutto ciò che accade sul dispositivo mentre registri la traccia, ad esempio durante l'esecuzione di un macrobenchmark o magari di uno script che stai registrando.
Inoltre, è di grande aiuto per ogni ingegnere che lavora alle prestazioni di Android.
Per quanto mi riguarda, al momento trascorro la maggior parte della mia giornata lavorativa a esaminare le tracce di Perfetto.
che hanno un aspetto simile a questo.
Si tratta di una grande quantità di dati.
Come ho detto, acquisiscono tutto ciò che accade su un dispositivo.
All'inizio potresti avere difficoltà a capire dove si trova esattamente la tua app, quindi ti consiglio di utilizzare questa pratica barra di ricerca.
Cerca Compose, cerca il pacchetto applicativo e così via.
Concentrati su cose familiari.
E mentre ti orienti e trovi il processo della tua app, vedrai le linee della durata frame e quanto tempo impiegano molte cose nella tua app.
Se ne isoliamo uno, vedrai qualcosa del genere.
Il primo è il lavoro del thread principale.
È qui che Compose svolge la maggior parte delle sue attività.
Poi c'è RenderThread.
Qui elaboriamo il comando canvas e lo codifichiamo per la GPU.
Poi la GPU fa il suo lavoro e il sistema compone il tutto.
Oggi esamineremo solo il thread principale.
perché è qui che Compose svolge il suo lavoro.
Stiamo parlando di Compose, quindi è ragionevole concentrarsi solo su questo.
Se isoliamo quel singolo frame sul thread principale, ora iniziamo a vedere le sezioni di Compose.
Ne ho selezionati alcuni per te.
È come composizione, misurazione, layout, disegno.
Se hai sentito parlare delle fasi di Compose, probabilmente le vedrai quando esamini le prestazioni.
Quindi, queste hanno sezioni singole in una traccia Perfetto ed è molto facile scoprire quanto tempo richiede ciascuna delle fasi e quali fasi vengono eseguite in ogni particolare frame.
Abbiamo parlato molto e a lungo dell'ottimizzazione di parte di questo lavoro e vuoi assicurarti di svolgere il lavoro giusto nella fase giusta.
Ad esempio, se hai un'animazione che aggiorna il colore della casella o qualcosa del genere, vuoi invalidare solo la fase di disegno, non una composizione, un layout e cose del genere.
Queste sono le nozioni di base sul rendimento e oggi approfondiremo alcuni aspetti, concentrandoci in particolare sulla composizione.
Ora passo la parola ad Andrew, che ci spiegherà come fare.
Grazie, Andrei.
Come ha detto Andrei, in precedenza abbiamo parlato molto, nel contesto delle prestazioni, di ottimizzazioni come evitare le letture all'indietro, assicurarsi di posticipare le letture dello stato alla fase corretta del ciclo di vita della composizione e ottimizzare e assicurarsi che i composabili vengano invalidati come previsto, in modo da non fare troppo lavoro su ogni singolo frame.
Questo è un ottimo punto di partenza, ma abbiamo già molto materiale su questo argomento e vogliamo portare avanti la conversazione.
Oggi, quindi, affronteremo ciò che rimane nella composizione dopo aver eseguito queste ottimizzazioni e ciò che fanno i composabili stessi.
La triste realtà di tutto ciò che si trova all'interno del codice Compose e, in realtà, di tutto ciò che viene fornito con l'app in termini di prestazioni, è che tutto ha un costo associato.
Nessun codice che inserisci nella tua app è senza costi e anche cose banali come la lettura dello stato e la composizione di elementi come un effetto collaterale all'interno della composizione hanno dei costi tangibili associati. È importante tenerli presenti, soprattutto quando inizi ad analizzare cosa fanno effettivamente i tuoi componibili e inizi a spremere più prestazioni all'interno della tua app.
Per mostrare come appare effettivamente alla scala più microscopica, ecco alcune delle operazioni più piccole che puoi eseguire ovunque nel tuo codice, e in particolare all'interno dei tuoi componibili, e a questa risoluzione, stiamo parlando della scala dei nanosecondi in termini di quanto costano approssimativamente queste cose come mentalità.
Se vuoi migliorare il rendimento, hai due opzioni.
Puoi rimuovere un numero molto elevato di operazioni poco costose o un numero inferiore di operazioni più costose.
In questo grafico, in particolare, le coroutine sono m-- sul lato più costoso per quanto riguarda queste operazioni e sono un buon esempio di dove iniziare a cercare, perché se rimuoviamo alcune di queste istanze, possiamo iniziare a vedere più rapidamente vantaggi più tangibili nella nostra applicazione.
Un'altra cosa da tenere presente in particolare sulle coroutine è che, come mostrato nella slide, non solo l'avvio di una coroutine ha un costo associato, ma anche l'annullamento di una coroutine.
Il costo di annullamento è particolarmente degno di nota perché si verifica principalmente quando elimini i contenuti e, se possibile, passi ad altre aree all'interno dell'applicazione.
La navigazione è un caso estremamente costoso in molte applicazioni in cui devi sfruttare al massimo la durata frame e assicurarti di utilizzare al meglio ciò che hai.
È quindi importante tenere conto di entrambi i costi.
Tutti questi aspetti, indipendentemente da ciò che stiamo esaminando, si sommano a questa scala di circa 16 milioni di nanosecondi con cui lavorare.
Quindi abbiamo un po' di budget da ridurre e le coroutine saranno un buon esempio da cui iniziare.
In Compose, le coroutine vengono visualizzate più spesso con le API degli effetti.
Esiste una gamma di API che puoi utilizzare con SideEffects, da quelle con funzionalità elevate a quelle con bassa latenza.
All'estremità più funzionale di questo spettro, abbiamo LaunchedEffect, che include tutto ciò che serve per il lavoro asincrono, in particolare le animazioni, tutto ciò per cui è necessario un ambito di coroutine.
Tuttavia, se non ne hai bisogno e puoi portare a termine l'attività con un'API meno costosa, potrebbe valere la pena valutare l'utilizzo di quest'ultima, se soddisfa le esigenze del tuo caso d'uso.
Per mostrare come funziona in pratica, ho un composable di media player per mostrare tutti i diversi SideEffect e quando potrebbero apparire.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}Possiamo iniziare con un LaunchedEffect.
In questo esempio, viene utilizzato per caricare i dati in modo asincrono all'interno del media player.
Se hai bisogno di uno scope di coroutine o se hai bisogno di coroutine in generale o per effettuare una chiamata di sospensione, utilizza LaunchedEffect.
Anche se è ancora un po' costoso e c'è un costo associato alle coroutine, se lo sfrutti e lo giustifichi, utilizza l'API giusta per il lavoro.
Tuttavia, se ti trovi in una situazione in cui utilizzi un LaunchedEffect, ma non sfrutti la coroutine, potrebbe essere una buona idea prendere in considerazione altre API che possono fare la stessa cosa, ma a un costo inferiore.
Per questo, scendendo lungo lo spettro, possiamo vedere altri esempi di effetti collaterali come DisposableEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}DisposableEffect è ottimo quando hai un processo di configurazione e disattivazione associato al tuo effetto.
Qui, nello stesso esempio di media player, ho un esempio che blocca l'orientamento dell'attività in orizzontale quando è presente il composable e poi rilascia il blocco dell'orientamento quando scompare in un secondo momento.
Se vuoi l'effetto una tantum di LaunchedEffect e DisposableEffect, ma non hai bisogno di coroutine e non hai un effetto di eliminazione, puoi anche utilizzare la nuova API SideEffect con chiavi che abbiamo introdotto in Compose 1.12.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}Questa è un'ottima alternativa alle altre due API se le hai utilizzate, LaunchedEffect per comodità o DisposableEffect per necessità di un effetto one-shot. La navigazione o la migrazione alle API SideEffect con chiavi può ottenere lo stesso risultato con un overhead inferiore e prestazioni migliorate, soprattutto se la migrazione viene eseguita più volte nell'arco di un'intera app.
Infine, l'API meno costosa è l'API SideEffect standard.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}Questo è l'approccio più appropriato quando vuoi sincronizzare lo stato esterno del mondo con ciò che accade nella composizione.
Quindi, sempre nell'esempio del media player, ho un altro esempio che mostra la sincronizzazione di uno stato di riproduzione con un parametro ricevuto dalla composizione.
Una cosa da tenere presente, se decidi di controllare gli utilizzi degli effetti collaterali ed eseguire la migrazione a uno meno costoso, è che oltre agli altri comportamenti e casi d'uso di cui abbiamo parlato, esiste anche una differenza nelle garanzie di ordinamento con gli effetti collaterali.
Tutti gli effetti disposable vengono eseguiti per primi, seguiti dagli effetti collaterali regolari, con o senza chiave, e poi, una volta completato il frame, vengono eseguiti tutti gli effetti lanciati perché è coinvolto un dispatch.
Se i tuoi effetti avviati dipendono da questa tempistica, tieni presente questo aspetto se stai valutando la migrazione.
Per qualsiasi migrazione, assicurati di testare il codice e di non dipendere da nessuna di queste garanzie di tempistica e che il codice funzioni ancora in seguito.
Se non riesci a ridurre la complessità dell'effetto collaterale, puoi comunque ridurre il numero di effetti collaterali unendoli.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}Questo è un aspetto particolarmente importante da considerare se hai più effetti che utilizzano la stessa chiave.
È un buon candidato da considerare per l'unione perché può essere eseguito contemporaneamente.
In questo esempio, ho diversi effetti avviati che effettuano ciascuno una chiamata asincrona a un modello di visualizzazione.
Se li sostituisco con un singolo LaunchedEffect e poi con altre chiamate di avvio al suo interno, anche se ho ancora lo stesso numero di coroutine in esecuzione, l'overhead è comunque inferiore perché il lavoro viene rimosso dalla composizione e inserito nelle coroutine.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}Pertanto, in situazioni come questa è consigliabile valutare la possibilità di eseguire la migrazione in un'unica soluzione per ridurre il numero di effetti, se possibile.
Gli effetti e le coroutine sono solo un esempio di un punto che potresti controllare all'interno della tua applicazione, ma ogni componibile e ogni applicazione sarà diversa e avrà opportunità diverse al suo interno.
Per capire cosa è pertinente per la tua applicazione, dobbiamo parlare del prossimo strumento che Andrei introdurrà, ovvero il tracciamento della composizione.
Okay.
Quindi, il tracciamento della composizione, se guardi questa fedele riproduzione del frame Perfetto che abbiamo visto prima in un'altra slide, vedrai che ci fornisce informazioni sui tempi con diverse fasi, ma non ci fornisce molti dettagli su ciò che sta accadendo.
Cosa?
Sì, cosa succede all'interno della composizione di cui vengono eseguite le funzioni componibili.
Il tracciamento della composizione ci aiuta in questo.
Per questo motivo, abbiamo introdotto alcuni indicatori nel compilatore Compose che, in pratica, annotano ogni funzione e la rendono visibile nella traccia di Perfetto, in modo da poter controllare quanto tempo impiega ogni funzione.
Puoi utilizzarlo per capire esattamente quali funzioni vengono ricomposte.
Tuttavia, questo comporta un certo overhead, quindi se vuoi acquisire i dati sul rendimento più accurati, ti consigliamo di utilizzare Perfetto standard o di eseguire l'operazione senza acquisizioni di Perfetto e di utilizzare altre metriche.
Tuttavia, il tracciamento della composizione è molto utile se vuoi capire quanto tempo impiega ogni singolo elemento componibile.
A volte, però, vuoi anche capire perché gli elementi vengono ricomposti, ed è proprio questo che stiamo cercando di risolvere nella prossima release del runtime di Compose Tracing, ovvero la versione 1.13.
Stiamo introducendo il tracciamento della ricomposizione, che annota ogni lettura e scrittura dello stato che ha portato alla ricomposizione.
Quindi, nella traccia Perfetto vedrai qualcosa del genere, dove vedrai ciascuna delle scritture di stato e gli stack di chiamate in cui si verificano la scrittura e la lettura, in modo da poterli far corrispondere alle righe effettive del codice in cui è stato eseguito l'accesso o la modifica di questi stati.
Se vuoi saperne di più, consulta questo link.
Contiene tutta la documentazione sul tracciamento della composizione e della ricomposizione.
Ora passo la parola ad Andrew, che ci spiegherà come utilizzare questo approccio in modo più efficiente per evitare le scritture di stato.
Grazie, Andrei.
In base ai risultati di questa indagine, potresti scoprire che nella tua composizione sono presenti invalidazioni che non sono utili per la tua applicazione e che avrebbero potuto essere evitate fin dall'inizio.
Ad esempio, l'esempio più comune che ho è un pulsante per scorrere verso l'alto che dipende dalla visibilità o dall'avanzamento dello scorrimento per la sua visibilità.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}In questo caso, poiché sto leggendo il valore di uno stato di scorrimento all'interno della composizione, si tratta di uno stato che cambia molto di frequente, potrebbe cambiare anche una volta per frame, e questo causerà molte ricomposizioni che potrebbero essere evitate perché questa condizione, anche se lo stato di scorrimento stesso sta cambiando, la condizione effettiva che ci interessa qui non cambia così spesso.
Questo è un esempio di situazione in cui consigliamo di utilizzare uno stato calcolato, che può evitare invalidazioni per modifiche di stato che non sono importanti per la tua applicazione.
In questo esempio, ho inserito il calcolo stesso all'interno di uno stato calcolato e, quando leggiamo questo stato, invece di invalidare in base alla posizione di scorrimento stessa, invaliderà in modo condizionale questo composable in base all'intera condizione e lo deriverà dall'altro stato stesso.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}Se hai già visto questo pattern, è probabile che tu l'abbia visto utilizzato con lo stato derivato.
ComputedState è una novità di Compose 1.13 e, sebbene ComputedState e DerivedState abbiano entrambi l'obiettivo simile di invalidare in modo condizionale quando il calcolo cambia, hanno una differenza molto sottile nel modo in cui lo fanno.
DerivedState, che abbiamo reso disponibile fin dall'inizio di Compose, memorizza nella cache il valore che calcola e lo riutilizza finché le sue dipendenze non cambiano.
Con ComputedState, il valore viene calcolato ogni volta che lo leggi.
La memorizzazione nella cache sembra utile, ma in realtà in molte situazioni è più costosa della semplice lettura del risultato.
Per capire come ciò avviene, possiamo esaminare un esempio di come questi stati potrebbero essere utilizzati all'interno della tua applicazione e approfondire ciò che ciascuno di essi sta effettivamente facendo.
Qui ho lo stesso stato che rispecchia approssimativamente ciò che stavamo facendo nel pulsante di scorrimento verso l'alto, dove è presente una condizione basata su un altro stato, e l'ho sia come DerivedState che come ComputedState.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valueInoltre, leggo lo stato due volte e otterrò lo stesso valore entrambe le volte, ma ciò che accade dietro le quinte è diverso tra i due.
Per semplificare una traccia di ciò che verrebbe effettivamente visualizzato qui in uno strumento come Perfetto, possiamo esaminare cosa succede effettivamente quando leggi entrambi questi stati.
Qui, iniziamo chiamando computedState.value.
Eseguirà la funzione lambda di calcolo, che poi leggerà il valore dello stato, e ci sarà anche un po' di manutenzione per notificare che la lettura è avvenuta.
Questo vale per tutti gli stati degli snapshot in Compose.
La seconda lettura si comporta esattamente come la prima e ha le stesse caratteristiche di prestazioni.
In confronto, la prima lettura di DerivedState fa una cosa simile, in cui deve comunque chiamare la funzione lambda di calcolo ed eseguire l'altra notifica, ma ha un po' di overhead di gestione della cache, che viene un po' bypassato la prima volta perché non esiste una cache da controllare.
Quindi esegue un po' di manutenzione per la memorizzazione nella cache stessa e poi ottiene lo stesso risultato in un tempo leggermente superiore.
La seconda lettura, tuttavia, per DerivedState vince effettivamente rispetto alla seconda lettura per ComputedState, anche se si tratta di stati abbastanza semplici.
Come ho detto nelle slide precedenti, il costo di una lettura di snapshot non è banale in alcuni casi e la lettura effettiva dalla cache è più veloce della lettura stessa, motivo per cui vediamo questo leggerissimo vantaggio per la seconda lettura stessa per DerivedState.
Ma lo scenario davvero interessante è quello che si verifica quando lo stato deve essere invalidato.
Questo è ciò che chiamiamo scenario di lettura dopo scrittura, in cui aggiorni uno degli stati utilizzati nel calcolo e il calcolo deve essere eseguito di nuovo.
Nel codice, questo significa che modifichiamo "a", magari incrementandolo, e poi leggiamo di nuovo il valore o forse ricomponiamo.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.valueNel diagramma effettivo, l'aspetto sarà esattamente lo stesso per ComputedState.
Non ha una cache da gestire, quindi la prima, la seconda, la terza o qualsiasi altra lettura è esattamente la stessa.
DerivedState, invece, ha un compito più difficile.
Segue un percorso molto costoso per verificare che la cache non sia valida prima ancora di aggiornare e chiamare il calcolo stesso, quindi deve eseguire il monitoraggio delle dipendenze e gestire molte dipendenze e molte osservazioni per riscrivere il valore della cache e capire che tutti questi valori sono effettivamente cambiati e gestire la manutenzione stessa.
Tutto questo si somma e rende il costo dell'annullamento della convalida di un DerivedState molto elevato rispetto a un ComputedState.
Per dare dei numeri a tutte queste affermazioni, ComputedState è in genere più veloce di circa 200 ns alla prima lettura.
ComputedState perde circa 350 ns su un Pixel 6, in particolare in condizioni di benchmark specifiche.
Quindi, alla seconda lettura, ComputedState perderà leggermente rispetto a DerivedState.
Ma nel caso che ci interessa davvero, quello in cui invalidiamo e probabilmente quello in cui hai effettivamente del lavoro da fare sul tuo frame, ComputedState è più veloce di quasi, e di fatto, più del doppio rispetto a DerivedState.
Quindi, a seconda di come utilizzi gli stati, ComputedState può essere un'ottima prima scelta.
In generale, quando si utilizzano DerivedState e ComputedState, è comunque necessario prendere una decisione.
Non stiamo deprecando ComputedState, ci sono ancora utilizzi per entrambi.
In genere, se non sai quale scegliere, lo stato calcolato è una buona opzione predefinita.
Lo stato derivato è più utile quando sfrutti effettivamente la memorizzazione nella cache, ovvero quando esegui un'operazione non banale come un algoritmo complicato, un'allocazione di grandi dimensioni, molte letture di stato all'interno che traggono effettivamente vantaggio dalla memorizzazione nella cache o qualsiasi operazione non banale o che dipende da elementi come l'uguaglianza degli oggetti e l'uguaglianza dell'identità degli oggetti, in cui compensi la differenza e ottieni i vantaggi della memorizzazione nella cache offerta dallo stato derivato.
L'altro problema di cui voglio parlare è che sia lo stato derivato che quello calcolato cercano di evitare di invalidare il composable stesso.
Questo è diverso dal saltare un composable, che è ciò di cui parliamo normalmente quando pensiamo all'annullamento condizionale o alla mancata esecuzione di un'operazione all'interno di un composable.
La verità che voglio esporre qui è che, anche se si parla molto di stabilità e di skipping all'interno di Compose, questo non è l'unico aspetto da considerare e non è sempre la cosa giusta da ottimizzare.
Come abbiamo detto in precedenza, tutto ciò che riguarda la composizione comporta dei costi, incluso l'omissione.
// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}Saltare un passaggio non è necessariamente un'operazione semplice.
Per prima cosa, dobbiamo eseguire molte operazioni di manutenzione per attivarlo.
Dobbiamo eseguire operazioni come l'archiviazione di tutti i parametri e la ricerca di spazio per questi nella composizione iniziale, per poi aggiornare il record durante la ricomposizione.
Inoltre, dobbiamo confrontare i tuoi valori con quelli precedenti per capire se il composable deve essere ignorato o meno.
In seguito, tutto questo è solo la potenza di un controllo if che salta o meno il codice.
Questa operazione può essere costosa a seconda di ciò che sta accadendo qui.
Esiste un'ottimizzazione nascosta per il compilatore: se passi lo stesso parametro, possiamo riutilizzare i controlli di uguaglianza, ma non è la soluzione definitiva ed è abbastanza facile da aggirare.
Per vedere un esempio di come l'omissione ci tradisce un po', possiamo guardare un esempio davvero classico di un elenco a scorrimento che contiene, soprattutto, un oggetto enorme che contiene tutti i dati da comporre sullo schermo.
data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }In questo caso, il modello di schermata contiene un elemento come una schermata di intestazione e poi l'intero elenco di contatti che passa attraverso un paio di elementi componibili che, alla fine, chiamano un elenco lazy e poi li mostrano sullo schermo con alcune celle di elementi.
La gerarchia di composizione di questo codice è simile a questa, dove iniziamo di nuovo con la schermata dei contatti, ci espandiamo un po' e poi abbiamo altri componibili che chiamano e presentano i nostri elementi.
Se osserviamo come vengono trasmessi i dati, iniziamo con il modello in alto.
La destrutturiamo in una stringa e poi in un elenco, che viene poi convertito in una lambda di elementi per l'elenco lazy, che a sua volta comporrà tutti gli elementi visualizzati sullo schermo.
Vediamo cosa succede quando modifichiamo l'ultimo contatto di questo elenco.
Quello che vogliamo è semplicemente invalidare l'ultimo elemento dell'elenco, ma non è quello che succederà qui.
Se osserviamo una traccia di composizione per questa ricomposizione, l'aspetto sarà simile a questo, dove iniziamo dalla schermata dei contatti e poi arriviamo a questo elemento dell'elenco.
Questo è un modo piuttosto costoso per arrivare qui.
Possiamo anche vedere che ci sono tutte queste sezioni in cui sembra che non stia accadendo nulla all'interno della traccia.
Anche se qui non è chiaro, è proprio in questo punto che i composable decidono se saltare o meno.
In questo esempio, vengono ignorati solo due componibili a causa del modo in cui l'elenco lazy esegue la ricomposizione.
Quando gli fornisci una nuova lambda di contenuti, invalida tutti gli elementi che contiene.
Quindi invaliderà i due contatti che non sono stati modificati, oltre a quello che è stato modificato, e questi due sono gli unici elementi qui che vengono ignorati.
Tutto il resto esegue questo confronto di uguaglianza per poi rendersi conto che deve effettivamente essere eseguito.
Se gli oggetti sono stabili, questo confronto può essere piuttosto oneroso.
Se ci costringi a esaminare tutte le tue strutture di dati, questo può effettivamente sommarsi e rappresentare un costo non trascurabile solo per capire se due oggetti sono uguali.
E peggio ancora, poiché abbiamo eseguito la destrutturazione, dobbiamo effettivamente eseguire lo stesso confronto nell'elenco due volte.
Se invece lasci che il compilatore si comporti con il comportamento predefinito e utilizzi lo skipping avanzato, puoi comunque ottenere lo skipping e i controlli per saltare in modo condizionale i tuoi componibili, ma questi seguono il percorso molto più rapido dell'utilizzo dell'uguaglianza dell'identità degli oggetti anziché dell'uguaglianza strutturale.
Questo è molto più veloce da calcolare per noi e, anche se ricomponiamo gli stessi elementi in questo frame, dove è effettivamente importante entrare rapidamente, non perdiamo troppo tempo a capire che non potevamo effettivamente saltare questi componibili.
Questo è anche il motivo per cui sottolineiamo l'importanza di utilizzare strumenti come benchmark e tracce per capire cosa sta succedendo all'interno dell'applicazione, anziché i conteggi di ricomposizione direttamente.
Possono essere un proxy utile, ma non sono necessariamente un elemento su cui concentrarsi in modo eccessivo o da sovraindicizzare durante l'ottimizzazione delle applicazioni.
Ricorda che, invece di provare a saltare il composable, evita di invalidarlo del tutto.
In questo esempio abbiamo visto che le tracce di composizione possono fornire molte informazioni su ciò che sta accadendo, ma non mostrano necessariamente il quadro completo.
Per questo, Andrei ci parlerà delle tracce metodo.
Okay.
Come ha già detto Andrew, il tracciamento della composizione non sempre ti dice tutto ciò che sta accadendo, solo perché annota solo le funzioni componibili e devono esserci alcuni casi in cui vedi solo uno spazio vuoto nella traccia della composizione che aumenta già la traccia di Perfetto, ma non mostra ancora tutto ciò che sta accadendo lì.
A volte può essere un'azione di Compose, altre volte può essere qualcosa nel tuo codice, è davvero difficile capirlo semplicemente guardando la traccia della composizione qui.
Ora devi acquisire la traccia metodo.
C'è un elemento nel profiler di Android Studio, oppure puoi utilizzare i comandi ADB per acquisire la traccia, che ti fornirà ogni chiamata di funzione che avviene nel mondo Java.
Contiene tutto ciò che il compilatore di Compose codifica, tutto ciò che fa il runtime di Compose e tutto ciò che fa il tuo codice. È molto utile per monitorare e analizzare cosa viene eseguito esattamente e quali funzioni sono state chiamate in quello spazio.
Una cosa, voglio… vedi questo elemento al centro.
Voglio- voglio sottolineare che il tracciamento dei metodi ha un overhead molto elevato.
L'overhead è da 10 a 20 volte superiore a seconda della complessità di un albero di funzioni, perché ogni chiamata di funzione aggiunge un po' di overhead.
Per illustrare questo concetto, possiamo guardare lo screenshot.
Questo è l'aspetto che di solito ha la traccia metodo per l'avvio dell'app e potresti chiederti: "Come faccio a capirla?"
In genere, inizi con la traccia di composizione e osservi le funzioni che hai qui.
Questa è una traccia di composizione semplificata, in cui sono presenti ScreenContent, un pulsante e altre funzioni.
Supponiamo di voler capire cosa sta succedendo in ScreenContent.
Perfetto ha una barra di ricerca.
Carichi la traccia metodo nel visualizzatore Perfetto, cerchi la stessa funzione che hai qui, ScreenContent, e otterrai qualcosa di simile, ovvero la traccia metodo per la schermata.
Quindi, puoi vedere qui che ci sono alcune chiamate Composer.startGroup e di gruppo.
Questo è ciò che i runtime e il compilatore fanno per te in background per assicurarsi che la composizione faccia effettivamente la sua magia, ma la chiamata interessante qui è Composer.changed.
In questo modo controlliamo se i parametri sono cambiati tra le chiamate precedenti e quella corrente del composable.
La maggior parte di questi Composer.changed è dominata da questo ScreenModel.equals.
Quindi, forse ScreenModel è una sorta di classe di big data e forse l'abbiamo resa stabile per aiutare con il conteggio della ricomposizione, quindi forse è qualcosa che è apparso nel report del compilatore.
E forse rimuovere questa annotazione stabile aiuterà con le prestazioni in questo caso particolare.
Quindi, devi cercare questo problema e le tracce metodo di composizione sono lo strumento migliore per trovarlo.
Okay.
Oggi abbiamo esaminato molti aspetti, quindi ora passo la parola ad Andrew per concludere.
Per concludere e fornire un riepilogo generale di tutto ciò di cui abbiamo parlato oggi, ci sono un paio di cose da tenere a mente quando progetti i composabili e poi ottimizzi le app che hai già.
Innanzitutto, ricorda che i componibili più economici e quelli che non invalidi sono migliori di quelli che salti.
Ricorda che, quando progetti i componibili, è meglio considerare argomenti più piccoli e facili da confrontare ed eventualmente riutilizzare i confronti con altre chiamate di funzione per sfruttare l'ottimizzazione.
Quando utilizzi le API all'interno dei tuoi componibili, tieni presente che le prestazioni o la durata frame saranno un prodotto del costo delle API stesse e della frequenza con cui le utilizzi.
Presta attenzione a come si sommano questi costi e tieni presente che potrebbero esserci costi di eliminazione associati che potresti dover sostenere nel momento meno opportuno per la tua applicazione.
Inoltre, ricorda che è importante essere un buon cittadino quando esegui ottimizzazioni all'interno della tua app.
Inizia eseguendo i benchmark per prendere decisioni consapevoli, profila la tua app per assicurarti di scegliere come target le aree giuste del codice e verifica che stai apportando le modifiche e vedendo i risultati che ti aspetti dalle modifiche che stai apportando in seguito ai benchmark e alla profilazione.
Inoltre, ricorda che può esserci un compromesso tra manutenibilità e prestazioni all'interno della tua applicazione.
Quindi, fai ciò che è meglio per te e per il tuo team per assicurarti di non ottimizzare eccessivamente e di non trovarti in una situazione difficile con la manutenibilità.
Inoltre, per ulteriore assistenza, hai a disposizione un altro strumento: l'agente AI che già possiedi.
Abbiamo lanciato, nell'ambito della CLI Android e delle skill che offriamo, una skill sulle prestazioni di Jetpack Compose che contiene molte delle informazioni di cui abbiamo parlato oggi e che può esaminare e controllare la tua applicazione e fornire suggerimenti per questo tipo di ottimizzazioni.
Puoi installarlo nell'agente esistente utilizzando la CLI Android.
android skills add jetpack-compose-performancePer saperne di più sulla CLI Android, visita il sito developer.android.com.
Puoi anche scoprire di più sulle nostre competenze nel nostro repository GitHub.
E con questo, buon benchmarking sicuro e buona composizione.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valuevar a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.value// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }android skills add jetpack-compose-performance