Back to the journal
Flutter / Android / Debugging / R8

Flutter worked in debug, but release was stuck on the splash screen

My logger hid the exception. Once I could read it, I traced the startup failure to a JNI lookup broken by R8.

My Flutter app started normally in debug mode. In release mode, it stayed on the splash screen.

I enabled release logging to see what was failing. This was all I got:

(PlatformDispatcher) Message: Fatal async error
Error:
PlatformException
StackTrace:

There was an error, but almost nothing to investigate.

I suspected notification permissions, FCM token retrieval, and the logger itself. Temporarily disabling those paths didn’t remove the failure. Neither did flutter clean and reinstalling the app.

Before I could find the startup failure, I had to fix my logging.

My logger was hiding the error

Inside the logger, the exception was converted like this:

error: error?.runtimeType.toString(),

For a PlatformException, that produces the string PlatformException. I was logging the type and losing the code, message, details, and native trace.

Turning on release logging didn’t help because the logger had already thrown that information away.

PlatformException.toString() includes those fields and the native stacktrace when one is available.

Printing the Dart StackTrace still gave me no useful frames. The details I needed were inside the exception itself.

I changed the console logging to use the original exception. This is a simplified version:

import 'package:flutter/foundation.dart';

const logInRelease = bool.fromEnvironment('logInRelease');

// Call from an existing error handler. This writes only to the console.
void printDiagnosticError(Object error, StackTrace stackTrace) {
  if (kReleaseMode && !logInRelease) return;

  debugPrint(error.toString());

  final dartTrace = stackTrace.toString();
  if (dartTrace.trim().isNotEmpty) {
    debugPrint(dartTrace);
  }
}

logInRelease is a custom flag read by this code, not a built-in Flutter logging option.

With the default entry point, I can enable it with:

flutter run --release --dart-define=logInRelease=true

Add your usual --flavor and -t arguments if your project uses them.

The failure was in shared preferences

Once I logged the full exception, I could see this:

java.lang.NoSuchMethodError: no non-static method
"Lio/flutter/plugins/sharedpreferences/SharedPreferencesApiRegistrar;.getAll(Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;"

That pointed to a native method lookup in shared preferences. My startup code awaited SharedPreferences.getInstance() during dependency initialization, before runApp().

The startup sequence looked like this:

Initialize Firebase
        |
Initialize dependencies
        |
Read shared preferences  <-- exception here
        |
      runApp()           <-- never reached

The app never reached runApp(), so the splash screen stayed visible. I now had a method to investigate instead of guessing which notification call was failing.

R8 had renamed a type in the method signature

Flutter’s release mode disables assertions and debugging, and optimizes the build. On Android, the Java and Kotlin code can also pass through R8, which shrinks, optimizes, and renames code.

I hadn’t enabled Dart obfuscation. R8 works on the Android side separately.

The relevant versions in my lockfile were:

  • shared_preferences_android: 2.5.0
  • jni: 1.1.0

shared_preferences_android moved to Pigeon JNI in 2.5.0, which explained why JNI appeared in a shared preferences error.

JNI looks up a method by its name and signature, including the parameter and return types.

The generated binding expected this parameter type:

Lkotlin/coroutines/Continuation;

But the failing build’s R8 mapping showed:

kotlin.coroutines.Continuation -> hn:

The optimized method’s signature used Lhn;. The generated Dart binding was still asking for Lkotlin/coroutines/Continuation;.

The registrar class and getAll method still had their original names. A parameter type had been renamed, and that was enough to break the lookup.

Android’s JNI keep-rule guidance covers this: methods called from native code and the types in their signatures need to remain available under the expected names.

The keep rules that fixed startup

I checked the generated binding and JNI coroutine support for other Kotlin types and fields looked up by name. Then I added these rules to android/app/proguard-rules.pro:

# shared_preferences_android 2.5.0 calls suspend APIs through jni 1.1.0.
# JNI looks up these Kotlin types and Result fields by their original names.
-keep interface kotlin.coroutines.Continuation { *; }
-keep class kotlin.coroutines.intrinsics.CoroutineSingletons { *; }
-keep class kotlin.Result { *; }
-keep class kotlin.Result$Failure { *; }

These rules keep those four types and their members. I didn’t need to disable R8 or keep every class in the app.

I rebuilt the staging release with optimization still enabled and checked its mapping. The four type names were preserved. After installing and relaunching it on the Android device, the original fatal error was gone. Startup reached runApp() and continued to profile loading.

A separate registration request still failed during that run. I’d verified the startup fix, but I hadn’t verified every screen or user flow.

This workaround fixed the versions above in my project. If you hit a similar error, check your resolved versions, generated binding, R8 mapping, and the current changelog before copying the rules.

What I’m keeping from this

The logger changes were useful beyond this error. I kept a console-only path for full notification payloads, gated behind logInRelease=true in release builds. Those entries bypass file logging and Crashlytics. Console output is still visible on the device, so I use it for controlled debugging. I also kept direct fatal-error reporting and stopped the logger from reporting the same error twice.

Next time a release build gets stuck during startup, these are the checks I want to make first:

  • Read the full exception, including native details. A type name isn’t enough.
  • Add before-and-after logs around awaited startup calls to find where execution stops.
  • Compare debug and release with the same flavor, entry point, and configuration.
  • Test the rebuilt release on the device. For an R8 issue, check that build’s mapping too.

I also want a release startup smoke test in the delivery workflow. In this case, debug worked the whole time. I needed to run the optimized build to catch the failure, and fix my logger to understand it.