R8: minificar, ofuscar y encoger recursos
Bajo un único nombre y una única opción de configuración, `R8` realiza cuatro trabajos distintos que conviene no confundir: elimina el código inalcanzable, lo optimiza de formas que cambian la estructura del programa, renombra todo lo que puede y, con una opción aparte, descarta los recursos no referenciados. Esta lección separa esos cuatro trabajos, explica el modelo de alcanzabilidad desde el que `R8` decide qué sobrevive, desarrolla la gramática real de las reglas de `keep` y sus diferencias sutiles, aborda el encogido de recursos con sus trampas de referencia por nombre, y establece un método sistemático para depurar el fallo que solo aparece en la compilación de publicación.
Hay una categoría de error que todo equipo de Android acaba conociendo y que tiene una firma inconfundible: la aplicación funciona perfectamente durante meses de desarrollo, pasa todas las pruebas, se sube a la tienda, y entonces empiezan a llegar informes de fallos con trazas ilegibles en las que aparecen clases llamadas con una sola letra invocando métodos llamados con otra sola letra. El código es el mismo. Lo que cambió es que entre tu fuente y el artefacto publicado se interpuso un programa que reescribió el binario entero con permiso tuyo, aunque probablemente sin que nadie del equipo revisara jamás bajo qué reglas. Ese programa es R8, y entenderlo no es un lujo de especialista: es la diferencia entre un artefacto pequeño y rápido y una tarde entera intentando descifrar por qué una biblioteca de serialización devuelve objetos con todos los campos a nulo.
- Separar los cuatro trabajos que
R8realiza y saber cuál de ellos causa cada tipo de fallo. - Describir el modelo de alcanzabilidad desde las raíces y cómo determina lo que sobrevive.
- Escribir reglas de
keepprecisas distinguiendo-keep,-keepnamesy-keepclassmembers. - Diagnosticar y corregir de forma sistemática un fallo que solo se manifiesta en la compilación de publicación.
Cuatro trabajos bajo un solo interruptor
Activar la minificación enciende de golpe una cadena de transformaciones que conviene tener separadas mentalmente, porque cada una produce una familia distinta de fallos. El primer trabajo es el encogido de código: R8 construye el grafo de alcanzabilidad del programa y elimina toda clase, método y campo al que no se llega desde ninguna raíz. El segundo es la optimización, y es el menos comentado y el más profundo: incorpora métodos en sus llamantes, fusiona clases, convierte llamadas virtuales en directas cuando puede probar el tipo, propaga constantes, desempaqueta enumeraciones, elimina ramas muertas resultantes de todo lo anterior y extrae secuencias repetidas a métodos comunes. El tercero es la ofuscación: renombrar clases, métodos y campos con identificadores cortos. El cuarto, gobernado por una opción independiente, es el encogido de recursos.
La distinción importa porque la ofuscación se lleva casi toda la culpa y casi nunca es la única responsable. Un fallo de serialización se debe a la ofuscación, porque los nombres de campo dejaron de coincidir con las claves del documento. Pero un método que desaparece pese a estar escrito se debe al encogido, y un comportamiento que cambia sin que nadie toque el código puede deberse a la optimización, que es capaz de eliminar una comprobación de nulidad que el análisis considera imposible y que en la práctica ocurría porque alguien devolvía nulo desde código nativo o desde reflexión.
Encogido de código
Elimina lo inalcanzable desde las raíces. Reduce el tamaño y el número de clases que hay que cargar al arrancar.
Optimización
Incorpora, fusiona, desvirtualiza y propaga constantes. Cambia la estructura del programa y a veces sus supuestos.
Ofuscación
Renombra todo lo que puede. Reduce tamaño y dificulta el análisis, a costa de trazas ilegibles sin el mapa.
Encogido de recursos
Opción aparte que descarta recursos no referenciados. Depende del encogido de código para saber qué se referencia.
El modelo mental correcto para todo lo demás es el de alcanzabilidad desde un conjunto de raíces. Las raíces por defecto son los componentes declarados en el manifiesto, los puntos de entrada de la plataforma y lo que las bibliotecas declaran mediante sus propias reglas empaquetadas. Todo lo que se alcanza desde ahí siguiendo llamadas y referencias sobrevive. Todo lo demás desaparece. Una regla de keep no es otra cosa que la declaración manual de una raíz adicional, necesaria precisamente cuando el enlace no es visible en el código porque pasa por reflexión, por código nativo o por un fichero de configuración.
La gramática de keep: tres verbos que no son sinónimos
La confusión más extendida consiste en creer que existe una única forma de conservar algo. Existen al menos tres, y elegir mal produce artefactos innecesariamente grandes o fallos sutiles. La directiva -keep conserva la clase y los miembros indicados frente al encogido y frente a la ofuscación: sobreviven y mantienen su nombre. La directiva -keepnames es más débil y a menudo la correcta: no impide que se elimine si resulta inalcanzable, pero garantiza que, si sobrevive, conserve su nombre. La directiva -keepclassmembers es condicional: no conserva la clase por sí misma, pero si la clase sobrevive por otro motivo, entonces sus miembros indicados también sobreviven con su nombre.
# Nombres de campo estables para una biblioteca que serializa por reflexion
-keepclassmembers class com.ejemplo.app.red.dto.** {
<fields>;
}
# Metodos invocados desde codigo nativo: enlace invisible para el analisis
-keepclasseswithmembernames class * {
native <methods>;
}
# Atributos necesarios para genericos y anotaciones en tiempo de ejecucion
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, AnnotationDefault
# Antipatron: anula el beneficio completo para todo el paquete
# -keep class com.ejemplo.app.** { *; }
La última línea del ejemplo, comentada a propósito, es el error más frecuente y el más caro. Cuando aparece un fallo en publicación, la reacción instintiva consiste en conservar el paquete entero con todos sus miembros, comprobar que el fallo desaparece y seguir adelante. El resultado es que se desactivan las cuatro transformaciones sobre la mayor parte del código propio: el artefacto crece, se cargan clases que nadie usa, se pierde la optimización y la ofuscación deja de proteger nada. Y como el síntoma desaparece, nadie vuelve a mirarlo.
Conviene además conocer el modo estricto, que en las versiones recientes viene activado por defecto y que cambia supuestos importantes. En ese modo R8 asume que no hay reflexión salvo declaración explícita, no conserva automáticamente los constructores por defecto de las clases conservadas, es más agresivo al asumir tipos y trata las anotaciones con menos indulgencia. Es el modo correcto, pero migrar a él desde una configuración antigua suele destapar reglas que llevaban años siendo insuficientes y que el modo compatible disimulaba.
Cuando R8 detecta referencias que no puede resolver, genera un fichero con las reglas que harían falta dentro del directorio de salida de la construcción. Ese fichero es el punto de partida honesto: en lugar de escribir reglas a ciegas, se revisa lo que la herramienta echó de menos, se descarta lo que corresponde a dependencias opcionales que realmente no usas mediante la supresión de avisos, y se conserva únicamente lo que es un enlace real e invisible. Complementariamente, las opciones de impresión permiten volcar qué se eliminó, qué raíces se aplicaron y cuál fue la configuración efectiva resultante de fusionar tus reglas con las de todas las bibliotecas.
Recursos: el encogido que depende del código
El encogido de recursos no funciona de forma independiente: se apoya en el resultado del encogido de código para saber qué identificadores de recurso siguen siendo referenciados. Por eso solo tiene efecto cuando la minificación está activada, y por eso conservar de más en el código conserva también recursos de más. Los dos encogidos están acoplados, y una configuración de keep excesiva engorda el artefacto por dos vías simultáneas.
La trampa clásica está en la referencia por nombre. Cuando el código construye el nombre de un recurso a partir de una cadena y lo resuelve en tiempo de ejecución, el análisis estático no ve ninguna referencia y el recurso desaparece. El síntoma es una imagen que no aparece o una cadena que sale vacía solo en publicación, y el diagnóstico es especialmente lento porque nadie sospecha del encogido de recursos. La solución consiste en declarar explícitamente qué se conserva mediante un fichero de recursos destinado a ese fin, o mejor aún, en eliminar la resolución por nombre y sustituirla por una correspondencia explícita en código.
# Comprobar el tamaño y el contenido del artefacto final
./gradlew :app:assembleRelease
./gradlew :app:analyzeReleaseBundle
# Localizar el mapa de nombres para poder revertir las trazas
ls app/build/outputs/mapping/release/
# Revertir una traza ofuscada usando el mapa de esa version exacta
retrace app/build/outputs/mapping/release/mapping.txt traza_ofuscada.txt
Existe además un modo estricto para los recursos que conviene activar deliberadamente. Por defecto la herramienta es conservadora ante referencias ambiguas y prefiere conservar de más; en modo estricto elimina todo lo que no vea referenciado con claridad, lo que reduce mucho más el artefacto pero exige que las excepciones estén declaradas. La decisión razonable es activarlo y declarar las excepciones, no dejarlo permisivo por comodidad.
flowchart TD A[Raices: manifiesto, entradas de plataforma y reglas de bibliotecas] --> B[Grafo de alcanzabilidad] B --> C[Encogido de codigo: se elimina lo inalcanzable] C --> D[Optimizacion: incorporar, fusionar, desvirtualizar] D --> E[Ofuscacion: renombrar clases y miembros] E --> F[Mapa de nombres generado] C --> G[Conjunto de identificadores de recurso vivos] G --> H[Encogido de recursos] I[Reglas de keep propias] --> A F --> J[Archivar y subir para revertir trazas] style C fill:#89b4fa,color:#11111b style E fill:#f9e2af,color:#11111b style J fill:#a6e3a1,color:#11111b
Depurar el fallo que solo existe en publicación
El método que funciona tiene un orden y conviene respetarlo, porque el instinto lleva siempre a la solución equivocada. El primer paso es hacer el fallo reproducible en local, y para eso se crea una variante de construcción que active la minificación y el encogido de recursos pero mantenga el binario depurable y las trazas legibles. Con esa variante, un fallo que tardaba días en confirmarse desde el campo se reproduce en minutos con un depurador conectado.
El segundo paso es determinar cuál de los cuatro trabajos es el responsable, y se hace por bisección desactivando uno cada vez. Si el fallo desaparece al desactivar solo la ofuscación, el problema es de nombres y apunta a reflexión o serialización. Si persiste sin ofuscación pero desaparece sin encogido, falta una raíz. Si solo desaparece desactivando la optimización, se trata de un supuesto que el análisis dio por válido y que en tiempo de ejecución no lo era, y ahí casi siempre hay un contrato de nulidad mentiroso en la frontera con código nativo, con reflexión o con una biblioteca sin anotaciones.
El tercer paso es escribir la regla mínima que corrige el caso concreto, no la máxima que lo tapa. Y el cuarto, que rara vez se hace y siempre se agradece, es añadir una prueba instrumentada que se ejecute sobre la variante minificada en integración continua, de modo que la próxima regresión de este tipo se detecte antes de publicar y no después.
# Diagnostico por biseccion sobre la variante minificada
-dontobfuscate # aisla los fallos de nombres
-dontoptimize # aisla los fallos de supuestos del analisis
-printusage salida/eliminado.txt
-printseeds salida/raices.txt
-printconfiguration salida/configuracion_efectiva.txt
Cada construcción de publicación genera un mapa que traduce los nombres ofuscados a los originales, y ese mapa es específico de esa construcción exacta. Si no se archiva junto con el artefacto y se sube al servicio de informes de fallos, las trazas de esa versión son irrecuperables para siempre: no hay forma de reconstruir el mapa a posteriori, ni siquiera compilando de nuevo el mismo código, porque la asignación de nombres depende del proceso completo. Archivar el mapa no es higiene opcional: es la condición para poder diagnosticar cualquier cosa que ocurra en producción.
Aceptar R8 con todas sus consecuencias supone renunciar a una comodidad intelectual muy profunda: la de creer que existe una correspondencia estable entre el texto que uno escribe y el programa que la máquina ejecuta. Esa correspondencia nunca fue exacta en ningún lenguaje compilado, pero en el desarrollo móvil la brecha se ha ensanchado hasta volverse cualitativa. Entre tu fuente y el artefacto que llega al dispositivo hay un compilador que reescribe, un optimizador que reorganiza el flujo de control, un renombrador que borra la semántica de los identificadores, un empaquetador que reordena, un sistema de distribución que trocea por dispositivo y un entorno de ejecución que decide en el momento qué traducir. La consecuencia práctica es que la depuración de producción exige un artefacto adicional, el mapa de nombres, sin el cual las trazas son ruido; pero la consecuencia conceptual es más interesante, y es que el desarrollador ha dejado de razonar sobre un programa para pasar a razonar sobre una familia de programas equivalentes bajo ciertos supuestos. Los supuestos son el punto crítico. R8 optimiza correctamente siempre que el programa cumpla lo que declara: que los tipos son los que dicen ser, que lo que no está anotado como anulable no es nulo, que no hay enlaces invisibles por reflexión salvo los declarados. Cuando esos supuestos se violan, y se violan constantemente en las fronteras con código nativo, con formatos de intercambio y con bibliotecas antiguas, el optimizador no comete un error: ejecuta con exactitud una deducción sobre premisas falsas. Por eso las reglas de keep no son una lista de excepciones burocráticas sino la documentación formal de los puntos donde tu programa miente sobre sí mismo. Un fichero de reglas corto y bien razonado es la señal más fiable de que un equipo entiende de verdad la frontera entre su código y todo lo que lo rodea, y un fichero de reglas largo y lleno de comodines es una confesión de que nadie quiso averiguarlo.
- Cuenta las reglas de tu configuración que usan comodines sobre paquetes propios y justifica una por una por qué son necesarias.
- Crea una variante depurable con minificación y encogido de recursos activados, y reproduce en local un fallo que solo veías en publicación.
- Aísla el trabajo responsable de ese fallo desactivando ofuscación y optimización por separado, y escribe la regla mínima que lo corrige.
- Activa el modo estricto de encogido de recursos y declara explícitamente todo recurso que resuelvas por nombre en tiempo de ejecución.
- Compara el tamaño del artefacto y el número de clases antes y después de la auditoría, y añade una prueba instrumentada sobre la variante minificada.