R8 y ProGuard para Kotlin, Room y Compose: el fallo que solo ocurre en release
Por qué una app Android con Room + Compose puede funcionar perfectamente en debug y fallar en producción, y las reglas keep de R8/ProGuard que lo evitan a tiempo.
Un build que pasa todas las pruebas en tu dispositivo y luego falla para un usuario en exactamente la misma versión es uno de los bugs más inquietantes que ofrece Android, y normalmente tiene una sola causa: minifyEnabled true. Los builds de debug se saltan por completo el shrinking y la ofuscación, así que cualquier cosa que R8 tocaría nunca se ejerce hasta el APK de release — el que ya está en la Play Store — que llega a código que nunca ejecutaste de verdad.
Esto no es un argumento contra la minificación. Reducir el recuento de métodos de una app Kotlin + Compose y eliminar código sin usar ayuda de forma real al tamaño de instalación y al arranque en frío, y Google lo espera cada vez más. La solución es más pequeña: saber qué partes de tu base de código R8 no puede razonar de forma estática, y decirle que las deje en paz.
Por qué R8 rompe cosas que “obviamente” funcionan
R8 decide qué mantener rastreando la alcanzabilidad desde los puntos de entrada de tu app — activities, el manifest, todo lo que se llama directamente. El código alcanzado solo mediante reflexión, un nombre de clase guardado como string, o una biblioteca que reconstruye un tipo en tiempo de ejecución, es invisible para ese rastreo. R8 lo renombra o lo elimina, la app compila sin problema, y el fallo solo aparece en el momento en que esa ruta reflexiva realmente se ejecuta.
Dos lugares donde esto muerde a una app Android local-first típica en la práctica:
Workers de WorkManager instanciados por nombre
Si programas trabajo en segundo plano — el tipo que hay detrás de la programación de recordatorios en Hydrame — tus subclases de ListenableWorker no se llaman directamente. WorkManager persiste el nombre de clase completo del worker en su propia base de datos y reconstruye la instancia con Class.forName cuando el trabajo realmente se ejecuta, a veces horas después de que el proceso de la app que lo programó ya no exista. Ofusca ese nombre de clase y el fallo no ocurre al programar — ocurre después, en silencio, como una ClassNotFoundException en las profundidades del dispatcher de WorkManager, sin ningún stack trace que apunte a tu código:
# proguard-rules.pro
-keep public class * extends androidx.work.ListenableWorker {
public <init>(android.content.Context, androidx.work.WorkerParameters);
}
Las versiones recientes de work-runtime ya incluyen una regla consumer cercana a esta, pero “probablemente ya cubierto por la biblioteca” no es algo que dar por hecho en un build de release — verifícalo, no lo asumas.
JSON basado en reflexión para copias de seguridad y exportación
Cualquier app local-first con un flujo real de exportación/importación — el tipo que Stocky usa para respaldar los datos de la despensa — está serializando campos de data class de entidades a JSON y de vuelta. Si esa serialización pasa por una biblioteca reflexiva (Gson, o Moshi sin la ruta de codegen), las claves JSON son por defecto tus nombres de propiedad de Kotlin. R8 renombra los campos privados que considera seguros de renombrar, el archivo de exportación que produce sigue viéndose bien, y el fallo solo aparece en la importación, cuando el lector reflexivo busca un nombre de campo que ya no existe en la clase reducida. Sin fallo, sin error — solo datos que en silencio no vuelven.
-keepclassmembers class com.example.app.data.** {
<fields>;
}
-keepattributes Signature
Limita esto a tu paquete de datos real, no a toda tu base de código — un -keep class ** general anula el propósito de minificar.
Probar el build que realmente se publica
La única forma fiable de detectar esto es ejecutar el build de release, no el de debug:
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
./gradlew assembleRelease, instala el APK en un dispositivo real, y recorre a mano cada flujo central antes de cada envío — programar un recordatorio, exportar datos, luego forzar el cierre y reabrir para confirmar que la importación sigue leyéndolos. Es una comprobación de cinco minutos contra una clase de bug que de otro modo es invisible hasta que los informes de fallos de Play Console empiezan a llenarse — para una app sin un reportador de fallos de terceros, esa es la única visibilidad que tendrás después.
Otro hábito que vale la pena mantener: conserva el mapping.txt que R8 genera por cada release (Play Console lo pedirá, y lo hace automáticamente si subes vía App Bundle). Sin él, un fallo que sí te llegue en producción es un stack trace lleno de nombres de clase y método de una sola letra — legible para R8, no para ti.
La conclusión
La minificación no falla de forma ruidosa. Falla en la única ruta de código que no volviste a probar a mano, semanas después de que el build que la envió pasara todo lo que sí comprobaste. Trata cualquier clase instanciada por nombre — workers, serializadores reflexivos, cualquier cosa que una biblioteca reconstruya a partir de un string — como una regla keep que escribes a propósito, no una que esperas que los valores por defecto de una biblioteca cubran por ti.
// Lecturas relacionadas
Más del diario
Room TypeConverters en 2026: cómo guardar enums, fechas y listas sin corromper tu esquema
Una guía práctica sobre los TypeConverters de Room en Android — enums, Instant/LocalDate y listas — además de los errores que convierten un converter en un bug silencioso de corrupción de datos.
Índices de base de datos Room en 2026: encontrar la consulta que realmente va lenta y arreglarla
Una guía práctica para indexar una base de datos Room/SQLite en Android — leer EXPLAIN QUERY PLAN, añadir @Index sin adivinar, y los errores que anulan un índice en silencio.
@Relation de Room: consultar datos uno-a-muchos en Android sin consultas N+1
Una guía práctica de la anotación @Relation de Room — modelar datos uno-a-muchos como categorías y entradas sin consultas N+1 ni joins manuales.