Herramientas y rendimiento

Reglas de conservación con seguridad de tipos y optimización de DEX

Bocinas

Acerca de

Acerca de la charla

Las apps para Android suelen enviarse con una optimización insuficiente debido a reglas de conservación demasiado conservadoras, que suelen provenir de bibliotecas de terceros, lo que genera APKs inflados, inicios en frío más lentos y un rendimiento degradado en el tiempo de ejecución.

Con los nuevos umbrales de rendimiento de Google Play para la optimización de DEX, la visibilidad de las apps ahora depende directamente de mantenerse a la vanguardia de la curva de optimización.

En esta charla, se presenta un flujo de trabajo moderno de R8 para abordar el exceso de reglas. Aprenderás a hacer lo siguiente:

  • Usa el analizador de configuración de R8 y la habilidad para identificar la sobrecarga de reglas heredadas.
  • Transición de reglas de conservación de comodines amplios a anotaciones generadas precisas y con seguridad de tipos.
  • Evita las regresiones de optimización con las nuevas herramientas de la CLI de Android.

Resumen

  • Google Play aplica umbrales de calidad de las apps que requieren más del 25% de optimización del código. Si los cumples, se reducirá el uso de memoria y, al mismo tiempo, mejorará la velocidad de inicio y las tasas de ANR.
  • El analizador de configuración de R8 genera un informe HTML que muestra qué reglas de conservación retienen el código. Destaca las reglas subsumidas y sin usar, y mide el potencial de optimización antes de la compilación.
  • Las reglas de conservación demasiado amplias, que suelen introducirse para evitar fallas de reflexión o que se heredan de las bibliotecas, perjudican la optimización de la app. Una sola regla de comodín para todo el paquete puede mantener decenas de clases sin usar.
  • Jetpack Keep usa lint para detectar la reflexión y generar anotaciones precisas y reglas de conservación condicionales directamente desde el código. Exporta reglas estándar del consumidor compatibles con versiones anteriores de R8.
  • La CLI de Android inspecciona los paquetes de aplicaciones y los AAR con la lógica exacta de Google Play sin necesidad de subirlos. Marca las reglas problemáticas y exporta informes JSON para realizar pruebas de regresión automatizadas.

Recursos

Gemini Notebook

Temas

  • dex-optimization
  • r8
  • keep-rules
  • jetpack-keep
  • android-cli
  • app-performance

Transcripción

Descargar transcripción

