An app that ran perfectly with flutter run stopped launching the moment I ran flutter build apk --release and installed it on a real device.
And it went down 3 times, for 3 separate reasons. What they had in common:
- Not one of them reproduced in a debug build
flutter analyzepassed every time- The error messages didn’t point to the cause
I squashed all 3, one at a time, on a real device, so here are the symptoms and the fixes.
1. R8 strips classes and the app crashes on launch
Symptom
Install the release APK and launch it, and it dies before even showing the splash screen. adb logcat shows a ClassNotFoundException.
It’s a class that definitely exists. It works fine in a debug build.
Cause
In Android release builds, R8 shrinks and obfuscates the code. R8 removes classes it judges to be “unused,” but classes referenced only through reflection get misjudged as unused.
In my case, it was WorkManager and Room classes. They’re resolved by name at runtime, so static analysis can’t see the references.
Fix
Write keep rules in android/app/proguard-rules.pro.
# WorkManager / Room are resolved via reflection, so R8 strips them
-keep class androidx.work.** { *; }
-keep class androidx.room.** { *; }
-keep class * extends androidx.work.Worker
-keep class * extends androidx.work.ListenableWorker
-keepclassmembers class * extends androidx.work.ListenableWorker {
public <init>(...);
}
The trap is that this file always looks like nothing is wrong. Delete the keep rules and nothing happens in a debug build. You can’t tell it’s broken until you launch a release build on a real device.
I fixed it by reusing, as-is, the proguard-rules.pro from another app I’d built earlier. I left a comment in it: never delete these rules.
2. An invalid AdMob App ID crashes the app before main()
Symptom
This was also a crash on launch, but the symptom was nastier. Not a single line of the logs I’d put inside main() showed up.
The app was going down “before any of my code ran.” No matter how much I suspected my Dart code, the cause wasn’t anywhere in it.
Cause
Android’s initialization Providers run before main().
The Google Mobile Ads SDK reads com.google.android.gms.ads.APPLICATION_ID from AndroidManifest.xml, and if the value is invalid, it throws an exception during initialization. That happens before any of your Dart code, so a try/catch on the Dart side can’t catch it.
A blank value, a placeholder string, or an ID you made up yourself are all invalid. If you put in a dummy thinking “I’ll swap in the production ID later,” this is where you end up.
Fix
Until your production AdMob ID is issued, use Google’s official test ID.
<!-- Always use the official test ID until the production ID is issued.
A blank value or a made-up string crashes before main() -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-3940256099942544~3347511713"/>
This is a test ID that Google publishes, and anyone can use it. “Just leave it blank” was the worst choice I could have made.
3. If sign-in fails at launch, the app freezes on a white screen
Symptom
The 3rd one isn’t a crash; the app freezes on a white screen. Since it doesn’t crash, the cause is even harder to find.
The reproduction condition was nasty too: first launch in a place with bad signal. It never reproduced at my desk, and only showed up when I happened to try it somewhere with barely any reception.
Cause
Inside main(), I was doing an anonymous sign-in before calling runApp().
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await FirebaseAuth.instance.signInAnonymously(); // ← if this fails
runApp(const MyApp()); // ← we never get here
}
If the network is unstable and the sign-in throws, runApp() never gets called. Flutter draws nothing, so it stays frozen on a white screen.
On top of that, this is a “first launch only” problem. Once a sign-in has succeeded, later launches run on the cached session, so it doesn’t reproduce. No wonder I never ran into it during development.
Fix
I routed each startup step through a helper that swallows exceptions.
/// One step of the startup sequence. Even if it fails, startup itself does not stop.
/// If you turn this back into a plain pass-through, the white-screen freeze on a first launch with no signal comes back.
Future<void> _bootStep(String label, Future<void> Function() step) async {
try {
await step();
} catch (e, st) {
debugPrint('[boot] $label に失敗(続行): $e');
// In production, log this to Crashlytics or similar
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await _bootStep('Firebase初期化', () => Firebase.initializeApp());
await _bootStep('匿名サインイン', () => FirebaseAuth.instance.signInAnonymously());
await _bootStep('広告初期化', () => MobileAds.instance.initialize());
runApp(const MyApp()); // always reached, no matter what fails
}
(The Japanese strings in the code are log labels: “failed (continuing)”, “Firebase init”, “anonymous sign-in”, and “ads init”.)
What matters is designing it so that “the app launches even if something fails.” If sign-in didn’t work, show the UI for that state. There was no reason to stop the launch itself.
Bonus: you can’t upload a single byte to Play Console with a debug certificate
This one isn’t about the device, but I hit it around the same time, so I’m noting it here.
Play Console rejects AABs signed with the debug certificate (CN=Android Debug) on every track, including internal testing. “Internal testing must be lenient” didn’t fly.
You need to create a dedicated upload key.
keytool -genkey -v -keystore upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias upload
And here’s the most important warning: if you lose this key, you can never update the app you published on Play again. There is a procedure for having Google reissue it, but it isn’t guaranteed. The moment you create it, back up the keystore file and its password in separate places.
Summary
What these 3 had in common was that they never reproduce in a development environment.
- R8 stripping only runs in release builds
- Initialization Provider crashes happen before
main(), so Dart can’t see them - The white-screen freeze only happens on a first launch with a bad network
In other words, no matter how many times you run flutter run and flutter analyze, you won’t find a single one of these 3.
Install a release build on a real device, ideally somewhere with bad signal, and test from the first launch. If I’d shipped to the store without doing that, I’d have either failed review or, worst case, distributed an app that doesn’t launch on users’ phones.
On the store review side, I also wrote about getting rejected over screenshot resolution. The heavier one was when renaming my GitHub account turned every terms page into a 404.