Medir antes de optimizar — el APK Analyzer
Antes de aplicar cualquier técnica, hay que saber qué ocupa espacio. Android Studio tiene el APK Analyzer integrado: Build → Analyze APK. Muestra exactamente qué porcentaje del APK es código, recursos, assets, librerías nativas y el archivo de recursos compilados.
# Abrir el APK Analyzer desde la línea de comandos:
# Android Studio → Build → Analyze APK → seleccionar el .apk o .aab
# También via CLI con aapt2:
aapt2 dump resources app-release.apk
# Lo que buscar en el APK Analyzer:
# → classes.dex: ¿qué porcentaje es código? ¿cuántas clases hay?
# → res/: ¿cuánto pesan los drawables? ¿hay imágenes PNG grandes?
# → lib/: ¿cuántas arquitecturas nativas incluye?
# → assets/: ¿hay archivos grandes que no necesitan estar ahí?
# → resources.arsc: el archivo compilado de strings y dimensiones
Comparar antes y después en el APK AnalyzerEl Analyzer tiene un botón "Compare with previous APK" que muestra exactamente qué cambió de tamaño entre dos versiones. Usarlo antes y después de cada optimización para confirmar el impacto real.
App Bundle — la optimización más grande con menos esfuerzo
Cambiar de APK universal a App Bundle (.aab) es la técnica con mejor ratio esfuerzo/resultado. Con un APK universal, instalás todos los recursos en todos los dispositivos — strings en todos los idiomas, drawables para todas las densidades, librerías para todas las arquitecturas. Con App Bundle, Google Play genera un APK optimizado para cada dispositivo específico.
# APK universal:
# → Incluye: ldpi + mdpi + hdpi + xhdpi + xxhdpi + xxxhdpi (todas las densidades)
# → Incluye: arm64-v8a + armeabi-v7a + x86 + x86_64 (todas las ABIs)
# → Incluye: strings en todos los idiomas que soporta la app
# → Un usuario con Pixel 8 (arm64, xxhdpi, español) descarga TODO igual
# App Bundle (.aab):
# → Google Play genera un APK personalizado por dispositivo
# → El Pixel 8 descarga solo: arm64-v8a + xxhdpi + español
# → Reducción típica: 20-40% del tamaño descargado
# En build.gradle ya es el default, pero verificar:
android {
bundle {
language { enableSplit = true } // splits por idioma
density { enableSplit = true } // splits por densidad
abi { enableSplit = true } // splits por arquitectura
}
}
# Buildear el AAB:
./gradlew bundleRelease
# Resultado en: app/build/outputs/bundle/release/app-release.aab
# Para ver el APK que recibiría un dispositivo específico, usar bundletool:
# java -jar bundletool.jar build-apks --bundle=app-release.aab \
# --output=app.apks --ks=keystore.jks --ks-pass=pass:password \
# --ks-key-alias=alias --key-pass=pass:password
# java -jar bundletool.jar get-size total --apks=app.apks \
# --dimensions=ABI,SCREEN_DENSITY,LANGUAGE
R8 bien configurado — código y librerías
R8 hace tres cosas: minificación (acorta nombres), ofuscación (renombra clases y métodos) y tree shaking (elimina código no usado). Habilitarlo correctamente puede reducir el DEX en un 30-60%.
// build.gradle — habilitar R8 en release
android {
buildTypes {
release {
isMinifyEnabled = true // activa R8 (minificación + tree shaking)
isShrinkResources = true // elimina recursos no referenciados en código
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" // tus reglas custom
)
}
// Para debug con R8 (útil para verificar que las reglas funcionan):
debug {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// signingConfig = signingConfigs.debug
}
}
}
# proguard-rules.pro — reglas importantes
# Mantener clases de modelos que Gson/Moshi/Retrofit deserializa por reflection:
-keep class ar.pensa.app.data.remote.dto.** { *; }
-keep class ar.pensa.app.domain.model.** { *; }
# Si usás kotlinx.serialization, no necesitás mantener los modelos manualmente —
# el compilador genera el código sin reflection
# Mantener el nombre de Exceptions para que los stack traces sean legibles:
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Para librerías que no tienen sus propias reglas de ProGuard:
# Retrofit:
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
# Room (ya tiene reglas propias en su .aar, pero por si acaso):
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
# Ver qué eliminó R8 — el reporte de uso:
# build/outputs/mapping/release/usage.txt
# Lista todas las clases y métodos que R8 eliminó — útil para diagnosticar
# Ver qué se conservó y con qué nombre — el reporte de mapping:
# build/outputs/mapping/release/mapping.txt
# Imprescindible para deobfuscar crash reports de producción
Guardar el mapping.txt de cada releaseSi un usuario reporta un crash con una stack trace obfuscada y no tenés el mapping.txt de esa versión, no podés leerla. Commitealo al repositorio o subilo a Crashlytics automáticamente con el Gradle plugin. Firebase Crashlytics lo hace automáticamente si lo tenés configurado.
Recursos no usados — shrinkResources y lint
// isShrinkResources = true (en buildTypes.release) elimina automáticamente
// recursos que ningún código referencia. Trabaja junto con isMinifyEnabled.
// Para ver qué recursos eliminó:
// build/outputs/mapping/release/resources.txt
// Recursos que shrinkResources NO puede eliminar automáticamente:
// → Recursos referenciados dinámicamente: Resources.getIdentifier("nombre", ...)
// → Recursos en librerías de terceros que no eliminaron los suyos
// Para recursos dinámicos — declarar cuáles conservar:
// res/raw/keep.xml (o cualquier nombre en res/raw/):
// <resources xmlns:tools="http://schemas.android.com/tools"
// tools:keep="@layout/l_used*_name,@layout/l_used_name2"
// tools:discard="@layout/unused2" />
// Lint para encontrar recursos sin usar manualmente:
// Android Studio → Analyze → Inspect Code → Android → Lint → Performance
// O via CLI:
./gradlew lint
// Ver el reporte en: app/build/reports/lint-results-debug.html
// Buscar: "UnusedResources"
# Densidades — si tu app solo soporta dispositivos modernos,
# no necesitás recursos ldpi o mdpi:
# En build.gradle — limitar las densidades incluidas:
android {
defaultConfig {
resConfigs("es", "en") # solo estos idiomas
# Para densidades: dejar que App Bundle lo maneje (splits automáticos)
# Si publicás APK: resConfigs("xxhdpi", "xxxhdpi") para dispositivos modernos
}
}
# Eliminar locales no necesarios de librerías de terceros:
# Muchas librerías (AppCompat, Play Services) incluyen 50+ idiomas
# Si tu app solo está en español e inglés, el resto ocupa espacio inútil
android {
defaultConfig {
resConfigs("es", "en") # solo incluir estos idiomas
}
}
# Impacto típico: 1-3 MB de ahorro
Imágenes y assets — la mayor fuente de desperdicio
# Regla general de formato:
# PNG → WebP (sin pérdida): -25% a -35% de tamaño
# PNG → WebP (con pérdida): -50% a -80% de tamaño (aceptable para fotos)
# JPEG → WebP: -25% a -34%
# Imágenes simples (íconos, formas) → VectorDrawable: escala sin pérdida a cualquier densidad
# Convertir a WebP desde Android Studio:
# Click derecho en imagen → Convert to WebP
# Eligir lossless (sin pérdida) para íconos, lossy para fotos decorativas
# Verificar soporte: WebP lossless requiere API 18+ (cubierto por casi todos)
# WebP animated requiere API 28+
# VectorDrawable para íconos:
# → Un solo archivo XML reemplaza 5 PNGs (ldpi/mdpi/hdpi/xhdpi/xxhdpi)
# → Escala perfectamente en cualquier densidad
# → Soporte desde API 21 (con VectorDrawableCompat desde API 16)
# → Limitación: solo figuras, no fotos o imágenes complejas
# Comprimir assets con herramientas externas:
# PNG: pngquant (lossy), optipng (lossless)
# SVG → VectorDrawable: Inkscape + svg2vector plugin de Android Studio
# Para imágenes que se cargan de internet (no bundleadas en el APK):
# No incluirlas en el APK — cargarlas con Coil/Glide desde la red
# El APK debería tener solo imágenes estáticas de la UI
// Coil para imágenes remotas (no bundear imágenes en el APK):
// Si tenés imágenes grandes en res/drawable que vienen del servidor,
// moverlas a Coil reduce el APK directamente.
// Configurar Coil para caché eficiente:
val imageLoader = ImageLoader.Builder(context)
.memoryCache {
MemoryCache.Builder(context)
.maxSizePercent(0.25) // 25% de la memoria disponible
.build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizeBytes(50 * 1024 * 1024) // 50 MB en disco
.build()
}
.build()
Fuentes — downloadable fonts vs bundleadas
# Fuente bundleada en assets/ — el default si la agregás manualmente:
# Una fuente ttf pesa entre 50 KB y 800 KB
# Si incluís 3 variantes (regular, bold, italic) = hasta 2.4 MB
# Downloadable Fonts via Google Fonts — la alternativa:
# → La fuente se descarga de los servidores de Google la primera vez
# → Si el dispositivo ya tiene la fuente (la usa otra app) → 0 MB adicionales
# → El APK solo contiene la declaración de qué fuente usar (~1 KB)
# En res/font/roboto.xml (declaración de fuente descargable):
# <font-family xmlns:app="http://schemas.android.com/apk/res-auto"
# app:fontProviderAuthority="com.google.android.gms.fonts"
# app:fontProviderPackage="com.google.android.gms"
# app:fontProviderQuery="Roboto"
# app:fontProviderCerts="@array/com_google_android_gms_fonts_certs">
# </font-family>
# Limitación: requiere Google Play Services — no funciona en AOSP sin GMS
# Para apps que deben funcionar sin Play Services → bundear la fuente
# En Compose — especificar el subconjunto de la fuente:
# Si usás una fuente de Google Fonts con muchos caracteres,
# podés limitar el subset a Latin si tu app no necesita otros scripts
val montserrat = GoogleFont("Montserrat")
val fontFamily = FontFamily(
Font(
googleFont = montserrat,
fontProvider = provider,
weight = FontWeight.Normal
)
)
Dependencias pesadas — auditar el build
# Ver qué librerías están incluidas y cuánto aportan al DEX:
# APK Analyzer → classes.dex → expandir paquetes → ver tamaño por librería
# Librerías comunes que sorprenden por su tamaño:
# Guava: ~2 MB de clases (muchas no usadas) → reemplazar con funciones Kotlin nativas
# Jackson: ~1.5 MB → reemplazar con Gson (~250 KB) o kotlinx.serialization
# Timber: ~50 KB (razonable)
# OkHttp: ~700 KB (inevitable si usás Retrofit)
# Retrofit: ~120 KB
# Hilt: ~400 KB
# Detectar dependencias duplicadas o innecesarias:
./gradlew dependencies --configuration releaseRuntimeClasspath
# Buscar librerías que aparecen múltiples veces con versiones diferentes
# Multidex — si tenés más de 64K métodos:
android {
defaultConfig {
multiDexEnabled = true // necesario si superás el límite de métodos
}
}
// Cada archivo .dex adicional agrega ~8 KB al APK
// R8 con minificación reduce el método count significativamente — activarlo antes de multiDex
// Excluir módulos innecesarios de librerías grandes:
dependencies {
// Excluir el módulo de animaciones de Lottie si no lo usás:
implementation("com.airbnb.android:lottie:6.3.0") {
exclude(group = "com.airbnb.android", module = "lottie-compose")
}
// Excluir transitive dependencies que ya tenés:
implementation("com.squareup.retrofit2:retrofit:2.11.0") {
exclude(group = "com.squareup.okhttp3") // si ya incluís okhttp directamente
}
}
// Para librerías de debug — asegurarse de que no van a release:
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
// LeakCanary en release sería un error — debugImplementation lo previene
Dynamic Delivery — features opcionales bajo demanda
Si la app tiene features grandes que no todos los usuarios usan (un módulo de AR, un editor de documentos, un modo offline completo), Play Feature Delivery permite entregarlos bajo demanda — el usuario los descarga solo si los necesita.
// En settings.gradle — el módulo de feature dinámico:
include(":app", ":feature-ar") // feature-ar es el módulo dinámico
// feature-ar/build.gradle:
plugins { id("com.android.dynamic-feature") }
android {
// El módulo dinámico NO tiene applicationId propio — usa el de :app
}
// En el AndroidManifest.xml del módulo dinámico:
// <manifest xmlns:dist="http://schemas.android.com/apk/distribution">
// <dist:module
// dist:instant="false"
// dist:title="@string/titulo_modulo_ar">
// <dist:delivery>
// <dist:on-demand /> ← descarga bajo demanda
// </dist:delivery>
// <dist:fusing dist:include="true" />
// </dist:module>
// </manifest>
// En la app — pedir el módulo cuando el usuario lo necesita:
val splitInstallManager = SplitInstallManagerFactory.create(context)
fun instalarModuloAR() {
val request = SplitInstallRequest.newBuilder()
.addModule("feature-ar")
.build()
splitInstallManager.startInstall(request)
.addOnSuccessListener { sessionId ->
// Módulo instalándose — monitorear el progreso
}
.addOnFailureListener { exception ->
// El módulo no pudo instalarse
}
}
Resumen de impacto por técnica
# Ordenado de mayor a menor impacto típico:
# 1. App Bundle + splits (ABI + densidad + idioma)
# Impacto: -20% a -40% del tamaño descargado
# Esfuerzo: bajo — cambiar de APK a AAB en el pipeline
# 2. R8 con minificación y shrinkResources
# Impacto: -30% a -60% del código; -5% a -20% de recursos
# Esfuerzo: bajo-medio — activar y ajustar reglas de ProGuard
# 3. PNG → WebP (o VectorDrawable para íconos)
# Impacto: -25% a -80% del peso de imágenes
# Esfuerzo: medio — convertir con Android Studio, verificar visualmente
# 4. resConfigs — limitar idiomas y densidades
# Impacto: -1 MB a -4 MB (depende de las librerías incluidas)
# Esfuerzo: bajo — 2 líneas en build.gradle
# 5. Downloadable Fonts
# Impacto: -50 KB a -2.4 MB (según cuántas variantes bundearas)
# Esfuerzo: bajo-medio — declarar la fuente descargable
# 6. Auditar dependencias y eliminar no usadas
# Impacto: variable — puede ser 0 o puede ser -5 MB
# Esfuerzo: medio — revisar con APK Analyzer
# 7. Dynamic Feature Modules
# Impacto: puede separar features de 5-20 MB
# Esfuerzo: alto — requiere refactoring arquitectural
# El orden recomendado:
# → Primero: App Bundle (gratis en términos de esfuerzo)
# → Segundo: R8 + shrinkResources (cambiar isMinifyEnabled a true)
# → Tercero: WebP para imágenes grandes
# → Cuarto: resConfigs
# → Solo si el APK sigue siendo grande: auditar dependencias y considerar Dynamic Features