Introducción a la optimización de DEX

  • Hi, everyone!

  • Mi nombre es Breana y formo parte del equipo de Relaciones con Desarrolladores de Android.

  • Y yo soy Chris Craik, del equipo de rendimiento de Android.

  • Te damos la bienvenida a las reglas de conservación con seguridad de tipos y la optimización de DEX.

  • Hoy, hablaremos sobre qué es la optimización de DEX, por qué debería importarte y algunas actualizaciones recientes y emocionantes.

  • Durante mucho tiempo, la optimización de la DEX se consideró como algo que simplemente es bueno tener.

  • Por lo tanto, tal vez lo configures una vez y luego lo dejes como está, o tal vez lo hagas cuando tengas un tiempo dedicado.

  • Sin embargo, dado que la industria de los dispositivos móviles enfrenta importantes limitaciones en el suministro de hardware que alteran la disponibilidad de memoria de los dispositivos, un DEX bien optimizado es esencial para que tu app use menos memoria, se inicie más rápido, reduzca los ANR y mejore el rendimiento de renderización y del tiempo de ejecución en general.

  • Para mantener un ecosistema Android saludable y garantizar que tus apps sigan ofreciendo experiencias de primer nivel, Google Play anunció el nuevo requisito de calidad de las apps con umbrales de rendimiento que se aplicarán en febrero.

  • Esto incluye umbrales para la optimización de código DEX.

  • Las apps deben alcanzar un porcentaje superior al 25% en reducción, optimización y ofuscación.

  • ¿Qué significa esto para ti como desarrollador de Android?

  • El código DEX no optimizado no solo afecta negativamente la experiencia del usuario, sino que también puede tener implicaciones en la visibilidad y las capacidades de publicación de tu app en Play Store.

  • Sin embargo, estás en el lugar correcto y, al final de esta charla, tendrás las herramientas para no solo cumplir con estos requisitos, sino también superarlos.

  • La optimización del código DEX se puede lograr con R8, el optimizador de apps de Android.

  • Y, si bien R8 no es obligatorio si otro optimizador de apps puede hacer que tu app supere estos umbrales, es la ruta recomendada que se integra directamente en la cadena de herramientas de Android.

  • Y, si bien un paquete de aplicación DEX bien optimizado usa menos memoria, los beneficios principales aquí siguen estando relacionados con el rendimiento: menos ANR, un inicio más rápido y un mejor rendimiento del tiempo de ejecución.

  • Además, optimizar tu app tiene resultados positivos, reales y comprobados para tu app y tus usuarios.

  • Por ejemplo, con la adopción del modo completo de R8 y el perfeccionamiento de sus reglas de conservación, Grab pudo lograr un 78% de optimización, un 79% de reducción y un 82% de ofuscación para su app.

  • Es posible que estés mirando esta diapositiva y pienses: "Bueno, esos números son bastante altos, pero ¿qué significan en realidad?".

  • Como resultado de estas mejoras, combinadas con los perfiles de Baseline, redujeron los ANR en un 32% y mejoraron el inicio en un 35%.

  • Cuando las apps más populares adoptan R8 por primera vez, observan una disminución promedio del 38% en el tamaño de DEX y del 16% en los errores de ANR percibidos por el usuario.

  • Y si ya usas R8, tenemos excelentes noticias para ti.

  • En el caso de las apps que ya habilitaron R8 y, luego, toman medidas para mejorar aún más los niveles de optimización, reducción y ofuscación a más del 50%, se observa, en promedio, una disminución adicional del 25% en el tamaño de DEX, una disminución del 4% en el tiempo de inicio y una disminución adicional del 17% en los errores de ANR percibidos por el usuario, que se suma al 16% que mostramos en la diapositiva anterior.

  • Por eso, hoy queremos mostrarte cómo puedes lograr cifras como estas.

  • Abordaremos las herramientas que pueden ayudarte a optimizar tu código DEX y pasar de reglas de conservación demasiado amplias y mal configuradas a reglas específicas y limitadas.

  • Primero, también queremos mencionar que R8 existe desde hace muchos años.

  • Y reconocemos que, si bien permite una optimización de alto impacto, aún se requiere mucho cuidado para configurarla.

  • Las llamadas reflexivas implican que las entradas son impredecibles y pueden provocar fallas en el tiempo de ejecución.

  • Esto ha tentado tanto a los equipos de apps como a los de bibliotecas a agregar reglas de conservación demasiado amplias solo para no correr riesgos.

  • Sabemos que el 41% de las apps principales se encuentran por debajo de los niveles de optimización del 25% definidos por Google Play, pero también sabemos que el 13% de las apps principales ya están alcanzando tres veces ese umbral.

  • Y se trata de apps de una amplia variedad de industrias y recorridos del usuario que pueden alcanzar esta cifra.

  • Además, puedes lograr niveles de optimización altos para tu app, siempre y cuando tengas las herramientas y la visibilidad adecuadas para administrar tus propias reglas de conservación y las que se heredan de las bibliotecas.

  • Antes de profundizar en las nuevas herramientas y mejoras que te ayudarán con esto, también queremos destacar algunas otras mejoras de R8 del año pasado que también facilitan un poco esta optimización.

  • Primero, las nuevas plantillas de apps incluyen un alcance de paquete, que es útil para garantizar un nivel mínimo de optimización desde el principio.

  • El analizador de configuración de R8, del que hablaremos en un segundo, ahora es compatible con versiones anteriores del AGP.

  • También se admite de forma no oficial para Bazel.

  • En AGP 9.2, las primitivas de corrutinas se volvieron el doble de rápidas.

  • Como resultado de las mejoras de R8 que se realizaron en el AGP 9.2, Tinder observó una disminución del 13% en los ANR después de actualizar desde la versión 9.0.1.

  • Además, AGP 9.3 incluye una API de R8 rediseñada con nombres más claros, valores predeterminados más simples para las reglas predeterminadas y la reducción de recursos, y compatibilidad con el directorio de código fuente de AGP para las reglas de conservación.

    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
                )
                ...
            }
        }
        ...
    }
  • Las actualizaciones que abordaremos hoy también son mejoras en la experiencia de los desarrolladores.

  • Comenzaremos con cómo aprovechar el Analizador de configuración de R8 y la habilidad de R8 para identificar la sobrecarga de reglas heredadas y determinar una ruta de mejora.

  • Luego, Chris explicará cómo pasar de las reglas de conservación amplias con comodines a las nuevas anotaciones precisas, con seguridad de tipos y generadas de forma automática.

  • Por último, veremos cómo usar las nuevas herramientas de validación de la CLI para garantizar una optimización agresiva sin que se produzcan fallas.

