Bidev

Why Does the App Work Perfectly in Debug and Crash as Soon as You Release It?

Bilal Fali4 min read
Why Does the App Work Perfectly in Debug and Crash as Soon as You Release It?

A Flutter app that runs fine with flutter run and then crashes, loses a feature, or throws an exception the moment you build it with flutter build appbundle --release almost always has the same root cause on Android: R8.

R8 is Android's code shrinker and obfuscator. It runs as part of a release build, removes classes and methods it determines are unused, and renames the ones it keeps to shorter names. It operates on the compiled Android bytecode, the Java/Kotlin side of a Flutter app, not on your Dart code. Dart code isn't shrunk or renamed by R8. The classes at risk are the native Android ones: platform channel implementations, embedded native SDKs, and any Android library a plugin pulls in.

Why R8 gets this wrong

R8 decides what's "unused" by tracing static references through the bytecode. Reflection defeats that trace. If a class is only ever looked up by name at runtime, through Class.forName(), an annotation processor, a serialization library, or a WebView's @JavascriptInterface bridge, R8 has no static reference to follow and treats it as dead code. It then either removes the class entirely or renames it, and the lookup that worked in debug fails in release because the name or the class itself is gone.

The two most common symptoms:

  • ClassNotFoundException or NoSuchMethodException/NoSuchFieldException in the release build's logs, naming a class from a native SDK or plugin.
  • Fields coming back null after parsing a JSON response in release, when the same code worked in debug, because the model class's fields were renamed and the reflection-based mapping no longer matches the JSON keys.

Confirm it's actually R8 before doing anything else

Check whether shrinking is on for the release build type, in android/app/build.gradle.kts (or the Groovy equivalent):

buildTypes {
    release {
        isMinifyEnabled = true
    }
}

If this is true and the break only happens in release, R8 is the first thing to check, specifically right after adding a native SDK or a plugin that wraps one.

Fix it with the narrowest rule that works

Most Flutter plugins that wrap a native Android library already ship their own consumer rules inside the library's AAR, which apply automatically. If a plugin's documentation lists required ProGuard/R8 rules, start there instead of guessing. If you do need to add a rule yourself, keep it specific to the package that's breaking:

-keep class com.example.paymentsdk.** { *; }

This tells R8 to leave every class under that package alone, by name and by members, without turning off shrinking for the rest of the app.

As of Android Gradle Plugin 9.3, there's a newer way to declare this: a .keep file inside a src/<variant>/keepRules/ source set (for example src/main/keepRules/custom-rules.keep), used with the optimization { keepRules { ... } } block. The older proguard-rules.pro file at the app module root still works under the legacy DSL, and both approaches read from the same kind of source set, so moving to the new DSL isn't required to fix a crash like this. Projects on AGP 9.3+ that are starting a new module can use either.

Two fixes that make the problem worse

A blanket rule like -keep class ** { *; } does stop the crash, because it tells R8 to keep everything. It also defeats most of the point of shrinking: a release build that keeps every class gets little of the size reduction and dead-code removal R8 exists to provide.

-dontwarn ** is a different kind of wrong fix. It silences R8's "Missing classes" warning without changing what R8 actually removes. The class is still gone; the build just stops telling you about it, which turns a warning you could act on into a runtime crash with no earlier signal.

The actual lesson

"Works in debug" and "works" are different claims for a Flutter Android app. Debug builds don't run R8 at all, so any reflection-dependent native code behaves correctly regardless of whether a keep rule exists. The only way to catch this class of bug before a user does is to actually run a release build, flutter run --release or a real release artifact, as part of testing, not just before shipping.

For the shorter troubleshooting version of this, see Flutter app works in debug but fails in release. For Gradle failures that happen during the build itself rather than at runtime, see debugging Gradle build failures.

Share this

Skip the boilerplate

Production-ready Flutter starter kit with Firebase Auth, Firestore, Cloud Functions, push notifications, and Clean Architecture — ship your app in days, not months.

Get Flutter Firebase Kit — $5

Did this article save you time?

I write these for free. If it helped, a coffee keeps me going — and more articles coming.

Buy me a coffee

Comments

Comments

Leave a comment

0/2000

Comments appear after review.