Saltar al contenido
Todas las entradas

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.

MFKAPPS 4 min de lectura

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.