Descripción general del analizador de configuración de R8

  • Por lo tanto, el Analizador de configuración de R8 es el lugar ideal para comenzar tu investigación de optimización de DEX.

  • Si no usaste la herramienta, así es como se ve.

  • Es un informe HTML que visualiza información sobre tu configuración de R8.

  • Este informe es para mi app, que incluye una biblioteca llamada Analytics Bloat.

  • Como puedes adivinar, esta no es una app ni una biblioteca real, pero se destaca en azul en la parte superior.

  • El analizador de configuración mide lo siguiente: la reducción, que es el porcentaje de clases, métodos y campos que las reglas de conservación no impiden que R8 reduzca.

  • Esto se calcula después de que R8 realiza su primer pase de reducción.

  • También mide la optimización, que es el porcentaje de código que está abierto a optimizaciones de todo el programa, como la integración de métodos y la combinación de clases.

  • Y la ofuscación, que es el porcentaje de miembros que se pueden ofuscar o a los que se les puede cambiar el nombre por identificadores más cortos.

  • También quiero mencionar que, debido a que el Analizador de configuración de R8 calcula estas puntuaciones antes de las optimizaciones finales y la compilación de DEX, los valores que ves en este informe para la optimización, la reducción y la ofuscación no coincidirán exactamente con los valores que ves en Play Console.

  • Esto se debe a que Play mide el resultado final de DEX.

  • El motivo de esta discrepancia es garantizar que, cuando desarrolles, puedas acceder a un ciclo de reacción local rápido.

  • Sin embargo, las puntuaciones que ves en el Analizador de configuración de R8 siguen siendo muy útiles para validar el potencial de optimización de DEX antes de subir la app a Play Store.

  • El informe también incluye la tabla Análisis de reglas de conservación, que es la vista principal que ves cuando ingresas al informe.

  • Desde allí, puedes hacer clic en cualquier regla y ver la cantidad exacta de clases, métodos y campos que conserva una regla específica para que puedas ver qué reglas conservan más de lo que deberían.

  • También tiene reglas subsumidas, que es lo que se muestra aquí.

  • Estas son reglas que se superponen con una regla más amplia.

  • Estas se destacan para que puedas quitar la amplia y conservar solo la más específica.

  • También te muestra las reglas sin usar, que son reglas que se pueden borrar sin riesgos para la estabilidad de tu app.

  • Por lo tanto, el Analizador de configuración se diseñó para abordar algunos problemas, comenzando con la acumulación de reglas heredadas.

  • Antes, sin la visibilidad por regla, era un poco riesgoso borrar las reglas antiguas.

  • Por lo tanto, los equipos los dejaban de lado o simplemente agregaban comodines más amplios.

  • En segundo lugar, te ayuda a descubrir puntos ciegos en las reglas de la biblioteca.

  • Por lo tanto, cuando agregas un SDK, históricamente ha sido difícil saber si una regla que se incluye con esa biblioteca es demasiado amplia o si incluso es necesaria, por ejemplo, 10 o más versiones después.

  • También hablaremos más sobre las bibliotecas en un momento.

  • Y, en tercer lugar, te ayuda a evaluar el impacto de la optimización.

  • Anteriormente, no había una forma sencilla de saber si una regla mantenía dos clases o 2,000.

  • Veamos cómo funciona R8 en el contexto de mi app y de la biblioteca que vimos antes.

  • Aquí tengo un módulo de app y tres bibliotecas.

  • Y presta mucha atención a la biblioteca tres de la derecha.

  • Esa es la biblioteca Analytics Bloat, que incluye una regla de conservación para todo el paquete.

  • Primero, R8 identifica el punto de entrada raíz de todas las reglas de conservación combinadas, que se destacan aquí en amarillo.

  • Por lo general, estos deben ser los puntos de entrada principales de la app, como las clases en el manifiesto.

  • Y debido a la regla de conservación de comodines de la biblioteca Analytics Bloat, cada clase de la biblioteca se trata como un punto de entrada.

  • Luego, R8 rastrea la accesibilidad en el gráfico de llamadas desde esas raíces.

  • Cualquier clase a la que se pueda acceder desde una raíz se marca para su retención en verde, mientras que las clases sin referencia se clasifican como código muerto y se marcan en rojo aquí.

  • Luego, R8 realiza la eliminación de código muerto y quita el código muerto sin referencia.

  • Por lo tanto, después de esta eliminación de código muerto o el primer pase de reducción, esto es lo que queda.

  • Y nuestra app tiene una puntuación de reducción del 42.5%, que está por encima del umbral del 25% de Google Play, pero aún no está donde nos gustaría que estuviera.

  • Esto se debe, en gran medida, a la regla de conservación de todo el paquete de la biblioteca de Analytics.

  • Y eso hace que solo 17 de las 40 clases restantes se puedan reducir aún más.

  • El punto que quiero destacar aquí es que una regla de conservación incorrecta puede causar problemas importantes en la optimización de tu app.

  • Con esto en mente, enfoquémonos rápidamente en las bibliotecas.

  • Durante el año pasado, nuestro equipo invirtió mucho tiempo en comprender cómo las reglas de conservación de los consumidores de bibliotecas afectan la optimización del ecosistema.

  • Muchas bibliotecas se enviarán con reglas demasiado amplias en nombre de la estabilidad.

  • O bien, especificarán reglas generales en su documentación para que los desarrolladores de apps las agreguen a sus apps.

  • Sin embargo, no es necesario enviar reglas amplias para lograr estabilidad, y esto afecta negativamente el rendimiento, donde los costos son principalmente tuyos como desarrollador de apps.

  • Hemos visto casos en los que una regla de conservación para todo el paquete preserva la biblioteca completa, como la biblioteca de Analytics Bloat, y, en el caso de esa biblioteca, esto tuvo un gran impacto en el potencial de optimización de mi app.

  • También quiero mencionar que incluso nuestros propios SDKs en Google no fueron inmunes a esto, y más de una docena de bibliotecas de Google corrigieron reglas de conservación demasiado amplias en el último año.

  • Por lo tanto, si quieres usar el analizador de configuración, este se integra directamente en el AGP 9.3, pero también está disponible para versiones anteriores si actualizas tu versión de R8.

  • Por lo tanto, ejecutar este comando generará ese informe HTML que mostramos, que muestra tus reglas de conservación, los impactos en la reducción, la optimización y la ofuscación para las reglas individuales y tu app en su conjunto.

  • Y, si ejecutas una compilación de lanzamiento completa de R8, el informe se genera automáticamente y se puede encontrar en esta ruta de acceso de archivo.

  • El beneficio de esta tarea independiente es que tarda menos tiempo en generarse que la compilación completa.

