R8 on, build green, app broken: five runtime traps that actually hit across 15 apps
I enabled R8 in 15 apps for Play's 2027 code-optimization requirement. Every build passed, but 2 of the first 7 I installed broke at runtime. Gson deserialization, getIdentifier resource lookups, Room _Impl classes, and debuggable builds that silently disable R8: the kinds of failure that never show up in a crash log.
On 26 August 2026 Google announced new Play quality requirements. From February 2027, apps whose DEX exceeds 10 MB (50 MB for games) must ship with code optimization (R8), and the support article says it is "not optional" and "can affect an app's visibility and publishing capabilities". I turned R8 on in the 15 apps that qualify, wrote keep rules per app, and got release builds through. DEX shrank by 71.6% to 87.7%.
The problem came next. I installed release builds of 7 apps that had built cleanly and tapped through the screens. Two were broken. With R8, "build succeeded" does not mean "works".
1. Gson: keep every package you deserialize into
The pet-care app died on launch:
ClassCastException: com.google.gson.internal.LinkedTreeMap cannot be cast to d82
I had kept only the dto package, but the actual model classes lived in data.repository. With generic type information stripped, Gson did not know the target type, produced a LinkedTreeMap, and the cast blew up. The fix was to find every fromJson and TypeToken call and keep every package those target classes live in, including inner classes of the backup reader (BackupManager$*).
2. getIdentifier: resource keys show up on screen
The screen-time app displayed strings like tpl_nightowl_2_wit and title_dawn_scroller. No crash. The code looked resources up by name with getResources().getIdentifier(), R8 could not see that, resource shrinking removed the strings, and a fallback that returns the name when id == 0 made it fail silently.
Crash monitoring will never catch this class of bug. Only a user's eyes do. The fix is res/raw/keep.xml listing the resource name patterns. A full search showed the lottery app (ball_*) and the error-code app (logo_*) use the same pattern, so I patched those pre-emptively. The real fix is to map with ordinary branches instead of getIdentifier.
3. Room finds _Impl by name
The remote-control release, installed on a physical phone, died before the first screen:
Unable to get provider androidx.startup.InitializationProvider
Failed to create an instance of androidx.work.impl.WorkDatabase
Room appends _Impl to the database class name and loads it with Class.forName. R8 could not see that reference and removed the class, and because WorkManager creates its WorkDatabase from the androidx.startup provider, the process dies before anything is drawn. -keep class **_Impl { *; } is one line. If you use Room together with WorkManager, this is the first thing that explodes.
4. Other things that bit at build time
- Sunrise/sunset app: a library referenced compile-only annotations, needed
-dontwarn. - Four remote apps:
R.idused inswitchstatements conflicts with non-final resource IDs. I disabled optimized resource shrinking (android.r8.optimizedResourceShrinking=false). DEX shrinking is unaffected. - Crypto library (BouncyCastle) loads algorithms by class-name string. Without keeping its provider package, the build passes and Wi-Fi pairing explodes at runtime.
5. Verification builds must be non-debuggable
This is a case where my own handover document was wrong. I had written "verify with a debug build that just has R8 enabled". If you set isMinifyEnabled = true on a debuggable build type, AGP prints one warning and disables obfuscation and optimization:
All code optimizations and obfuscation are disabled for debuggable builds
Testing in that state reproduces none of the traps above, so you pass the app wrongly. The correct shape is a separate build type that inherits from release, sets isDebuggable = false, swaps in the debug signing key and test ad IDs. That installs on a real device while R8 runs exactly as it does in release.
For the same reason, Android Studio's default variant is debug; install that without thinking and you are looking at an app R8 never touched.
Checklist
When enabling R8 on a new app or tightening keep rules:
- Search for
fromJson/TypeTokenand keep every target package. - Search for
getIdentifier; if it hits, addres/raw/keep.xml. - If Room is used, keep
**_Impl. Mandatory if WorkManager is also present. - Keep the provider packages of libraries that load classes by string (crypto, serialization, DI).
- Install a non-debuggable build on a physical device and look at every screen even with zero crashes. Trap 2 is only visible by eye.
What remains uncertain
Google's document does not define what "25% coverage" means: size reduction or class ratio. I do not assert either. Default R8 clears it under any reading. And enabling R8 is not the same as Play judging the requirement met; the final check is the app quality overview in Play Console.