Del código al APK: compilación, DEX, empaquetado y firma
El recorrido completo de la construcción: Kotlin a bytecode JVM, la traducción a DEX con D8, el trabajo de R8 sobre el grafo de llamadas, el empaquetado con AAPT2 y los esquemas de firma con su alineación de bloques.
Un APK es un archivo comprimido con una estructura muy concreta, y llegar hasta él exige una cadena de transformaciones que casi nadie mira porque Gradle la esconde tras un botón. Merece la pena abrirla: el código pasa por dos compiladores distintos y dos representaciones de bytecode, los recursos se convierten en una tabla binaria, todo se comprime en un zip con reglas de alineación, y al final una firma criptográfica sella el resultado. Cada etapa explica un error que tarde o temprano te vas a encontrar, y varias explican por qué tu app pesa lo que pesa.
- Seguir la cadena completa desde el fuente Kotlin hasta el paquete instalable.
- Distinguir el bytecode de la máquina virtual Java del formato
DEXy saber por qué existen los dos. - Entender qué hacen
D8yR8, y por qué el segundo necesita reglas de conservación. - Comprender los esquemas de firma, la alineación de bloques y el papel de la firma gestionada por la tienda.
De Kotlin a bytecode, y de ahí a DEX
La primera etapa es convencional: el compilador de Kotlin produce archivos de clase con bytecode de la máquina virtual Java, el mismo formato que produciría un compilador de Java. Junto a eso genera metadatos propios que preservan lo que el formato original no sabe expresar —nulabilidad, parámetros con nombre, funciones de suspensión—, y que permiten que otro módulo Kotlin consuma la librería sin perder información de tipos.
La segunda etapa es la peculiar. Android no ejecuta bytecode de la máquina virtual Java: ejecuta DEX, un formato distinto diseñado para otro compromiso. Mientras el formato original es una pila de operandos con una constante por clase, DEX es un formato de registros con un conjunto de constantes compartido para todo el archivo. La consecuencia práctica es que cientos de clases que mencionan la misma cadena o el mismo método la almacenan una sola vez, y eso importa mucho cuando el objetivo es un dispositivo con poca memoria.
La diferencia entre pila y registros no es teórica: cambia el número de instrucciones necesarias para lo mismo. Sumar dos variables locales en el formato original exige cargar ambas a la pila, sumar y guardar el resultado; en DEX es una sola instrucción que nombra tres registros. Menos instrucciones significa menos despacho del intérprete y archivos más compactos, y ese cálculo se hizo pensando en dispositivos que interpretaban bytecode antes de que existiera la compilación anticipada.
La herramienta que traduce se llama D8. Además de cambiar de formato, aplica azucarado sintáctico: reescribe expresiones lambda, métodos por defecto de interfaz y parte de las APIs modernas de la biblioteca estándar para que funcionen en versiones antiguas del sistema. Ese proceso es lo que permite escribir código moderno y seguir soportando dispositivos de hace años.
flowchart TD K[Fuentes Kotlin y Java] --> C[Compilador a bytecode JVM] C --> D8[D8 traduce a DEX y azucara] C --> R8[R8 optimiza reduce y ofusca] R8 --> D8 RES[Carpeta res y manifiesto] --> AAPT[AAPT2 compila y enlaza] AAPT --> Z[Empaquetado en zip] D8 --> Z LIB[Librerias nativas y assets] --> Z Z --> AL[Alineacion de bloques] AL --> F[Firma criptografica] F --> OUT[Paquete instalable]
Un archivo DEX direcciona sus métodos con un índice de dieciséis bits, lo que impone un techo de algo más de sesenta y cinco mil referencias por archivo. Cualquier app con dependencias serias lo supera, y por eso existe el modo multi-DEX: el paquete lleva varios archivos numerados en lugar de uno. Desde Android 5 esto es transparente porque el sistema los precompila juntos al instalar, pero el límite sigue ahí y sigue siendo la razón de que reducir el número de métodos importe. Es también el origen de una regla que se repite sin explicar: cuidado con las dependencias enormes que arrastras por usar una función.
R8: reducir, optimizar y ofuscar en una sola pasada
En compilaciones de publicación, D8 no trabaja solo: lo hace R8, que engloba las tres tareas que antes hacían herramientas separadas. Construye el grafo de accesibilidad de la app partiendo de unos puntos de entrada y ejecuta tres transformaciones encadenadas.
Reducción
Elimina clases, métodos y campos que nadie alcanza desde los puntos de entrada. En apps con dependencias grandes suele quitar más de la mitad del código.
Optimización
Incorpora métodos pequeños en su llamador, propaga constantes, elimina ramas muertas y simplifica jerarquías de clases que quedaron con un solo hijo.
Ofuscación
Renombra todo lo que no sea público hacia identificadores cortos. Reduce tamaño y, de paso, dificulta la ingeniería inversa.
Reducción de recursos
En paralelo se descartan recursos que ningún código ni ningún otro recurso referencia, siempre que no se acceda a ellos por nombre en tiempo de ejecución.
Los puntos de entrada del grafo no los eliges tú por completo: el plugin los deriva de lo que el sistema puede invocar sin pasar por tu código. Todo componente declarado en el manifiesto es una raíz, porque Android lo instancia por nombre. Lo mismo ocurre con las clases referenciadas desde recursos y con cualquier cosa anotada para conservarse. Desde ahí, R8 propaga la accesibilidad hacia dentro y lo que no queda alcanzado desaparece.
El talón de Aquiles es la reflexión. R8 decide qué sobra recorriendo llamadas explícitas, y una clase que solo se instancia por su nombre en una cadena de texto es invisible para ese análisis: acaba eliminada o renombrada, y la app falla en publicación mientras funciona perfectamente en depuración. La solución son las reglas de conservación, un archivo que declara qué debe permanecer intacto. Las librerías serias incluyen las suyas y se fusionan automáticamente; el código propio que usa serialización por reflexión, o clases nombradas desde una cadena de texto, necesita las tuyas.
# Conservar las clases de datos que se serializan por reflexion
-keep class com.miapp.modelo.** { *; }
# Conservar los nombres pero permitir optimizar el cuerpo
-keepnames class com.miapp.plugins.Registro
# Silenciar avisos de una dependencia opcional que nunca se usa
-dontwarn org.ejemplo.opcional.**
Merece la pena distinguir dos niveles de conservación. Conservar una clase entera con todos sus miembros es la regla más contundente y la más cara: bloquea reducción, optimización y renombrado de todo lo que hay dentro. Conservar únicamente los nombres permite que el optimizador siga trabajando sobre los cuerpos y solo impide el renombrado, que suele ser lo único que la reflexión necesita. Escribir reglas amplias porque funcionan es la forma más habitual de desperdiciar el trabajo de R8 sin darse cuenta.
Cada compilación con R8 produce un archivo de correspondencias entre los nombres originales y los ofuscados. Sin él, cualquier traza de error que llegue desde producción es ilegible: una pila de nombres de una sola letra. Ese archivo es específico de cada compilación, así que uno de ayer no sirve para el binario de hoy. Subirlo a la consola de la tienda con cada publicación, o guardarlo como artefacto en la integración continua, es la diferencia entre poder diagnosticar un fallo remoto y no poder hacerlo.
Empaquetado: qué hay dentro del archivo
Los recursos siguen su propia rama del proceso, y también tiene dos fases. AAPT2 primero compila cada archivo por separado a una forma intermedia —lo que permite recompilar solo lo que cambió— y después enlaza todas esas piezas junto con los manifiestos de la app y de sus dependencias, asigna los identificadores definitivos, construye la tabla y genera la clase R. Esa separación entre compilar y enlazar es la razón de que tocar un solo color no obligue a reprocesar la carpeta entera.
Con el código en DEX y los recursos compilados, el empaquetador arma un zip con una estructura fija: los archivos DEX numerados en la raíz, el manifiesto en su forma binaria, la tabla de recursos, la carpeta de recursos ya compilados, los assets copiados tal cual, y las librerías nativas organizadas por arquitectura de procesador.
mi-app.apk
AndroidManifest.xml manifiesto en XML binario
classes.dex codigo, mas classes2.dex si hace falta
resources.arsc tabla de recursos con todas las variantes
res/ recursos compilados
assets/ archivos sin procesar
lib/arm64-v8a/ librerias nativas por arquitectura
META-INF/ firmas y sumas de verificacion
Dentro de ese zip no todo se comprime. La tabla de recursos se guarda sin comprimir precisamente para poder mapearse, y las librerías nativas siguen la misma lógica desde que el sistema aprendió a cargarlas directamente desde el paquete en lugar de extraerlas al almacenamiento durante la instalación. Ese cambio, que se controla con un atributo del manifiesto y hoy viene activado por defecto, ahorra duplicar cada librería en disco y acorta la instalación, a costa de exigir que estén correctamente alineadas.
Antes de firmar ocurre un paso que suele malinterpretarse: la alineación. Ciertos contenidos deben empezar en una frontera de bytes concreta dentro del archivo para que el sistema pueda mapearlos en memoria directamente en lugar de copiarlos. La tabla de recursos se alinea a cuatro bytes por esta razón, y las librerías nativas a dieciséis kilobytes en los dispositivos modernos, porque el tamaño de página de la memoria cambió y una librería mal alineada ya no puede cargarse. No es una optimización cosmética: es la condición para que el mapeo directo funcione.
La firma: identidad, no confidencialidad
Todo paquete instalable debe ir firmado, y la firma no cifra nada: prueba que el binario no ha sido alterado y que procede de quien dice proceder. El sistema guarda el certificado en la instalación y a partir de ahí impone una regla estricta: una actualización solo se acepta si viene firmada con la misma clave. Perder la clave de firma histórica significaba no poder actualizar nunca más esa app.
Durante el desarrollo esto pasa desapercibido porque las herramientas firman por ti con un almacén de claves de depuración generado automáticamente, común a todos tus proyectos y con una contraseña conocida. Esa clave sirve para instalar y probar, y para nada más: no permite publicar, y como es la misma en todas las máquinas de desarrollo del mundo, no prueba absolutamente nada sobre el origen del binario.
Los esquemas evolucionaron. El primero firmaba archivo por archivo con las estructuras clásicas del formato jar, y era lento de verificar. El segundo introdujo un bloque de firma insertado en el propio archivo que cubre el contenido completo en un solo bloque contiguo, mucho más rápido y más difícil de manipular. El tercero añadió rotación de claves y el cuarto habilitó la verificación incremental necesaria para ejecutar una app mientras todavía se descarga.
Como cada esquema cubre un rango de versiones distinto, un paquete real suele llevar varias firmas simultáneas: la antigua para los dispositivos viejos y las modernas para el resto. Esa convivencia tiene un efecto contraintuitivo que conviene conocer —modificar cualquier byte del paquete después de firmar lo invalida por completo, incluida cualquier herramienta que reordene o realinee el zip—. De ahí el orden estricto de la cadena: primero se alinea, después se firma, nunca al revés.
La rotación de claves merece una nota aparte. Antes de existir, el certificado de una app era literalmente irremplazable: comprometerlo significaba no poder actualizar jamás, y publicar de nuevo con otra identidad perdiendo instalaciones, reseñas e historial. El esquema que introdujo la rotación permite firmar con una clave nueva demostrando, mediante una cadena de prueba, que quien firma es el legítimo sucesor del certificado anterior. Es la corrección de un fallo de diseño que costó muy caro a varios equipos durante los primeros años de la plataforma.
Las herramientas de la plataforma incluyen un verificador que informa de qué esquemas cubren un paquete, con qué certificado está firmado y qué huella digital tiene ese certificado. Esa huella es el dato que reclaman los servicios externos cuando hay que registrar la app —enlaces verificados con tu dominio, autenticación con proveedores de identidad, claves de mapas—. Un error clásico consiste en registrar la huella de la clave de depuración y no entender por qué la función deja de operar en la versión publicada: son certificados distintos, y con firma gestionada por la tienda ni siquiera es la tuya la que acaba en el dispositivo.
La pieza que cambia la práctica es la firma gestionada por la tienda. Tú firmas con una clave de subida, la tienda verifica que eres tú, y después vuelve a firmar el paquete que llega al dispositivo con la clave real de la app, que custodia ella. Esto resuelve el problema de la clave perdida —una clave de subida comprometida se sustituye, la clave de la app no se toca— y es además la condición técnica para que la tienda pueda generar paquetes distintos por dispositivo, porque cada uno de esos paquetes necesita ser firmado después de generarse.
Si observas las cuatro etapas juntas, aparece un mismo principio repetido con obstinación: hacer el trabajo caro una vez, en una máquina potente, para que el dispositivo no tenga que hacerlo nunca. El formato DEX con su tabla de constantes compartida existe para que cargar clases cueste menos memoria en un teléfono barato. El azucarado sintáctico se hace al compilar para que el sistema del usuario no necesite entender construcciones que no conoce. R8 reordena y elimina código durante minutos en tu servidor de integración para ahorrar milisegundos y megabytes en millones de dispositivos. La alineación de bloques se calcula al empaquetar para que el sistema pueda mapear archivos directamente en memoria en lugar de copiarlos al abrir la app. El manifiesto se compila a binario para que el instalador lo lea sin parsear texto. Incluso el esquema de firma moderno se diseñó para que verificar un paquete de cien megabytes sea un recorrido lineal y no una comprobación entrada por entrada. Entender esto convierte la cadena de build de un ritual opaco en un argumento coherente, y además reordena tus prioridades como desarrollador: los minutos que tarda una compilación de publicación no son tiempo perdido, son tiempo comprado a cada arranque de la app en cada dispositivo del mundo. Quien desactiva R8 porque le molestan las reglas de conservación no está simplificando su build, está trasladando ese coste a sus usuarios, multiplicado por su base instalada.
- Compila una variante de depuración y otra de publicación, y compara los tamaños de ambos paquetes.
- Descomprime el paquete con cualquier herramienta de zip y localiza los archivos
DEX, la tabla de recursos y las librerías nativas. - Cuenta los métodos de cada archivo
DEXy observa cuántos eliminóR8entre una variante y otra. - Localiza el mapa de correspondencias generado y desofusca una traza de error de ejemplo con él.
- Inspecciona el esquema de firma del paquete y comprueba qué versiones del sistema quedan cubiertas.