Caso de éxito: Optimización de WHOOP

  • El equipo de WHOOP, que podría estar aquí en algún lugar, ejecutó el analizador de configuración y obtuvo una reducción del 74%, una ofuscación del 74% y una optimización del 0%.

  • Como puedes imaginar, el equipo se sorprendió mucho al ver este valor de optimización.

  • La visibilidad del analizador de configuración los ayudó a descubrir que su compilación usaba un archivo de ProGuard heredado que inhabilitaba la optimización, por lo que, al cambiar a la versión actualizada, pudieron habilitar la optimización para su app.

  • Esto generó una optimización del 55% y una reducción del 4% en el tamaño del APK.

  • En combinación con los perfiles de Baseline, mejoraron los tiempos de inicio en frío en un 38% y redujeron los fotogramas congelados excesivos en un 28%.

  • La pantalla de la izquierda no tiene perfiles de Baseline, y la de la derecha sí. Como puedes ver, el inicio de la app es más rápido con la incorporación de los perfiles de Baseline.

  • Reproduzcámoslo una vez más.

  • Genial.

  • Este vínculo te llevará a la guía del analizador de configuración. Revisar el informe de forma manual es una buena idea, pero, como puedes imaginar, según el estado de la configuración de R8 de tu app, podría llevar mucho tiempo.

  • Para acelerar el proceso, tenemos una habilidad de R8 Analyzer.

    $ android skills list
    $ android skills add r8-analyzer
  • Puedes pedirle al agente que analice tu configuración de R8, y la habilidad no solo generará el informe del analizador de configuración de R8, sino que también te indicará las reglas más importantes.

  • Este es un resultado de ejemplo y, para destacar algunos detalles, se observa un nivel de optimización del 42%, lo que significa que el 52% (o, más bien, el 58%) de la base de código no se puede optimizar con R8.

  • Y si observamos específicamente la sección de reglas de conservación, la regla de conservación para todo el paquete de la biblioteca de análisis inflada evita que se optimice el 22% de la base de código, y la habilidad también recomienda que perfeccionemos esta regla de conservación, ya que, como ya sabemos, es demasiado amplia.

  • Ahora puedes diagnosticar qué causa el aumento de tamaño de la app y bloquea la optimización de DEX con el analizador de configuración de R8 y la habilidad.

