flutter run では完璧に動くアプリが、flutter build apk --release して実機に入れた瞬間に起動しなくなりました。

しかも3回、別々の原因で落ちました。共通していたのは以下です。

  • debugビルドでは1つも再現しない
  • flutter analyze は全部通る
  • エラーメッセージが原因を指していない

3つとも実機で1つずつ潰したので、症状と対策を書きます。

1. R8がクラスを削り、起動時にクラッシュする

症状

リリースAPKをインストールして起動すると、スプラッシュも出ずに落ちます。adb logcat を見ると ClassNotFoundException が出ています。

存在しないはずがないクラスです。debugビルドでは普通に動いています。

原因

Androidのリリースビルドでは R8 がコードの圧縮と難読化を行います。R8は「使われていない」と判断したクラスを削除しますが、リフレクション経由でしか参照されないクラスは「使われていない」と誤判定されます。

自分の場合はWorkManagerとRoomのクラスでした。これらは実行時に名前で解決されるので、静的解析では参照が見えません。

対策

android/app/proguard-rules.pro にkeepルールを書きます。

# WorkManager / Room はリフレクションで解決されるためR8が消してしまう
-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>(...);
}

ハマりどころは、このファイルが「何も問題が起きていないように見える」ことです。keepルールを消しても、debugビルドでは何も起きません。リリースビルドを実機で起動するまで壊れたことに気づけません。

自分は先に作った別アプリの proguard-rules.pro をそのまま流用して解決しました。このルールは絶対に消さない、とコメントを入れてあります。

2. AdMobのApp IDが不正だと、main()より前にクラッシュする

症状

こちらも起動時のクラッシュですが、症状がより厄介でした。main() の中に置いたログが1行も出ません。

「アプリのコードが実行される前に落ちている」という状態です。自分のDartコードを疑っても、どこにも原因がありません。

原因

Androidの初期化Providerは、main() よりも前に走ります。

Google Mobile Ads SDKは AndroidManifest.xmlcom.google.android.gms.ads.APPLICATION_ID を読み、その値が不正だと初期化の時点で例外を投げます。ここは自分のDartコードより前なので、Dart側でtry/catchしても捕まえられません。

空欄・仮の文字列・自分で作った適当なIDは、すべて不正です。「あとで本番IDに差し替えよう」と思ってダミーを入れると、この状態になります。

対策

本番のAdMob IDが発行されるまでは、Google公式のテストIDを入れておきます。

<!-- 本番ID未発行の間は必ず公式テストIDを使う。
     空欄・独自の文字列を入れるとmain()の前にクラッシュする -->
<meta-data
    android:name="com.google.android.gms.ads.APPLICATION_ID"
    android:value="ca-app-pub-3940256099942544~3347511713"/>

このIDはGoogleが公開しているテスト用のもので、誰でも使えます。「空にしておく」が一番やってはいけない選択でした。

3. 起動時のサインインが失敗すると、白画面で固まる

症状

3つ目はクラッシュではなく、白画面で固着します。落ちないので余計に原因がわかりません。

そして再現条件が厄介でした。電波の悪い場所での初回起動です。手元では再現せず、たまたま圏外に近い場所で試した時に出ました。

原因

main() の中で、runApp() を呼ぶ前に匿名サインインをしていました。

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  await FirebaseAuth.instance.signInAnonymously();  // ← ここで失敗すると
  runApp(const MyApp());                            // ← ここに到達しない
}

ネットワークが不安定でサインインが例外を投げると、runApp() が呼ばれません。Flutterは何も描画しないので、白画面のまま固まります。

しかもこれは「初回起動時だけ」の問題です。一度サインインに成功していれば、以降はキャッシュされたセッションで動くので再現しません。開発中に一度も遭遇しないのは当然でした。

対策

起動処理の各ステップを、例外を吸収するヘルパー経由にしました。

/// 起動処理の1ステップ。失敗しても起動そのものは止めない。
/// ここを素通しに戻すと、圏外の初回起動で白画面固着が再発する。
Future<void> _bootStep(String label, Future<void> Function() step) async {
  try {
    await step();
  } catch (e, st) {
    debugPrint('[boot] $label に失敗(続行): $e');
    // 本番ではCrashlytics等へ記録する
  }
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await _bootStep('Firebase初期化', () => Firebase.initializeApp());
  await _bootStep('匿名サインイン', () => FirebaseAuth.instance.signInAnonymously());
  await _bootStep('広告初期化', () => MobileAds.instance.initialize());
  runApp(const MyApp());   // 何が失敗しても必ずここに来る
}

大事なのは「失敗しても起動する」という設計にすることです。サインインできなかったなら、その状態のUIを出せばいい。起動自体を止めるべき理由はありませんでした。

おまけ:debug証明書ではPlay Consoleに1バイトも上げられない

これは実機の話ではありませんが、同じ時期に踏んだので書いておきます。

Play Consoleは、debug証明書(CN=Android Debug)で署名されたAABを、内部テストを含む全トラックで拒否します。「内部テストなら緩いだろう」は通用しませんでした。

専用のアップロード鍵を作る必要があります。

keytool -genkey -v -keystore upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 -alias upload

そして最重要の注意点です。この鍵を紛失すると、Playで公開したアプリを二度と更新できなくなります。 Googleに再発行してもらう手続きはありますが、確実ではありません。作った瞬間に、キーストア本体とパスワードを別々の場所にバックアップしてください。

まとめ

この3つに共通していたのは、**「開発中の環境では絶対に再現しない」**ことでした。

  • R8による削除は、リリースビルドでしか動かない
  • 初期化Providerのクラッシュは、main() の前なのでDartから見えない
  • 白画面固着は、ネットワークが悪いときの初回起動だけ

つまり、flutter runflutter analyze を何回通しても、この3つは1つも見つかりません。

リリースビルドを実機に入れて、できれば電波の悪い場所で、初回起動から試す。これをやらずにストアへ出していたら、審査で落ちるか、最悪ユーザーの手元で起動しないアプリを配ることになっていました。


ストア審査まわりではスクリーンショットの解像度で弾かれた話も書きました。もっと重かったのはGitHubのアカウント名を変えて規約ページが全部404になった件です。