Aller au contenu
Tous les articles

R8 et ProGuard pour Kotlin, Room et Compose : le crash qui n'arrive qu'en release

Pourquoi une app Android Room + Compose peut fonctionner parfaitement en debug et planter en production, et les règles keep R8/ProGuard précises qui l'évitent.

MFKAPPS 4 min de lecture

Un build qui passe tous les tests sur votre appareil puis plante pour un utilisateur sur exactement la même version est l’un des bugs les plus déstabilisants qu’offre Android, et il n’a généralement qu’une seule cause : minifyEnabled true. Les builds debug ignorent entièrement le shrinking et l’obfuscation, donc tout ce que R8 toucherait n’est jamais exercé avant l’APK de release — celle déjà sur le Play Store — qui atteint du code que vous n’avez jamais réellement exécuté.

Ce n’est pas un argument contre la minification. Réduire le nombre de méthodes d’une app Kotlin + Compose et éliminer le code inutilisé aide véritablement la taille d’installation et le démarrage à froid, et Google s’y attend de plus en plus. Le correctif est plus modeste : savoir quelles parties de votre code base R8 ne peut pas raisonner statiquement, et lui dire de les laisser tranquilles.

Pourquoi R8 casse des choses qui « marchent visiblement »

R8 décide de ce qu’il garde en traçant l’accessibilité depuis les points d’entrée de votre app — activities, le manifest, tout ce qui est appelé directement. Le code atteint uniquement via la réflexion, un nom de classe stocké comme string, ou une bibliothèque reconstruisant un type au runtime, est invisible pour cette trace. R8 le renomme ou le supprime, l’app compile sans problème, et l’échec n’apparaît qu’au moment où ce chemin réflexif s’exécute réellement.

Deux endroits où cela mord une app Android local-first typique en pratique :

Workers WorkManager instanciés par nom

Si vous planifiez du travail en arrière-plan — le genre derrière la planification des rappels dans Hydrame — vos sous-classes ListenableWorker ne sont pas appelées directement. WorkManager persiste le nom de classe complet du worker dans sa propre base de données et reconstruit l’instance avec Class.forName quand le travail s’exécute réellement, parfois des heures après que le processus d’app qui l’a planifié ait disparu. Obfusquez ce nom de classe et le crash ne se produit pas au moment de la planification — il se produit plus tard, silencieusement, sous forme de ClassNotFoundException au fond du dispatcher de WorkManager, sans aucune stack trace pointant vers votre code :

# proguard-rules.pro
-keep public class * extends androidx.work.ListenableWorker {
    public <init>(android.content.Context, androidx.work.WorkerParameters);
}

Les versions récentes de work-runtime embarquent déjà une règle consumer proche de celle-ci, mais « probablement déjà couvert par la bibliothèque » n’est pas quelque chose à prendre pour acquis dans un build de release — vérifiez-le, ne le supposez pas.

JSON réflexif pour la sauvegarde et l’export

Toute app local-first avec un vrai flux d’export/import — le genre que Stocky utilise pour sauvegarder les données de garde-manger — sérialise des champs de data class d’entités vers du JSON et retour. Si cette sérialisation passe par une bibliothèque réflexive (Gson, ou Moshi sans le chemin codegen), les clés JSON sont par défaut vos noms de propriétés Kotlin. R8 renomme les champs privés qu’il juge sûrs à renommer, le fichier d’export produit a toujours l’air correct, et l’échec n’apparaît qu’à l’import, quand le lecteur réflexif cherche un nom de champ qui n’existe plus dans la classe réduite. Pas de crash, pas d’erreur — juste des données qui ne reviennent pas, silencieusement.

-keepclassmembers class com.example.app.data.** {
    <fields>;
}
-keepattributes Signature

Limitez cela à votre vrai package data, pas à toute votre code base — un -keep class ** général annule l’intérêt même de minifier.

Tester le build qui est réellement livré

La seule façon fiable d’attraper ça est de faire tourner le build de release, pas celui de debug :

buildTypes {
    release {
        isMinifyEnabled = true
        isShrinkResources = true
        proguardFiles(
            getDefaultProguardFile("proguard-android-optimize.txt"),
            "proguard-rules.pro"
        )
    }
}

./gradlew assembleRelease, installez l’APK sur un vrai appareil, et parcourez à la main chaque flux central avant chaque soumission — planifier un rappel, exporter des données, puis forcer la fermeture et rouvrir pour confirmer que l’import les relit toujours. C’est une vérification de cinq minutes contre une classe de bug par ailleurs invisible jusqu’à ce que les rapports de crash de Play Console commencent à se remplir — pour une app sans rapporteur de crash tiers, c’est la seule visibilité que vous aurez après coup.

Une autre habitude à garder : conservez le mapping.txt que R8 génère pour chaque release (Play Console le demandera, et le fait automatiquement si vous uploadez via App Bundle). Sans lui, un crash qui vous parvient bien en production est une stack trace pleine de noms de classe et de méthode à une seule lettre — lisible pour R8, pas pour vous.

Ce qu’il faut retenir

La minification n’échoue pas bruyamment. Elle échoue sur le seul chemin de code que vous n’avez pas retesté à la main, des semaines après que le build qui l’a livré ait passé tout ce que vous avez vérifié. Traitez toute classe instanciée par nom — workers, sérialiseurs réflexifs, tout ce qu’une bibliothèque reconstruit à partir d’une string — comme une règle keep que vous écrivez délibérément, pas une que vous espérez voir couverte par les valeurs par défaut d’une bibliothèque.