Desafíos de la reflexión en la optimización de DEX

  • Sin embargo, una vez que encuentras una regla de conservación demasiado amplia, ¿cómo la reemplazas de forma segura sin introducir accidentalmente una falla inesperada?

  • Le daré la palabra a Chris, quien te mostrará cómo realizar la transición de las reglas de conservación heredadas a las nuevas anotaciones de conservación generadas de forma automática con seguridad de tipos.

  • Gracias, Breana.

  • Bien, hablemos de cómo llegamos hasta aquí.

  • Con esta biblioteca de estadísticas inflada, con todas estas reglas de conservación que están, con esta regla de conservación que conserva básicamente todo, el problema principal que estamos encontrando es la reflexión.

  • Veamos un poco del código de reflexión como un pequeño recordatorio.

    fun createWithReflection(className: String): MyObject {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as MyObject
    }
  • Esta es una función de creación de instancias de objetos que solo aumenta un objeto en particular y lo hace de forma reflexiva.

  • Ahora bien, la desventaja de esto, en el contexto de la optimización de DEX, es que retrasa todas estas llamadas estáticas hasta el tiempo de ejecución.

  • Con solo mirar el código, según de dónde provenga este nombre de clase, nuestro optimizador de R8 no puede ver lo que está sucediendo aquí.

  • Estas son las situaciones que interrumpen la optimización de Dex de forma predeterminada, por lo que, por lo general, tendrías que agregar una regla de conservación.

  • Lo ideal es evitar esto, ¿verdad?

  • Lo ideal es evitar la reflexión por completo, especialmente ahora en 2026, cuando tenemos herramientas de generación de código increíbles para la serialización y la inyección de dependencias, que pueden eliminar por completo esta categoría de problema.

  • Pero no es todo, ¿verdad?

  • La reflexión sigue siendo necesaria en algunos casos.

  • Analicemos cómo sucedió esto exactamente en esta biblioteca sobrecargada.

  • Este es un ejemplo particular, y es común, es una dependencia opcional.

    // 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
    }
  • Aquí, aumentamos un tipo particular al que no se puede acceder en la ruta de clase de la biblioteca, por lo que debemos hacerlo con una cadena.

  • Esto hace referencia a esta clase en particular que puede o no estar presente en el momento en que se compiló la biblioteca, por lo que, de manera muy razonable, esta biblioteca usa la reflexión para encontrar un miembro dentro de una biblioteca relacionada.

  • Otro caso que tenemos aquí es la creación de instancias de subclases de forma dinámica.

  • Por lo tanto, especialmente si se trata de algo que proviene de un archivo de manifiesto o algo que se envía desde el servidor, le informamos a la app local: "Oye, por favor, infla esto, crea esto tarde".

  • Y, luego, esta tercera categoría que vemos aquí es la serialización vergonzosa.

  • Es decir, tengo una serialización JSON que se produce iterando por todos los miembros.

  • Esto es absolutamente asqueroso, pero, ya sabes, este tipo de cosas a veces se quedan por ahí.

  • No siempre se soluciona en el momento en que se necesita.

  • Pero hablemos de cuál es el resultado de esto, ¿no?

  • A medida que se acumulan estas llamadas reflexivas, te sientes más motivado para hacer lo más fácil y optimizar la experiencia del desarrollador por sobre la experiencia del usuario.

  • Eso no se ve bien, así que agregaré un elemento de tareas pendientes y todo estará bien.

    # TODO: clean this up
    -keep com.example.analytics.bloat.** { * };
  • Algún día, lo arreglaré, ¿verdad?

  • Entonces, hablemos de lo que se supone que debemos hacer, lo que se suponía que debíamos hacer al menos antes de hoy, cómo escribirías una regla Keep ideal aquí.

    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
  • Muy bien, prepárate.

  • Por lo tanto, la primera parte aquí es que queremos mantener la clase particular que mostramos desde esta función reflexiva.

    ### Keep the EventFilter
    -keep class com.example.analytics.bloat.EventFilter {
        *
    }
  • En realidad, no, espera.

  • ¿Sabes qué?

  • En realidad, esto no es correcto.

  • Esta es una clase de Kotlin, debo asegurarme de que tenga un tipo de cadena, déjame poner el KT allí.

    ### Keep the actual EventFilter
    -keep class com.example.analytics.bloat.EventFilterKt {
        *
    }
  • De hecho, lo importante aquí es que no estoy devolviendo un EventFilter, sino una subclase de EventFilter, por lo que necesito conservar las clases que extienden EventFilter para poder aumentar esto correctamente.

    ### Keep the EventFilter subclasses
    -keep class ** extends com.example.analytics.bloat.EventFilterKt {
        *
    }
  • Y, en realidad, si quiero hacerlo de manera más eficiente, eso habría funcionado, pero para hacerlo más eficiente, como un buen desarrollador de bibliotecas, ¿qué debería hacer para ir más allá?

  • Bueno, debería decir -keepclasseswithmembers y solo conservar las clases que tienen este constructor <init> en particular que uso para aumentar el objeto.

    ### Keep the EventFilter subclasses, if they have this constructor
    -keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
       void <init>();
    }
  • Y, en realidad, si quiero ser muy amable, agregaré allowaccessmodification para permitir que se realice una optimización aún mayor en todas las APIs a las que se dirige esta regla de Keep porque, ya sabes, no quiero evitar una mayor optimización de R8, quiero intentar ser un buen ciudadano.

    ### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
    -keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
       void <init>();
    }
  • Y, luego, el último paso que casi nadie hace, porque es un poco más de trabajo, pero es genial para una biblioteca, es hacerlo condicional.

    ### -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>();
    }
  • Ahora, conceptualmente, no necesitas conservar todas esas subclases, a menos que realmente llames a esta función, y eso es lo que hace esta sentencia if en la parte superior de una regla Keep.

  • Pero, ya saben, podría pedirles que levanten la mano, pero sé que casi nadie hace esto, ¿verdad?

  • Es decir, escribirlo y hacerlo condicional.

  • ¿Y si pudiéramos mejorar esto un poco?

  • Primero, veamos, por ejemplo, bien.

  • Bueno, tal vez haya otra forma de hacerlo.

  • Entonces, hablamos de qué pasa si escribo una regla de Keep estrecha y escrita manualmente.

  • Bueno, técnicamente es posible, pero es difícil de escribir, de leer y de mantener, porque hay muchos pequeños fragmentos de tipo cadena que son fáciles de romper cuando ocurre algo debajo de ellos.

  • Debajo de algo, debajo de eso, cambia.

  • Y tenemos alternativas.

  • Tenemos anotaciones como @Keep.

  • Ahora bien, @Keep ha estado en el paquete de anotaciones de AndroidX durante mucho tiempo, pero en realidad no se recomienda usarlo en estos días.

    @androidx.annotation.Keep
    class EventFilter { … }
    
    @androidx.annotation.Keep
    class SizeBasedEventFilter { … }
    
    @androidx.annotation.Keep
    class DropLogsEventFilter { … }
  • No es una gran solución para los desarrolladores de bibliotecas y, de hecho, está un poco prohibida internamente, porque, si bien es más fácil de mantener, es muy inflexible y no incluye algunas de las optimizaciones que mencioné antes.

  • No puede.

  • No permite ese análisis del sitio de llamada.

  • Ahora, si observamos ese caso en particular que teníamos antes, "Bueno, puedo poner @Keep en mi EventFilter, estoy bien, ¿verdad?".

    @Keep
    class EventFilter { … }
  • En realidad, no.

  • @Keep no se orienta a subclases, sino solo a esa clase.

    @Keep
    class EventFilter { … }
    
    @Keep
    class SizeBasedEventFilter { … }
    
    @Keep
    class DropLogsEventFilter { … }
  • Por lo tanto, debo orientar todas las subclases y, ahora, escribo más anotaciones @Keep de las que habría escrito con esa única regla de Keep que era difícil de escribir.

  • Ambos tienen problemas, ¿verdad?

  • Es fácil olvidarse de ellas, te enteras tarde de su existencia y, de forma predeterminada, no están condicionadas por el emisor.

  • Especialmente para las bibliotecas, queremos una mejor manera.

  • Queremos que haya una forma más fácil de obtener las cosas listas para usar.

  • Y uno de los grandes problemas que vemos aquí es que estos elementos están muy lejos de la reflexión contra la que nos estamos protegiendo.

  • En última instancia, la solución que queremos es algo que se encuentre justo en contra del, ya sabes, código de reflexión problemático que está inflando los elementos de destino.

