Android Performance Engineer
Le app per Android vengono spesso distribuite con un'ottimizzazione insufficiente a causa di regole di conservazione eccessivamente conservative, spesso provenienti da librerie di terze parti, il che comporta APK di dimensioni eccessive, avvii a freddo più lenti e prestazioni di runtime ridotte.
Con le nuove soglie di rendimento di Google Play per l'ottimizzazione DEX, la visibilità delle app ora dipende direttamente dal mantenimento di un livello di ottimizzazione elevato.
Questo intervento introduce un workflow R8 moderno per affrontare il problema del bloat delle regole. Imparerai a:
Trascrizione
Ciao,
Mi chiamo Breana e faccio parte del team Android DevRel.
Io sono Chris Craik e faccio parte del team Android Performance.
Ti diamo il benvenuto alle regole di conservazione dei tipi sicuri e all'ottimizzazione DEX.
Oggi parleremo di cos'è l'ottimizzazione DEX, perché è importante e di alcuni aggiornamenti recenti interessanti.
Per molto tempo, l'ottimizzazione della DEX è stata considerata un'attività non indispensabile.
Quindi, forse la configuravi una volta e poi la lasciavi così com'era, oppure la modificavi quando avevi un po' di tempo a disposizione.
Tuttavia, con il settore dei dispositivi mobili che deve far fronte a significativi vincoli di fornitura dell'hardware che stanno alterando la disponibilità della memoria dei dispositivi, un DEX ben ottimizzato è essenziale affinché la tua app utilizzi meno memoria, si avvii più velocemente, riduca gli errori ANR e migliori le prestazioni di rendering ed esecuzione in generale.
Per mantenere un ecosistema Android sano e garantire che le tue app continuino a offrire esperienze di alta qualità, Google Play ha annunciato il nuovo requisito di qualità delle app con soglie di rendimento che entreranno in vigore a febbraio.
Sono incluse le soglie per l'ottimizzazione del codice DEX.
Le app devono raggiungere una percentuale superiore al 25% per la riduzione, l'ottimizzazione e l'offuscamento.
Cosa significa tutto questo per te in qualità di sviluppatore Android?
Il codice DEX non ottimizzato non solo influisce negativamente sull'esperienza utente, ma potrebbe anche avere implicazioni sulla visibilità e sulle funzionalità di pubblicazione della tua app sul Play Store.
Tuttavia, sei nel posto giusto e, al termine di questo intervento, avrai gli strumenti non solo per soddisfare, ma anche per superare questi requisiti.
L'ottimizzazione del codice DEX è possibile con R8, l'ottimizzatore di app di Android.
Sebbene R8 non sia obbligatorio se un altro ottimizzatore di app può portare la tua app al di sopra di queste soglie, è il percorso consigliato integrato direttamente nella toolchain di Android.
Sebbene un DEX e un app bundle ben ottimizzati utilizzino meno memoria, i vantaggi principali sono comunque legati alle prestazioni: meno errori ANR, avvio più rapido e prestazioni di runtime migliorate.
Inoltre, l'ottimizzazione della tua app ha risultati positivi reali e comprovati per la tua app e per i tuoi utenti.
Ad esempio, adottando la modalità completa di R8 e perfezionando le regole di conservazione, Grab è riuscita a ottenere un'ottimizzazione del 78%, una riduzione del 79% e un'offuscamento dell'82% per la sua app.
Guardando questa slide, potresti pensare: "Ok, questi numeri sono piuttosto alti, ma cosa significano in pratica?"
Grazie a questi miglioramenti, combinati con i profili di riferimento, ha ridotto gli ANR del 32% e migliorato l'avvio del 35%.
Quando le app principali adottano per la prima volta R8, registrano una riduzione media delle dimensioni DEX del 38% e una riduzione del 16% degli errori ANR percepiti dagli utenti.
Se utilizzi già R8, abbiamo ottime notizie per te.
Per le app che hanno già abilitato R8 e poi adottano misure per migliorare ulteriormente i livelli di ottimizzazione, riduzione e offuscamento di oltre il 50%, si registra, in media, un'ulteriore riduzione del 25% delle dimensioni DEX, una riduzione del 4% del tempo di avvio e un'ulteriore riduzione del 17% degli ANR percepiti dagli utenti, che si aggiunge al 16% mostrato nella slide precedente.
Oggi vogliamo illustrarti come ottenere risultati simili.
Tratteremo gli strumenti che possono aiutarti a ottimizzare il codice DEX e a passare da regole keep eccessivamente ampie e configurate in modo errato a regole specifiche e limitate.
Innanzitutto, vogliamo anche ricordare che R8 esiste da molti anni.
Siamo consapevoli che, sebbene abbia consentito un'ottimizzazione di grande impatto, la sua configurazione richiede ancora molta attenzione.
Le chiamate riflessive implicano che gli input sono imprevedibili e possono causare arresti anomali in fase di runtime.
In passato, questo ha spinto sia i team delle app sia quelli delle librerie ad aggiungere regole di conservazione eccessivamente ampie solo per andare sul sicuro.
Sappiamo che il 41% delle app più importanti è al di sotto dei livelli di ottimizzazione del 25% definiti da Google Play, ma sappiamo anche che il 13% delle app più importanti sta già raggiungendo il triplo di questa soglia.
Questo numero viene raggiunto da app di un'ampia varietà di settori e percorsi dell'utente.
Inoltre, è possibile ottenere punteggi di ottimizzazione elevati per la tua app, a condizione che tu disponga degli strumenti giusti e della visibilità adeguata per gestire sia le tue regole di conservazione sia quelle ereditate dalle librerie.
Prima di approfondire i nuovi strumenti e i miglioramenti che ti aiuteranno in questo, volevamo anche evidenziare alcuni altri miglioramenti di R8 dell'anno scorso che semplificano anche questa ottimizzazione.
Innanzitutto, i nuovi modelli di app includono un ambito del pacchetto, utile per garantire un livello minimo di ottimizzazione fin dall'inizio.
L'analizzatore della configurazione R8, di cui parleremo tra un attimo, è ora supportato nelle versioni precedenti di AGP.
Inoltre, è supportato in modo non ufficiale per Bazel.
In AGP 9.2, le primitive delle coroutine sono diventate due volte più veloci.
Inoltre, grazie ai miglioramenti di R8 apportati in AGP 9.2, Tinder ha registrato una riduzione del 13% degli ANR dopo l'upgrade dalla versione 9.0.1.
AGP 9.3 introduce anche un'API R8 riprogettata con una denominazione più chiara, valori predefiniti più semplici per le regole predefinite e la riduzione delle risorse, nonché il supporto della directory di origine AGP per le regole di conservazione.
android {
buildTypes {
release {
optimization {
// no need to specify default
// rules and resource shrinking
enabled = true
}
}
}
...
}android {
buildTypes {
release {
// Enables code optimizations.
isMinifyEnabled = true
// Enables resource shrinking.
isShrinkResources = true
proguardFiles(
// Default rules.
getDefaultProguardFile(
"proguard-android-optimize.txt"
),
... // list out each file with keep rules
)
...
}
}
...
}Gli aggiornamenti che tratteremo oggi sono anche miglioramenti all'esperienza degli sviluppatori.
Inizieremo con come sfruttare R8 Configuration Analyzer e la skill R8 per identificare l'overhead delle regole legacy e determinare un percorso di miglioramento.
Poi, Chris spiegherà come passare da regole di conservazione ampie e con caratteri jolly alle nuove annotazioni precise, sicure per i tipi e generate automaticamente.
Infine, vedremo come utilizzare i nuovi strumenti di convalida dell'interfaccia a riga di comando per garantire un'ottimizzazione aggressiva senza introdurre arresti anomali.
Quindi, l'R8 Config Analyzer è il punto di partenza ideale per l'indagine sull'ottimizzazione DEX.
Se non hai mai utilizzato lo strumento, ecco come si presenta.
Si tratta di un report HTML che visualizza le informazioni sulla configurazione di R8.
Questo report riguarda la mia app, che include una libreria chiamata Analytics Bloat.
Come puoi immaginare, non si tratta di un'app o di una libreria reale, ma è evidenziata in blu in alto.
L'analizzatore della configurazione misura quanto segue: riduzione, ovvero la percentuale di classi, metodi e campi che R8 non può ridurre in base alle regole di conservazione.
Questo valore viene calcolato dopo che R8 esegue il primo passaggio di riduzione.
Misura anche l'ottimizzazione, ovvero la percentuale di codice aperta alle ottimizzazioni dell'intero programma, come l'incorporamento dei metodi e l'unione delle classi.
E l'offuscamento, ovvero la percentuale di membri che possono essere offuscati o rinominati con identificatori più brevi.
Voglio anche ricordare che, poiché R8 Config Analyzer calcola questi punteggi prima delle ottimizzazioni finali e della compilazione DEX, i valori che vedi in questo report per l'ottimizzazione, la riduzione e l'offuscamento non corrisponderanno esattamente ai valori che vedi in Play Console.
Questo perché Play misura l'output DEX finale.
Il motivo di questa discrepanza è garantire che, durante lo sviluppo, tu possa accedere a un ciclo di feedback locale rapido.
Tuttavia, i punteggi che vedi in R8 Config Analyzer sono comunque molto utili per convalidare il potenziale di ottimizzazione DEX prima del caricamento sul Play Store.
Il report include anche la tabella Analisi delle regole di conservazione, che è la visualizzazione principale che vedi quando accedi al report.
Da qui, puoi fare clic su qualsiasi regola e visualizzare il numero esatto di classi, metodi e campi che una regola specifica sta conservando, in modo da poter vedere quali regole stanno conservando più del dovuto.
Contiene anche regole incluse, che sono quelle ingrandite qui.
Si tratta di regole che si sovrappongono a una regola più ampia.
Queste vengono evidenziate in modo che tu possa rimuovere quella generica e mantenere solo quella più specifica.
Mostra anche le regole inutilizzate, ovvero quelle che possono essere eliminate senza rischi per la stabilità dell'app.
Config Analyzer è stato progettato per risolvere alcuni problemi, a partire dall'accumulo di regole legacy.
In precedenza, senza la visibilità per regola, l'eliminazione delle regole precedenti era un po' rischiosa.
Quindi, i team tendevano a lasciarli stare o ad aggiungere caratteri jolly più ampi.
In secondo luogo, ti aiuta a scoprire i punti ciechi delle regole della libreria.
Quindi, quando aggiungi un SDK, storicamente è stato difficile capire se una regola inclusa in quella libreria è troppo ampia o se è necessaria, ad esempio, 10 o più versioni dopo.
Inoltre, parleremo più approfonditamente delle librerie tra poco.
In terzo luogo, ti aiuta a valutare l'impatto dell'ottimizzazione.
In precedenza, non era facile capire se una regola conservava due classi o 2000.
Vediamo come funziona R8 nel contesto della mia app e della libreria che abbiamo esaminato in precedenza.
Qui ho un modulo dell'app e tre librerie.
Presta molta attenzione alla libreria tre sulla destra.
Si tratta della libreria Analytics Bloat, che include una regola di conservazione a livello di pacchetto.
Innanzitutto, R8 identifica il punto di accesso principale da tutte le regole keep unite, evidenziate qui in giallo.
In genere, questi dovrebbero essere i punti di accesso principali dell'app, come le classi nel manifest.
Inoltre, a causa della regola di conservazione con caratteri jolly della libreria Analytics Bloat, ogni singola classe della libreria viene trattata come un punto di accesso.
Successivamente, R8 traccia la raggiungibilità nel grafico delle chiamate a partire da queste radici.
Qualsiasi classe raggiungibile da una radice viene contrassegnata per la conservazione in verde, mentre le classi non referenziate vengono classificate come codice inattivo e sono contrassegnate in rosso qui.
R8 esegue quindi il tree shaking e rimuove il codice non utilizzato senza riferimenti.
Quindi, dopo questo tree shaking, o la prima fase di riduzione, questo è ciò che rimane.
La nostra app ha un punteggio di riduzione del 42,5%, che è superiore alla soglia del 25% di Google Play, ma non è ancora esattamente dove vorremmo che fosse.
Questo è dovuto in gran parte alla regola di conservazione a livello di pacchetto della libreria di analisi.
Ciò significa che solo 17 delle 40 classi rimanenti possono essere ulteriormente ridotte.
Il punto che voglio sottolineare è che una regola di conservazione errata può causare gravi problemi all'ottimizzazione dell'app.
Tenendo conto di questo, concentriamoci rapidamente sulle librerie.
Nell'ultimo anno, il nostro team ha investito molto per capire in che modo le regole di conservazione dei consumatori della libreria influiscono sull'ottimizzazione per l'ecosistema.
Molte librerie vengono fornite con regole eccessivamente ampie in nome della stabilità.
In alternativa, specificano regole generali nella documentazione per gli sviluppatori di app, che questi ultimi devono aggiungere alle proprie app.
Tuttavia, l'invio con regole ampie non è necessario per la stabilità e influisce negativamente sulle prestazioni, con costi che gravano principalmente su di te in qualità di sviluppatore di app.
Abbiamo visto casi in cui l'intera libreria viene conservata da una regola di conservazione a livello di pacchetto, come la libreria di analisi, e nel caso di quella libreria, ciò ha avuto un impatto di vasta portata sul potenziale di ottimizzazione della mia app.
Voglio anche ricordare che nemmeno i nostri SDK di Google erano immuni da questo problema e più di una dozzina di librerie Google hanno corretto regole di conservazione eccessivamente ampie nell'ultimo anno.
Quindi, se vuoi utilizzare l'analizzatore di configurazione, è integrato direttamente in AGP 9.3, ma è disponibile anche per le versioni precedenti se esegui l'upgrade della versione di R8.
L'esecuzione di questo comando genererà il report HTML che abbiamo mostrato, con le regole di conservazione, gli impatti sulla riduzione, l'ottimizzazione e l'offuscamento per le singole regole e per l'app nel suo complesso.
Se esegui una build di release R8 completa, il report viene generato automaticamente e può essere trovato in questo percorso file.
Il vantaggio di questa attività autonoma è che richiede meno tempo per la generazione rispetto alla build completa.
Il team di WHOOP, che potrebbe essere qui da qualche parte, ha eseguito l'analizzatore della configurazione e ha ottenuto una riduzione del 74%, un'offuscamento del 74% e un'ottimizzazione dello 0%.
Come puoi immaginare, il team è rimasto davvero sorpreso di vedere questo valore di ottimizzazione.
La visibilità offerta dall'analizzatore di configurazione ha permesso di scoprire che la build utilizzava un file ProGuard legacy che disattivava l'ottimizzazione. Passando alla versione aggiornata, è stato possibile attivare l'ottimizzazione per l'app.
Ciò ha portato a un'ottimizzazione del 55% e a una riduzione del 4% delle dimensioni dell'APK.
In combinazione con i profili di riferimento, hanno migliorato i tempi di avvio a freddo del 38% e ridotto i frame bloccati eccessivi del 28%.
La schermata a sinistra è senza profili di riferimento, mentre quella a destra li include e, come puoi vedere, l'avvio dell'app è più veloce con l'aggiunta dei profili di riferimento.
Riproduciamo di nuovo il video.
Perfetto.
Questo link ti porterà alla guida per l'analizzatore della configurazione. Esaminare manualmente il report è un'ottima idea, ma come puoi immaginare, a seconda dello stato della configurazione R8 della tua app, potrebbe richiedere molto tempo.
Per velocizzare le cose, abbiamo una skill R8 Analyzer.
$ android skills list
$ android skills add r8-analyzerPuoi chiedere all'agente di analizzare la tua configurazione R8 e la skill non solo genera il report di R8 Configuration Analyzer, ma ti indirizza anche alle regole più efficaci.
Ecco un esempio di output. Per evidenziare alcuni dettagli, viene indicato il punteggio di ottimizzazione del 42%, il che significa che il 52% (anzi, il 58%) del codebase non può essere ottimizzato da R8.
Se esaminiamo nello specifico la sezione delle regole keep, la regola keep a livello di pacchetto per la libreria analytics bloat impedisce l'ottimizzazione del 22% del codebase e l'abilità consiglia anche di perfezionare questa regola keep poiché, come già sappiamo, è troppo ampia.
Ora puoi diagnosticare la causa del bloatware dell'app e del blocco dell'ottimizzazione DEX utilizzando l'analizzatore di configurazione R8 e la skill.
Ma una volta trovata una regola keep troppo generica, come si fa a sostituirla in modo sicuro senza introdurre accidentalmente un arresto anomalo imprevisto?
Ora passo la parola a Chris, che vi mostrerà come passare dalle regole keep legacy alle nuove annotazioni keep generate automaticamente e con sicurezza dei tipi.
Grazie, Breana.
Bene, parliamo di come siamo arrivati a questo punto, ok?
Con questa libreria di analisi gonfia, con tutte queste regole di conservazione che sono-- con questa regola di conservazione che sta conservando praticamente tutto, il problema principale che stiamo riscontrando è la riflessione.
Diamo un'occhiata a un po' di codice riflessivo, giusto per rinfrescarci la memoria.
fun createWithReflection(className: String): MyObject {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as MyObject
}Questa è una funzione di istanziazione di un oggetto che si limita a eseguire l'inflation di un particolare oggetto, e lo fa in modo riflessivo.
Ora, lo svantaggio di questo, nel contesto dell'ottimizzazione DEX, è che ritarda tutte queste chiamate statiche fino al runtime.
Guardando semplicemente il codice, a seconda della provenienza del nome della classe, il nostro ottimizzatore R8 non è in grado di vedere cosa sta succedendo qui.
Queste sono le situazioni che interrompono l'ottimizzazione Dex per impostazione predefinita, quindi in genere devi aggiungere una regola Keep.
Ora, l'ideale sarebbe evitare questo problema, giusto?
Idealmente, dovremmo evitare del tutto la reflection, soprattutto ora nel 2026, dove abbiamo strumenti di generazione di codice straordinari per la serializzazione e l'inserimento delle dipendenze, che possono eliminare completamente questa categoria di problemi.
Ma non è tutto, giusto?
In alcuni casi, la riflessione è ancora necessaria.
Vediamo quindi come è successo esattamente in questa libreria gonfia.
Ecco un esempio particolare, e comune, di dipendenza facoltativa.
// Problem 1 - optional dependency
fun tryInitCrashReporter(context: Context): EventTransport? {
return runCatching {
Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
.getDeclaredConstructor(Context::class.java)
.newInstance(context) as? EventTransport
}.getOrNull()
}
// Problem 2 - dynamically instantiate subclass
fun createEventFilter(className: String): EventFilter {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as? EventFilter
}
// Problem 3 - serialization
fun serializeEvent(event: UserActionEvent): JSONObject {
val json = JSONObject()
for (field in event.javaClass.declaredFields) {
field.isAccessible = true
json.put(field.name, field.get(event))
}
return json
}In questo caso, stiamo espandendo un tipo specifico non accessibile nel classpath della libreria, quindi dobbiamo farlo con una stringa.
Questo fa riferimento a questa particolare classe che potrebbe essere presente o meno al momento della creazione della libreria, quindi è molto ragionevole che questa libreria utilizzi la riflessione per trovare un membro all'interno di una libreria correlata.
Un altro caso che abbiamo qui è la creazione dinamica di istanze di sottoclassi.
Quindi, soprattutto se si tratta di un file manifest o di un elemento inviato dal lato server, informiamo l'app locale: "Ehi, per favore, gonfia questa cosa, crea questa cosa in ritardo".
Infine, questa terza categoria che vediamo qui è la serializzazione imbarazzante.
In pratica, ho una serializzazione JSON che viene eseguita eseguendo l'iterazione di tutti i membri.
È assolutamente disgustoso, ma, sai, questo genere di cose a volte rimane.
Non sempre viene risolto nel momento in cui è necessario.
Ma parliamo del risultato di tutto questo, giusto?
Man mano che queste chiamate riflessive si accumulano, hai più motivazione a fare la cosa più semplice e a ottimizzare l'esperienza degli sviluppatori rispetto a quella degli utenti.
Non è una bella sensazione, quindi aggiungerò un'attività da fare e andrà tutto bene.
# TODO: clean this up
-keep com.example.analytics.bloat.** { * };Un giorno o l'altro, mi deciderò a sistemarlo, no?
Parliamo di cosa dovremmo fare, o almeno di cosa avremmo dovuto fare prima di oggi, per scrivere una regola Keep ideale in questo caso.
fun createEventFilter(className: String): EventFilter {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as? EventFilter
}Ok, allaccia le cinture.
Quindi, la prima parte è che vogliamo mantenere la classe specifica che restituiamo da questa funzione riflessiva.
### Keep the EventFilter
-keep class com.example.analytics.bloat.EventFilter {
*
}Anzi, no, aspetta.
Sai una cosa?
In realtà non è così.
Questa è una classe Kotlin, devo assicurarmi che sia di tipo stringa, quindi inserisco KT.
### Keep the actual EventFilter
-keep class com.example.analytics.bloat.EventFilterKt {
*
}In effetti, la cosa importante qui è che non sto restituendo un EventFilter, ma una sottoclasse di EventFilter, quindi devo mantenere le classi che estendono EventFilter per poter eseguire l'inflation correttamente.
### Keep the EventFilter subclasses
-keep class ** extends com.example.analytics.bloat.EventFilterKt {
*
}E in realtà, se voglio farlo in modo più efficiente, ora, avrebbe funzionato, ma per renderlo più efficiente, come un buon sviluppatore di librerie, cosa dovrei fare ancora di più?
Beh, dovrei dire -keepclasseswithmembers e mantenere solo le classi che hanno questo particolare costruttore <init> che sto utilizzando per l'inflating dell'oggetto.
### Keep the EventFilter subclasses, if they have this constructor
-keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
void <init>();
}E se voglio essere davvero gentile, aggiungerò allowaccessmodification per consentire un'ulteriore ottimizzazione su tutte le API che sono il target di questa regola Keep perché, sai, non voglio impedire un'ulteriore ottimizzazione da parte di R8, voglio cercare di essere un buon cittadino.
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
void <init>();
}Infine, l'ultimo passaggio che quasi nessuno esegue, perché richiede un po' di lavoro in più, ma è ottimo per una libreria, è renderla condizionale.
### -if makes this conditional, critical for libraries
-if class com.example.analytics.bloat.EventToolsKt {
class com.example.analytics.bloat.EventFilterKt createEventFilter(java.lang.String);
}
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
void <init>();
}Ora, concettualmente, non è necessario conservare tutte queste sottoclassi, a meno che non si chiami effettivamente questa funzione, ed è ciò che fa questa istruzione if all'inizio di una regola Keep.
Potrei chiedere di alzare la mano, ma so che quasi nessuno lo fa, giusto?
Ad esempio, scrivendolo effettivamente, rendendolo condizionale.
E se potessimo migliorare un po' questa situazione?
Diamo un'occhiata a, vediamo, tipo, ok.
Beh, forse c'è un altro modo per farlo.
Abbiamo parlato di cosa succede se scrivo una regola di conservazione specifica e scritta manualmente.
Beh, tecnicamente è possibile, ma è difficile da scrivere, è difficile da leggere ed è molto difficile da gestire perché contiene molti piccoli elementi di tipo stringa che sono facili da interrompere quando succede qualcosa sotto.
Sotto qualcosa, sotto cambia.
E abbiamo delle alternative.
Abbiamo annotazioni come @Keep.
@Keep è presente nel pacchetto di annotazioni AndroidX da molto tempo, ma al giorno d'oggi è sconsigliato.
@androidx.annotation.Keep
class EventFilter { … }
@androidx.annotation.Keep
class SizeBasedEventFilter { … }
@androidx.annotation.Keep
class DropLogsEventFilter { … }Non è una soluzione ottimale per gli sviluppatori di librerie e, di fatto, è un po' vietata internamente, perché, sebbene sia più facile da gestire, è molto rigida e non presenta alcune delle ottimizzazioni di cui parlavo prima.
In un certo senso, non può.
Non consente l'analisi del sito di chiamata.
Ora, se guardiamo quel caso particolare che abbiamo visto prima, "Oh, beh, posso semplicemente mettere @Keep sul mio EventFilter, sono a posto, giusto?"
@Keep
class EventFilter { … }Beh, no.
@Keep non ha come target le sottoclassi, ma solo la classe.
@Keep
class EventFilter { … }
@Keep
class SizeBasedEventFilter { … }
@Keep
class DropLogsEventFilter { … }Quindi, in realtà devo scegliere come target tutte le sottoclassi e ora sto scrivendo più annotazioni @Keep di quante ne avrei scritte con quella singola regola Keep che era difficile da scrivere.
Quindi, entrambi hanno dei problemi, giusto?
Sono facili da dimenticare, vengono scoperti in ritardo e non sono condizionali per il chiamante per impostazione predefinita.
Soprattutto per le librerie, vogliamo un modo migliore.
Vogliamo che ci sia un modo più semplice per ottenere risultati corretti fin da subito.
Uno dei problemi principali che vediamo qui è che questi elementi sono lontani dalla riflessione da cui ci stiamo effettivamente proteggendo.
In definitiva, la soluzione che vogliamo è qualcosa che si trova proprio accanto al codice di reflection dannoso che sta effettivamente gonfiando gli elementi presi di mira.
Iniziamo quindi a parlare di Jetpack Keep, la nuova libreria e il nuovo plug-in di AndroidX progettati per risolvere esattamente questo problema.
Quindi, per utilizzare… l'intero obiettivo di Jetpack Keep è iniziare generando regole Keep dalle annotazioni, ma anche andare oltre, generando annotazioni direttamente dalla riflessione già presente nel codebase.
La configurazione è molto semplice.
plugins {
id("com.android.library")
id("androidx.annotation.keep")
}
dependencies {
implementation("androidx.annotation.keep:keep")
...
}È un plug-in Gradle, è una libreria di annotazioni.
Ma vediamo come viene eseguito in fase di build.
Quindi, se ho questo esempio precedente in cui sto eseguendo l'inflating di una sottoclasse di un tipo noto, cosa devo fare?
Eseguo semplicemente lintFix.
./gradlew analyticsbloat:lintFixLint analizza tutta la riflessione che avviene all'interno di questo metodo e dice: "Ehi, hai bisogno di questa annotazione".
@UsesReflectionToConstruct(
classConstant = EventFilter::class,
parameterTypes = []
)
fun createEventFilter(className: String): EventFilter {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as? EventFilter
}Questa annotazione esprime esattamente ciò che fa la riflessione all'interno di questa chiamata.
Dice: "Sto espandendo una classe di questo tipo".
In questo caso specifico, EventFilter è una classe nota.
Non utilizzo una stringa per fare riferimento alla risorsa perché non è necessario.
Questo aggiunge un ulteriore livello di sicurezza e sto persino codificando parameterTypes in modo che indichi esattamente quale costruttore viene conservato.
Ma cosa fa?
Ad esempio, in che modo questo influisce su un'eventuale build R8?
Beh, genera la regola keep e, come hai visto prima, questa è esattamente la stessa regola keep ideale che avevamo prima.
# Generated Keep Rule
-if class com.example.analytics.bloat.EventTools {
class com.example.analytics.bloat.EventFilter createEventFilter(java.lang.String);
}
-keepclasseswithmembers,allowaccessmodification class ** extends class com.example.analytics.bloat.EventFilter {
void <init>();
}Ha come target le stesse classi, le sottoclassi ed è condizionale su questo punto di riflessione.
E non devo mantenere un… non devo preoccuparmi di annotare tutte le sottoclassi.
Non devo conservare un file di regole di conservazione separato altrove.
Questo è un modo molto più semplice per gestire le regole di conservazione.
Torniamo all'esempio della dipendenza facoltativa.
@UsesReflectionToConstruct(
className = "com.example.analytics.bloat.crash.CrashReporterTransport",
parameterTypes = [Context::class]
)
fun tryInitCrashReporter(context: Context): EventTransport? {
return runCatching {
Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
.getDeclaredConstructor(Context::class.java)
.newInstance(context) as? EventTransport
}.getOrNull()
}In questo caso, la classe non era nel percorso della classe, ma posso comunque farvi riferimento.
Lo faccio semplicemente utilizzando una stringa, ma tutto il resto, i tipi di parametri, possono essere indicati tramite riferimenti di classe, quindi lo faccio.
Anche in questo caso, generiamo la regola di conservazione ideale per questo caso.
# Generated Keep Rule
-if class com.example.analytics.bloat.CrashConnection {
class com.example.analytics.bloat.EventTransport tryInitCrashReporter(android.content.Context);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.crash.CrashReporterTransport {
void <init>(android.content.Context);
}È condizionale, specifico e mirato e consente di eseguire ottimizzazioni, ove possibile, su queste classi mirate.
Ecco la riflessione sui campi e, in questo caso particolare, stavo eseguendo l'iterazione su tutti i campi all'interno di un oggetto, ma anche in questo caso, gestiamo la cosa senza problemi dicendo di scegliere come target tutti i campi con questo operatore asterisco.
// TODO: migrate to Kotlinx Serialization or Moshi codegen.
@UsesReflectionToAccessField(
classConstant = UserActionEvent::class,
fieldName = "*"
)
fun serializeEvent(event: UserActionEvent): JSONObject {
val json = JSONObject()
for (field in event.javaClass.declaredFields) {
field.isAccessible = true
json.put(field.name, field.get(event))
}
return json
}In questo modo posso scegliere come target tutti i campi e, guarda, genera l'esatta regola keep che ti aspetteresti per questa, sai, riflessione incredibilmente sfortunata che non dovrebbe essere nella mia app, ma, hey, tornerò e la pulirò, lo prometto.
# Generated Keep Rule
-if class com.example.analytics.bloat.serialization.EventSerializer {
class org.json.JSONObject serializeEvent(com.example.analytics.bloat.model.UserActionEvent);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.model.UserActionEvent {
<fields>;
}Quindi, cosa fa Jetpack Keep?
Riduce drasticamente la manutenzione delle regole di conservazione.
Tutto ciò che puoi generare automaticamente viene rimosso dalla tua area di interesse e dalla tua area di rischio per il mantenimento delle regole di conservazione.
Inoltre, ti offre il determinismo di lint.
Non si limita a correggere gli errori durante l'iterazione e il tentativo di migrazione, ma rileva nuove riflessioni e modifiche riflessive ogni volta che il codice cambia, e questa sicurezza è assolutamente fondamentale.
Posso utilizzarlo?
Quali sono le dipendenze in questo caso?
Il plug-in e lint richiedono AGP 8.7.
Dovrebbero essere quasi tutti.
ma è anche molto flessibile in termini di utilizzo.
Puoi utilizzarlo all'interno delle app.
Puoi utilizzarlo all'interno di librerie Android, AAR, librerie KMP e JAR, con un po' di cablaggio manuale in questo caso.
Ma la caratteristica principale è che è sicuro da usare come sviluppatore di librerie.
Anche se qualcuno utilizza una versione precedente di R8 o uno strumento di terze parti che utilizza la tua libreria, esportiamo comunque le regole consumer standard effettive in modo che chiunque possa utilizzarle senza doversi preoccupare di utilizzare un nuovo formato incompatibile.
Ora che ho parlato di ciò che Jetpack Keep può fare per aiutarti a ottimizzare e a preoccuparti meno di proteggere le chiamate di codice riflessive, parliamo un po' delle nuove funzionalità che abbiamo lanciato di recente nella CLI Android per controllare l'ultima fase di rilascio.
Breana ha accennato prima che esistono strumenti in cui i risultati di Play ti forniranno informazioni sulle dimensioni, sulle dimensioni DEX e sulle percentuali di ottimizzazione.
Ora abbiamo iniziato a renderlo disponibile anche tramite la CLI Android.
Utilizza esattamente la stessa logica del Play Store, quindi senza dover caricare un bundle sul Play Store, puoi semplicemente analizzarlo tramite l'interfaccia a riga di comando.
$ android analyze <mybundle.aab>
$ android analyze <mybundle.apk>Inoltre, ricevi le stesse informazioni direttamente e immediatamente con l'analisi locale.
In questo caso specifico, l'app riceve un avviso relativo a un tasso di ottimizzazione molto basso e viene anche informata: "Queste sono le dimensioni DEX e queste sono le percentuali di ottimizzazione", esattamente come farebbe il Play Store.
Ma, cosa molto utile, abbiamo anche un output JSON, quindi se vuoi scrivere uno script per automatizzare i test di regressione in caso di calo drastico di uno di questi valori, perché, ripeto, questi sono i numeri che Play applica, vuoi tenerne traccia, puoi farlo molto facilmente con questo valore JSON, questi report JSON.
{
"optimization": {
"bundleAnalysisResult": {
"agpVersion": "9.3.2",
"r8Json": {
"version": "9.3.16",
"options": {
"isRepackageClassesEnabled": true,
"isProguardCompatibilityModeEnabled": false
},
"stats": {
"shrinkingRatio": 0.2306,
"optimizationRatio": 0.2351,
"obfuscationRatio": 0.2322
},
...
}
"mappingObfuscationRatio": 0.21631891,
"dexHeuristicObfuscationRatio": 0.25118626,
"uncompressedDexSizeBytes": "16198152"
}Qui vediamo che sono presenti esattamente gli stessi numeri.
Questa non è l'unica funzionalità di android analyze.
android analyze può essere eseguito anche sulle librerie.
$ android analyze <library.aar>
$ android analyze <library.jar>E come ha detto Breana, ci sono molti problemi con le librerie che vengono eseguite con regole di conservazione sfortunate al loro interno.
Quindi, utilizziamo questo approccio per esaminare alcuni di questi problemi.
Quindi, quando eseguo questo comando su un particolare AAR che ho, posso vedere che questa libreria ha una serie di problemi evidenziati proprio in alto.
Questa libreria ha un rapporto di conservazione elevato, il che significa che conserva troppi dati con regole di conservazione illimitate.
Contiene regole di conservazione ampie che impediscono l'ottimizzazione e ci sono alcuni numeri di seguito che ti dicono esattamente quanto sia grave. Contiene anche regole di conservazione che, forse la cosa più spaventosa di tutte, non hanno un target, il che probabilmente significa che hanno come target qualcun altro in un modo illimitato.
Code smell molto pericolosi.
Quindi, puoi controllare tutto questo in un AAR, soprattutto come publisher di librerie, questo è un ottimo modo per assicurarti di non spedire regole di conservazione errate e di non farle trapelare quando qualcuno forse non così esperto nella scrittura di regole di conservazione sta lavorando alla libreria.
Qui puoi vedere che viene restituito anche un rapporto di conservazione.
Questo è un buon modo per valutare la qualità complessiva della tua raccolta.
Se ottimizzo una singola libreria in modo completamente autonomo, senza riferimenti, con R8, dovrei vedere che è di zero byte, giusto?
Dovrebbe essere praticamente vuoto perché non contiene nulla.
Sarebbe l'ideale.
In questo caso, non lo è.
Puoi visualizzare direttamente le dimensioni di R8 e D8.
In effetti, mettiamo in evidenza anche alcune delle regole di conservazione peggiori per renderle un po' più visibili.
Anche questo ha un formato JSON, quindi, che tu sia uno sviluppatore di app che cerca di controllare gli aggiornamenti della libreria o uno sviluppatore di librerie che cerca di prevenire grandi regressioni nella qualità della libreria, puoi eseguire la stessa analisi e prevenire questi problemi con un semplice script che racchiude la CLI Android.
{
"optimization": {
"libraryAnalysisResult": {
"dex": {
"d8CompileResult": {
"dexSizeBytes": 4523352
},
"r8CompileResult": {
"dexSizeBytes": 3266596,
"configuration": {
"usedPackageWideKeepRules": [
{
"source": "-keep class com.example.analytics.bloat.** { *; }",
"keptItemCount": 125579,
},
...
],
"unusedPackageWideKeepRules": [
{
"source": "-keep class some.other.library.** { *; }",
},
...
]
},
...
}Quindi, se trovi un problema in una libreria, prova innanzitutto ad aggiornarla.
Come ha detto Breana, abbiamo corretto più di una dozzina di librerie Google per correggere alcune regole di conservazione molto sfortunate e ci sono altre librerie che hanno ricevuto correzioni, ma non vengono aggiornate dalle app che le utilizzano.
È un ottimo primo passo.
In secondo luogo, puoi cercare un problema e contattare lo sviluppatore della libreria e, se possibile, allegare un report dell'analizzatore che gli dica: "Ehi, stai conservando troppi dati.
Aiutatemi a ottimizzare la mia app."
Per riassumere, abbiamo parlato di R8 Config Analyzer, che ti aiuta a esaminare tutto ciò che accade nella build R8, tutto ciò che indica cosa è ottimizzabile e cosa no.
Abbiamo parlato di Jetpack Keep, la nuova libreria che ti aiuta a scrivere automaticamente le regole di conservazione senza muovere un dito.
E CLI Android, il nuovo strumento che ti aiuta a controllare e assicurarti, a ricontrollare l'ultimo passaggio e ad assicurarti che tu, il tuo agente, l'ingegnere junior del tuo team non abbiate rovinato nulla per i vostri consumatori.
Quindi, se vuoi saperne di più, consulta la documentazione relativa a R8, all'analizzatore di configurazione e a Jetpack Keep.
Inoltre, è disponibile la documentazione sulla CLI Android che illustra il comando analyze e come utilizzarlo.
Grazie mille.
android {
buildTypes {
release {
optimization {
// no need to specify default
// rules and resource shrinking
enabled = true
}
}
}
...
}android {
buildTypes {
release {
// Enables code optimizations.
isMinifyEnabled = true
// Enables resource shrinking.
isShrinkResources = true
proguardFiles(
// Default rules.
getDefaultProguardFile(
"proguard-android-optimize.txt"
),
... // list out each file with keep rules
)
...
}
}
...
}$ android skills list
$ android skills add r8-analyzerfun createWithReflection(className: String): MyObject {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as MyObject
}// Problem 1 - optional dependency
fun tryInitCrashReporter(context: Context): EventTransport? {
return runCatching {
Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
.getDeclaredConstructor(Context::class.java)
.newInstance(context) as? EventTransport
}.getOrNull()
}
// Problem 2 - dynamically instantiate subclass
fun createEventFilter(className: String): EventFilter {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as? EventFilter
}
// Problem 3 - serialization
fun serializeEvent(event: UserActionEvent): JSONObject {
val json = JSONObject()
for (field in event.javaClass.declaredFields) {
field.isAccessible = true
json.put(field.name, field.get(event))
}
return json
}# TODO: clean this up
-keep com.example.analytics.bloat.** { * };fun createEventFilter(className: String): EventFilter {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as? EventFilter
}### Keep the EventFilter
-keep class com.example.analytics.bloat.EventFilter {
*
}### Keep the actual EventFilter
-keep class com.example.analytics.bloat.EventFilterKt {
*
}### Keep the EventFilter subclasses
-keep class ** extends com.example.analytics.bloat.EventFilterKt {
*
}### Keep the EventFilter subclasses, if they have this constructor
-keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
void <init>();
}### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
void <init>();
}### -if makes this conditional, critical for libraries
-if class com.example.analytics.bloat.EventToolsKt {
class com.example.analytics.bloat.EventFilterKt createEventFilter(java.lang.String);
}
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
void <init>();
}@androidx.annotation.Keep
class EventFilter { … }
@androidx.annotation.Keep
class SizeBasedEventFilter { … }
@androidx.annotation.Keep
class DropLogsEventFilter { … }@Keep
class EventFilter { … }@Keep
class EventFilter { … }
@Keep
class SizeBasedEventFilter { … }
@Keep
class DropLogsEventFilter { … }plugins {
id("com.android.library")
id("androidx.annotation.keep")
}
dependencies {
implementation("androidx.annotation.keep:keep")
...
}./gradlew analyticsbloat:lintFix@UsesReflectionToConstruct(
classConstant = EventFilter::class,
parameterTypes = []
)
fun createEventFilter(className: String): EventFilter {
return Class.forName(className)
.getDeclaredConstructor()
.newInstance() as? EventFilter
}# Generated Keep Rule
-if class com.example.analytics.bloat.EventTools {
class com.example.analytics.bloat.EventFilter createEventFilter(java.lang.String);
}
-keepclasseswithmembers,allowaccessmodification class ** extends class com.example.analytics.bloat.EventFilter {
void <init>();
}@UsesReflectionToConstruct(
className = "com.example.analytics.bloat.crash.CrashReporterTransport",
parameterTypes = [Context::class]
)
fun tryInitCrashReporter(context: Context): EventTransport? {
return runCatching {
Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
.getDeclaredConstructor(Context::class.java)
.newInstance(context) as? EventTransport
}.getOrNull()
}# Generated Keep Rule
-if class com.example.analytics.bloat.CrashConnection {
class com.example.analytics.bloat.EventTransport tryInitCrashReporter(android.content.Context);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.crash.CrashReporterTransport {
void <init>(android.content.Context);
}// TODO: migrate to Kotlinx Serialization or Moshi codegen.
@UsesReflectionToAccessField(
classConstant = UserActionEvent::class,
fieldName = "*"
)
fun serializeEvent(event: UserActionEvent): JSONObject {
val json = JSONObject()
for (field in event.javaClass.declaredFields) {
field.isAccessible = true
json.put(field.name, field.get(event))
}
return json
}# Generated Keep Rule
-if class com.example.analytics.bloat.serialization.EventSerializer {
class org.json.JSONObject serializeEvent(com.example.analytics.bloat.model.UserActionEvent);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.model.UserActionEvent {
<fields>;
}$ android analyze <mybundle.aab>
$ android analyze <mybundle.apk>{
"optimization": {
"bundleAnalysisResult": {
"agpVersion": "9.3.2",
"r8Json": {
"version": "9.3.16",
"options": {
"isRepackageClassesEnabled": true,
"isProguardCompatibilityModeEnabled": false
},
"stats": {
"shrinkingRatio": 0.2306,
"optimizationRatio": 0.2351,
"obfuscationRatio": 0.2322
},
...
}
"mappingObfuscationRatio": 0.21631891,
"dexHeuristicObfuscationRatio": 0.25118626,
"uncompressedDexSizeBytes": "16198152"
}$ android analyze <library.aar>
$ android analyze <library.jar>{
"optimization": {
"libraryAnalysisResult": {
"dex": {
"d8CompileResult": {
"dexSizeBytes": 4523352
},
"r8CompileResult": {
"dexSizeBytes": 3266596,
"configuration": {
"usedPackageWideKeepRules": [
{
"source": "-keep class com.example.analytics.bloat.** { *; }",
"keptItemCount": 125579,
},
...
],
"unusedPackageWideKeepRules": [
{
"source": "-keep class some.other.library.** { *; }",
},
...
]
},
...
}