Introducción a Jetpack Keep

  • Comencemos a hablar de Jetpack Keep, la nueva biblioteca y el complemento de AndroidX que se diseñaron para ayudar con este problema.

  • Entonces, para usar… El objetivo de Jetpack Keep es comenzar generando reglas de conservación a partir de anotaciones, pero, incluso más allá de eso, generar anotaciones directamente a partir de la reflexión que ya está en tu base de código.

  • Por lo tanto, la configuración es muy sencilla.

    plugins {
       id("com.android.library")
       id("androidx.annotation.keep")
    }
    
    dependencies {
        implementation("androidx.annotation.keep:keep")
        ...
    }
  • Es un complemento de Gradle y una biblioteca de anotaciones.

  • Pero veamos cómo se ejecuta esto en el momento de la compilación.

  • Entonces, si tengo este ejemplo anterior en el que aumento una subclase de un tipo conocido, ¿qué debo hacer?

  • Solo ejecuto lintFix.

    ./gradlew analyticsbloat:lintFix
  • Aquí, Lint analiza toda la reflexión que ocurre dentro de este método y dice: "Oye, necesitas esta anotación".

    @UsesReflectionToConstruct(
       classConstant = EventFilter::class,
       parameterTypes = []
    )
    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
  • Esta anotación expresa exactamente lo que hace la reflexión dentro de esta llamada.

  • Indica que se está inflando una clase de este tipo.

  • En este caso en particular, EventFilter es una clase conocida.

  • No uso una cadena para hacer referencia a ella porque no es necesario.

  • Eso es seguridad adicional, y hasta estoy codificando el parameterTypes para que diga exactamente qué constructor se conserva.

  • ¿Pero qué hace esto?

  • Por ejemplo, ¿cómo se informa una compilación de R8 eventual?

  • Bueno, genera la regla de conservación y, como viste antes, esta es exactamente la misma regla de conservación ideal que teníamos antes.

    # 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>();
    }
  • Se orienta exactamente a las mismas clases, las subclases, y está condicionada a este punto de reflexión.

  • Y no tengo que mantener, no tengo que preocuparme por anotar todas las subclases.

  • No tengo que mantener un archivo de reglas de conservación separado en ningún otro lugar.

  • Esta es una forma mucho más sencilla de administrar tus reglas de conservación.

  • Volvamos a ver ese ejemplo de dependencia opcional.

    @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()
    }
  • En este caso, la clase no estaba en la ruta de clase, pero aún puedo hacer referencia a ella.

  • Solo lo hago usando una cadena, pero todo lo demás, los tipos de parámetros, se pueden referenciar por referencias de clase, así que lo hago.

  • Y, de nuevo, generamos la regla de conservación ideal para este 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);
    }
  • Es condicional, específico y segmentado, y habilita optimizaciones siempre que sea posible en esas clases segmentadas.

  • Aquí hay una reflexión sobre los campos y, en este caso en particular, iteraba en todos los campos dentro de un objeto, pero aquí, nuevamente, lo manejamos bien diciendo que se orienten todos los campos con este operador de estrella.

    // 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
    }
  • Eso me permite segmentar todos los campos y, mira, genera la regla de conservación exacta que esperarías para esta, ya sabes, reflexión increíblemente desafortunada que no debería estar en mi app, pero, oye, volveré y la limpiaré, lo prometo.

    # 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>;
    }
  • Entonces, ¿qué hace Jetpack Keep?

  • Reduce drásticamente el mantenimiento de las reglas de conservación.

  • Todo lo que se puede generar automáticamente se quita de tu área de preocupación y de tu área de riesgo para mantener las reglas de conservación.

  • También te brinda el determinismo de lint.

  • No solo corrige los problemas mientras iteras y tratas de migrar, sino que detecta la reflexión nueva y los cambios reflexivos cada vez que cambia el código, y esa seguridad es absolutamente fundamental.

  • ¿Puedo usarlo?

  • Entonces, ¿cuáles son las dependencias aquí?

  • El complemento y lint requieren AGP 8.7.

  • Eso debería incluir a casi todos.

  • Pero también es muy flexible en cuanto a dónde puedes usarlo.

  • Puedes usar esta función en las apps.

  • Puedes usarlo dentro de bibliotecas de Android, AAR, bibliotecas de KMP y JAR, con un poco de cableado manual en ese caso.

  • Sin embargo, la característica clave aquí es que es seguro usarlo como desarrollador de bibliotecas.

  • Incluso si alguien usa una versión antigua de R8 o una herramienta de terceros que consume tu biblioteca, exportamos reglas de consumidor estándar reales para que cualquiera pueda usarte y no tenga que preocuparse por consumir algún tipo de formato nuevo incompatible.

Analiza apps con la CLI de Android

  • Ahora que ya hablé sobre lo que puede hacer Jetpack Keep para ayudarte a optimizar y a preocuparte menos por proteger tus llamadas de código reflexivo, hablemos sobre las nuevas funciones que lanzamos recientemente en la CLI de Android para verificar la última etapa de lanzamiento.

  • Breana mencionó antes que había herramientas en las que los hallazgos de Play te brindarán información sobre el tamaño, el tamaño de DEX y los porcentajes de optimización.

  • Ahora, comenzamos a habilitar esa opción también a través de la CLI de Android.

  • Usa exactamente la misma lógica que Play Store, por lo que, sin tener que subir un paquete a Play Store, puedes hacer que la CLI lo analice.

    $ android analyze <mybundle.aab>
    $ android analyze <mybundle.apk>
  • Además, obtienes exactamente la misma información que se te informa de forma directa y de inmediato con el análisis local.

  • En este caso en particular, la app recibe una advertencia sobre una tasa de optimización muy baja y también se le indica: "Este es el tamaño de tu DEX y estos son tus porcentajes de optimización", exactamente como lo haría Play Store.

  • Pero también, para ayudarte, tenemos un resultado JSON, de modo que, si quieres escribir una secuencia de comandos para automatizar las pruebas de regresión en caso de que alguno de estos valores disminuya drásticamente, porque, de nuevo, estos son los números que Play aplica, quieres hacer un seguimiento de ellos, puedes hacerlo con mucha facilidad con este valor JSON, estos informes 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"
      }
  • Aquí vemos que también están presentes exactamente los mismos números.

  • Esta no es la única función de android analyze.

  • android analyze también se puede ejecutar en bibliotecas.

    $ android analyze <library.aar>
    $ android analyze <library.jar>
  • Y, como mencionó Breana, hay muchos problemas con las bibliotecas que se ejecutan con reglas de conservación desafortunadas en su interior.

  • Por lo tanto, usemos esto para analizar algunos de estos problemas.

  • Por lo tanto, cuando ejecuto esto en un AAR en particular que tengo, puedo ver que esta biblioteca tiene varios problemas que se destacan en la parte superior.

  • Esta biblioteca tiene una proporción de conservación alta, lo que significa que conserva demasiado con reglas de conservación sin límites.

  • Contiene reglas de conservación amplias que impiden que se optimice, y hay algunos números a continuación que te indican exactamente qué tan malo es eso. También contiene reglas de conservación que, quizás lo más aterrador de todo esto, no se orientan a nada, lo que probablemente significa que se orientan a otra persona de alguna manera ilimitada.

  • Indicios de código muy peligrosos.

  • Por lo tanto, puedes verificar todo eso en un AAR, especialmente como publicador de bibliotecas. Esta es una excelente manera de asegurarte de no enviar reglas de conservación incorrectas y de que no se filtren cuando alguien que tal vez no sea tan experto en escribir reglas de conservación esté trabajando en la biblioteca.

  • Aquí, puedes ver que también generamos un porcentaje de retención.

  • Esta es una buena manera de pensar en la calidad general de tu biblioteca.

  • Si solo optimizo una biblioteca individual de forma completamente independiente, sin referencias, con R8, debería ver que tiene cero bytes, ¿verdad?

  • Debería estar básicamente vacío porque no se retiene nada dentro de él.

  • Eso sería lo ideal.

  • En este caso, no lo es.

  • Puedes ver el tamaño de R8 y D8 directamente.

  • De hecho, también destacamos algunas de las peores reglas de conservación para que las veas con más claridad.

  • Esto también tiene un formato JSON, por lo que, de nuevo, ya sea que seas un desarrollador de apps que intenta controlar las actualizaciones de la biblioteca o un desarrollador de bibliotecas que intenta evitar grandes regresiones en la calidad de la biblioteca, puedes hacer ese mismo análisis y evitar que estos problemas ocurran con una secuencia de comandos simple que une la CLI de 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.** { *; }",
                  },
                  ...
                ]
              },
              ...
    }
  • Por lo tanto, si encuentras un problema con una biblioteca, primero intenta actualizarla.

  • Como mencionó Breana antes, corregimos más de una docena de bibliotecas de Google para corregir algunas reglas de conservación muy desafortunadas, y hay otras bibliotecas que se corrigieron, pero las apps que las usan no las actualizan.

  • Es un excelente primer paso.

  • En segundo lugar, puedes buscar un problema y comunicarte con el desarrollador de la biblioteca y, tal vez, adjuntar un informe del analizador que le diga: "Oye, estás conservando demasiado.

  • Ayúdenme a optimizar mi app".

Conclusiones y recursos clave

  • En resumen, hablamos del analizador de configuración de R8, que te ayuda a revisar todo lo que sucede en tu compilación de R8, todo lo que informa qué se puede optimizar y qué no.

  • Hablamos de Jetpack Keep, la nueva biblioteca que te ayuda a escribir reglas de conservación automáticamente sin que tengas que hacer nada.

  • Y la CLI de Android, la nueva herramienta que te ayuda a verificar y asegurarte, volver a verificar con el último paso y asegurarte de que tú, tu agente o el ingeniero júnior de tu equipo no hayan arruinado nada para tus consumidores.

  • Por lo tanto, si quieres obtener más información, hay documentación para R8, el analizador de configuración y Jetpack Keep.

  • Además, hay documentación sobre la CLI de Android que habla sobre el comando analyze y cómo usarlo.

  • Muchas gracias.

Código

Fragmentos de código

Fragmento de secuencia de comandos de Kotlin en el minuto 5:15 de "Introducción a la optimización de DEX"
android {
    buildTypes {
        release {
            optimization {
                // no need to specify default
                // rules and resource shrinking
                enabled = true
            }
        }
    }
    ...
}
Fragmento de secuencia de comandos de Kotlin en el minuto 05:15 de "Introducción a la optimización de DEX"
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
            )
            ...
        }
    }
    ...
}
Fragmento de shell en el minuto 13:19 de "Caso de éxito: Optimización de WHOOP"
$ android skills list
$ android skills add r8-analyzer
Fragmento de Kotlin en el minuto 14:41 de "Challenges of reflection in DEX optimization"
fun createWithReflection(className: String): MyObject {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as MyObject
}
Fragmento de Kotlin en el minuto 15:40 de "Challenges of reflection in DEX optimization"
// 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
}
Fragmento de shell en el minuto 16:52 de "Desafíos de la reflexión en la optimización de DEX"
# TODO: clean this up
-keep com.example.analytics.bloat.** { * };
Fragmento de Kotlin en el minuto 16:59 de "Challenges of reflection in DEX optimization"
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}
Fragmento de shell en el minuto 17:07 de "Desafíos de la reflexión en la optimización de DEX"
### Keep the EventFilter
-keep class com.example.analytics.bloat.EventFilter {
    *
}
Fragmento de shell en el minuto 17:15 de "Challenges of reflection in DEX optimization"
### Keep the actual EventFilter
-keep class com.example.analytics.bloat.EventFilterKt {
    *
}
Fragmento de shell en el minuto 17:19 de "Challenges of reflection in DEX optimization"
### Keep the EventFilter subclasses
-keep class ** extends com.example.analytics.bloat.EventFilterKt {
    *
}
Fragmento de shell en el minuto 17:40 de "Challenges of reflection in DEX optimization"
### Keep the EventFilter subclasses, if they have this constructor
-keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
   void <init>();
}
Fragmento de shell en el minuto 17:50 de "Challenges of reflection in DEX optimization"
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
   void <init>();
}
Fragmento de shell en el minuto 18:03 de "Desafíos de la reflexión en la optimización de DEX"
### -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>();
}
Fragmento de Kotlin en el minuto 18:57 de "Challenges of reflection in DEX optimization"
@androidx.annotation.Keep
class EventFilter { … }

@androidx.annotation.Keep
class SizeBasedEventFilter { … }

@androidx.annotation.Keep
class DropLogsEventFilter { … }
Fragmento de Kotlin en el minuto 19:22 de "Challenges of reflection in DEX optimization"
@Keep
class EventFilter { … }
Fragmento de Kotlin en el minuto 19:29 de "Challenges of reflection in DEX optimization"
@Keep
class EventFilter { … }

@Keep
class SizeBasedEventFilter { … }

@Keep
class DropLogsEventFilter { … }
Fragmento de secuencia de comandos de Kotlin en el minuto 20:38 de "Introducción a Jetpack Keep"
plugins {
   id("com.android.library")
   id("androidx.annotation.keep")
}

dependencies {
    implementation("androidx.annotation.keep:keep")
    ...
}
Fragmento de shell en el minuto 20:54 de "Introducción a Jetpack Keep"
./gradlew analyticsbloat:lintFix
Fragmento de Kotlin en el minuto 20:55 de "Introducción a Jetpack Keep"
@UsesReflectionToConstruct(
   classConstant = EventFilter::class,
   parameterTypes = []
)
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}
Fragmento de shell en el minuto 21:25 de "Introducción a Jetpack Keep"
# 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>();
}
Fragmento de Kotlin en el minuto 21:52 de "Introducción a Jetpack Keep"
@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()
}
Fragmento de shell en el minuto 22:05 de "Introducción a Jetpack Keep"
# 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);
}
Fragmento de Kotlin en el minuto 22:16 de "Introducción a Jetpack Keep"
// 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
}
Fragmento de shell en el minuto 22:27 de "Introducción a Jetpack Keep"
# 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>;
}
Fragmento de shell en el minuto 24:22 de "Analyzing apps with Android CLI"
$ android analyze <mybundle.aab>
$ android analyze <mybundle.apk>
Fragmento de JSON en el minuto 24:52 de "Analyzing apps with Android CLI"
{
  "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"
  }
Fragmento de shell en el minuto 25:17 de "Analyzing apps with Android CLI"
$ android analyze <library.aar>
$ android analyze <library.jar>
Fragmento de JSON en el minuto 26:46 de "Cómo analizar apps con la CLI de 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.** { *; }",
              },
              ...
            ]
          },
          ...
}