2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterのmain()が2回呼ばれる怪奇現象 — audio_serviceとpushを併用したらFlutterEngineが二重生成された話

2
Posted at

TL;DR

  • Androidで audio_servicepush を併用すると、1プロセス内にFlutterEngineが2つ作られ、Dartのmain()が2回実行されることがあります。
  • 原因は、両プラグインが「Activityなしでもネイティブ層からDartを呼び出す」ために、それぞれ独自の方法でFlutterEngineを起こしていること。お互いのキャッシュを知らないので衝突します。
  • 暫定対策: pushプラグインの「プロセス停止中の通知」処理を無効化する。
  • 根本対策: pushプラグイン側にパッチを当て、audio_serviceと同じキーでFlutterEngineCacheにEngineを登録・再利用する。
  • そもそも論として、「Androidの1プロセスにつき1つのFlutterEngineを共有する」という公式パターンがないのが構造的な問題だと思っています。

「main()が2回呼ばれる」、という怪奇現象

リリース直前の動作確認をしていたところ、Androidで次のような現象が見つかりました。

PUSH通知を受け取った直後にホーム画面からアプリを起動すると、起動画面(スプラッシュ)でロックしてしまい、その先に進まない

しかも、iOSでは再現しません。Androidでだけ起きます。

最初は「タイミング系のレースだろうか」「初期化処理のどこかでawaitが返ってこないのかな」程度に思っていたのですが、念のためあちこちにログを仕込んでいったところ、最終的に行き着いたのがmain()そのものでした。

void main() {
  print('🟢 main() called: ${DateTime.now()}');
  runApp(...);
}

通知 → アイコンタップ、というシーケンスを踏むと、このログが 2回 出るのです。

🟢 main() called: 2026-06-10 14:32:01.000
🟢 main() called: 2026-06-10 14:32:18.452

main()って1プロセスに1回じゃないんですか……?」と頭を抱えながら調査を始めた、というのが今回の記事の出発点です。

ネタばらしをしてしまうと、これはFlutterの実行モデルを正しく理解していれば「あり得る」現象でした。そして本来であれば起きてはならないようにアプリ側で構造を整えるべきだった、というのが結論になります。

前提:Flutterの実行階層(Process / Engine / Isolate)

調査を進めるためにまずFlutter自体の構造をおさらいしておきます。Flutterアプリの実行モデルは、Androidでは大まかに次の3階層からなります。

   ┌─────────────────────────────┐
   │  Dart Isolate (root)        │  ← main() が実行される
   ├─────────────────────────────┤
   │  FlutterEngine              │  ← Flutterを動かすコア
   ├─────────────────────────────┤
   │  Android Process            │  ← OSから見た実行単位(1つ)
   └─────────────────────────────┘

そしてFlutterEngineが複数立ち上がると、その分だけ上に乗るDart Isolateも増え、main()が複数回呼ばれることになります。図にするとこんなイメージです。

   ┌──────────────────┐    ┌──────────────────┐
   │ Dart Isolate #1  │    │ Dart Isolate #2  │  ← main() が2回走る
   ├──────────────────┤    ├──────────────────┤
   │ FlutterEngine #1 │    │ FlutterEngine #2 │  ← Engine が2本
   └──────────────────┘    └──────────────────┘
   ────────────────────────────────────────────
                  Android Process (1つ)

要点だけまとめると次のとおりです。

  • Process:OS視点での実行単位。
  • FlutterEngine:Flutterを動かすコア。Dart VMを抱え、MethodChannelなどのプラットフォーム接点を束ねます。
  • Isolate:Dartの実行コンテキスト。FlutterEngineを起こすと、その上に root isolate が1つ作られ、そこで main() が呼ばれます。

ここで一点補足です。Dartには Isolate.spawn()compute() といったAPIがあって、1つのFlutterEngine内でroot以外のIsolateを追加で作ることはできます(重い処理を別Isolateにオフロードするのに使う、あれです)。ただし、それらの追加Isolateでは main() は走りません。main() が呼ばれるのは root isolate だけです。

したがってロジックとしては、

main()が2回呼ばれた」 ⇒ 「root isolate が2つある」 ⇒ 「FlutterEngine が2つ作られている」

という具合に、最終的に「Engineが二重生成されている」ところまで一直線にたどり着けるわけです。

iOSは素直、Androidは不便

iOSの場合、Flutterアプリは基本的にFlutterAppDelegateが1つのFlutterEngineを抱える形で動くので、「1プロセス=1Engine」の構図がほぼ崩れません。今回iOSで再現しなかったのも、おそらくこの素直さのおかげです。

一方、Androidはアプリケーションコンポーネントが複数種類あります。

  • Activity:UIを持つ。普通のFlutter画面はここで動く。
  • Service:UIを持たずバックグラウンドで動く。音楽再生やデータ同期など。
  • BroadcastReceiver:外部イベント(プッシュ通知やネットワーク変化)を受け取る。

そしてFlutterActivityが暗黙に作るFlutterEngineは、Activityのライフサイクルに紐づきます。Activityが破棄されればEngineも一緒に破棄されます。

ここで困るケースが出てきます。

  • 音楽再生のように Activityが消えてもDartコードを動かし続けたい
  • プッシュ通知のように Activityが存在しない状態でもDart層に処理を渡したい

Activityがない状態でServiceやBroadcastReceiverが立ち上がっても、Dart層は「いない」のでDartコードが呼べない——これがAndroid固有の悩みです。

各プラグインはこの問題に対してそれぞれ独自に解決策を実装しているわけですが、今回はその「独自解決」同士が衝突してしまったのでした。

audio_serviceの解決策:FlutterEngineCache経由でActivityとServiceで共有

audio_serviceプラグインは、FlutterEngineCacheを活用してActivityとServiceで同じEngineを使い回す設計になっています。

仕組みは次のとおりです。

  1. AndroidManifestでMainActivityを**AudioServiceActivityに差し替える**ことを要求する。
  2. AudioServiceActivityは、provideFlutterEngineをオーバーライドしている(AudioServiceActivity.java:11-14)。
// audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceActivity.java
public class AudioServiceActivity extends FlutterActivity {
    @Override
    public FlutterEngine provideFlutterEngine(@NonNull Context context) {
        return AudioServicePlugin.getFlutterEngine(context);
    }
}
  1. AudioServicePlugin.getFlutterEngineは、固定のキー名でFlutterEngineCacheを引き、無ければ新規生成して同じキーで登録する(AudioServicePlugin.java:62-114)。
// audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java
private static String flutterEngineId = "audio_service_engine";

public static synchronized FlutterEngine getFlutterEngine(Context context) {
    FlutterEngine flutterEngine = FlutterEngineCache.getInstance().get(flutterEngineId);
    if (flutterEngine == null) {
        flutterEngine = new FlutterEngine(context.getApplicationContext());
        // …initialRoute の解決などを行ったうえで…
        flutterEngine.getDartExecutor()
            .executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault());
        FlutterEngineCache.getInstance().put(flutterEngineId, flutterEngine);
    }
    return flutterEngine;
}

キー名のデフォルトは "audio_service_engine" で、AudioServicePlugin.setFlutterEngineId(String)で上書きすることもできます。

  1. AudioService(Android Service側)の起動時にも、同じキーでFlutterEngineCacheを引きにいく。

結果として、

  • アプリをActivityから起動 → ActivityがEngineを作る → AudioServiceも同じEngineを使う。
  • 通知のメディアコントロールでServiceを単独起動 → Serviceが先にEngineを作る → 後からActivityが立ち上がっても同じEngineを再利用。

という形で1プロセスに1Engineが保たれるようになっています。Activity/Service間で同じEngineを共有するための、なかなかよく考えられた仕組みです。

pushの解決策:Activityがいないなら独自にEngineを立てる

pushプラグインは、FCMをはじめとするプッシュ通知を受け取るためのプラグインで、こちらもAndroidで「Activityなしでも受信処理を走らせたい」というニーズに直面しています。

実装の中核はFirebaseMessagingReceiverにあって、通知を受け取ったときに次の3分岐で動きます(FirebaseMessagingReceiver.kt:49-60)。

when {
    isApplicationInForeground -> {
        // フォアグラウンド:既存のEngineにメッセージを流す
        PushHostHandlers.sendMessageToFlutterApp(context, intent)
    }
    PushPlugin.isMainActivityRunning -> {
        // Activityは生きているがバックグラウンド
        PushHostHandlers.sendBackgroundMessageToFlutterApp(context, intent)
    }
    else -> {
        // プロセス完全停止中:独自にFlutterEngineを立てる
        BackgroundFlutterAppLauncher(context, this, intent)
    }
}

肝心のBackgroundFlutterAppLauncherの中では、何も気にせずに新しいFlutterEngineを生成していますBackgroundFlutterAppLauncher.kt:34-43)。

// push/android/src/main/kotlin/uk/orth/push/BackgroundFlutterAppLauncher.kt
private val flutterEngine: FlutterEngine = FlutterEngine(context, null)
private var pushHostHandlers: PushHostHandlers =
    PushHostHandlers(context, flutterEngine.dartExecutor.binaryMessenger)

init {
    pushHostHandlers.setupForBackgroundNotificationProcessing(remoteMessage) { finish() }
    PushHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, pushHostHandlers)
    // 自前で main() を走らせる
    flutterEngine.dartExecutor.executeDartEntrypoint(
        DartExecutor.DartEntrypoint.createDefault()
    )
}

ここで注目してほしいのが、FlutterEngineCacheを経由していないという点です。audio_serviceがせっかくキャッシュしているEngineの存在を、pushプラグインは知りません。

なお、ここで生成されたEngineは Dart 側のバックグラウンド処理が完了した時点でflutterEngine.destroy()で破棄される設計になっています(BackgroundFlutterAppLauncher.kt:51-60)。

private fun finish() {
    Log.i(TAG, "Manually launched Flutter application has finished processing message. " +
               "Destroying FlutterEngine and finishing asynchronous Broadcast Receiver")
    flutterEngine.destroy()
    broadcastReceiver.finish()
}

つまりこの「BG用エンジン」は本来一時的な存在のはずなのですが、完了する前に他の経路(アイコンタップなど)でEngineが起きると、一瞬2本のEngineが共存することになります。これが今回踏んだ罠の本質でした。

衝突:FlutterEngineが2個できる流れ

ここまでの2つの仕組みは、それぞれ単独で見れば妥当な設計です。問題は併用したときに起きます。

今回ハマったシナリオを順に見ていきましょう。

  1. アプリは未起動(プロセス停止中)。
  2. FCMの通知が届きます。BroadcastReceiverが起動し、BackgroundFlutterAppLauncherFlutterEngine #2を新規生成。main()が1回目走ります。
  3. その処理中(=Engine #2 のdestroy()が呼ばれる前)に、ユーザーがホーム画面からアプリのアイコンをタップ。
  4. AudioServiceActivityが立ち上がります。provideFlutterEngineが呼ばれ、AudioServicePlugin.getFlutterEngineFlutterEngineCacheを覗きにいきますが、そこにはpushが作ったEngineは登録されていません(pushはFlutterEngineCacheを経由しないため)。仕方なくFlutterEngine #1を新規生成してキャッシュに登録します。main()が2回目走ります。
プロセス起動
  │
  ├─ BroadcastReceiver(FCM)
  │    └─ FlutterEngine #2 を new
  │         └─ main() ……(1)
  │
  └─ Activityタップ
       └─ AudioServiceActivity#provideFlutterEngine
            └─ FlutterEngineCache を確認 → 空
                 └─ FlutterEngine #1 を new + cache
                      └─ main() ……(2)

両方のEngineでmain()が走るため、

  • DIコンテナや状態管理の初期化が二重に走る
  • AudioService.init()の競合
  • 結果としてANR(アプリ応答なし)が起きる、もしくはアプリが起動画面で固まったまま先に進まない

といった現象につながります。冒頭の「Androidで起動画面のまま動かない」は、まさにこのAudioService.init()競合のせいだったわけです。

対策1(暫定):プロセス停止中のpushを諦める

最初に効かせたのは、pushプラグインに直接パッチを当てて「プロセス完全停止中のメッセージは無視する」ようにする対策でした。

else -> {
    // ⚠️ PATCHED: プロセス停止中のバックグラウンド通知は無視
    // 理由: 複数の FlutterEngine が作成され、main() が2回実行されることで
    //       AudioService.init() の競合や ANR が発生するため
    // トレードオフ: プロセス完全停止中の通知は受信できない
    Log.i(TAG, "Process terminated - ignoring background notification " +
               "to prevent multiple Flutter Engines")
    finish()
    // BackgroundFlutterAppLauncher(context, this, intent)  ← コメントアウト
}

トレードオフは明確で、プロセスが完全に停止している間に届いた通知は処理されないことになります。ただし、通知センターには表示されるので(FCMのnotificationメッセージとして送っていれば)、ユーザーが気付いてタップすればその時点でアプリが立ち上がって以降の処理は動きます。

「データ通知(data-only)でバックグラウンド処理を行いたい」というユースケースを諦められるなら、もっとも安全な対策です。リリース日が迫っていたこともあり、まずはこれで急場をしのぎました。

対策2(根本):pushプラグインを改造してEngineを共有する

長期的には、pushプラグイン側を改造して、audio_serviceと同じキーでFlutterEngineCacheを使うようにしました。

イメージとしては次のような感じです。

class BackgroundFlutterAppLauncher(context: Context, ...) {

    private val flutterEngine: FlutterEngine = run {
        val cache = FlutterEngineCache.getInstance()
        val cached = cache.get(AUDIO_SERVICE_ENGINE_ID)
        if (cached != null) {
            cached
        } else {
            val engine = FlutterEngine(context, null)
            engine.dartExecutor.executeDartEntrypoint(
                DartExecutor.DartEntrypoint.createDefault()
            )
            cache.put(AUDIO_SERVICE_ENGINE_ID, engine)
            engine
        }
    }

    private val pushHostHandlers =
        PushHostHandlers(context, flutterEngine.dartExecutor.binaryMessenger)

    companion object {
        // audio_service が使っているキャッシュキーと揃える
        private const val AUDIO_SERVICE_ENGINE_ID = "audio_service_engine"
    }
}

これで、

  • BroadcastReceiverが先に動いた場合、FlutterEngineCacheにEngineを登録する。
  • 後からAudioServiceActivityが起動しても、AudioServicePlugin.getFlutterEngineが同じキーで既存のEngineを取得できる。
  • Activityが先に立ち上がっていれば、push受信側はそのEngineに乗っかる。

という形で、1プロセス1Engineの不変条件を取り戻せます

⚠️ 注意:audio_serviceのキャッシュキーはデフォルトで"audio_service_engine"AudioServicePlugin.java:62)です。アプリ側でAudioServicePlugin.setFlutterEngineId(...)を呼んで変更している場合はそれに合わせる必要があります。

おまけ:pushプラグインのシングルトンバグ

ついでに踏んだバグも紹介しておきます。pushプラグインのPushHostHandlersは一見シングルトンとして実装されているのですが、実は正しくシングルトンになっていません

PushHostHandlers.kt:177-187を見てみましょう。

@SuppressLint("StaticFieldLeak") // We clean up when we call close.
private var instance: PushHostHandlers? = null

@Synchronized
fun getInstance(
    context: Context,
    binaryMessenger: BinaryMessenger,
    onRequestPushNotificationsPermission:
        ((callback: (Result<Boolean>) -> Unit) -> Unit)? = null,
): PushHostHandlers =
    instance
        ?: PushHostHandlers(context, binaryMessenger, onRequestPushNotificationsPermission)
        // ⚠ ここで作ったインスタンスを `instance` に代入していない

ぱっと見、

  • instance というcompanion objectの静的フィールドがある
  • @Synchronized が付いている
  • instancenullなら新しく作って返す

という、ごく普通のシングルトンに見えます。しかし右辺で生成したインスタンスをinstanceに代入していないので、instanceは永遠にnullのまま、毎回新規生成されてしまいます。Kotlinの?:は「nullなら右辺の式の値を返す」だけの演算子で代入は行わないため、シングルトン化したいなら次のように明示的に代入する必要があります。

fun getInstance(...): PushHostHandlers =
    instance ?: PushHostHandlers(context, binaryMessenger, ...).also { instance = it }

@Synchronizedも、代入する処理が存在しないので何も保護していません。意図と実装が噛み合っていないわけですね。

さらに悪いことに、PushHostHandlersのコンストラクタはprivateになっていません(Kotlinはデフォルトでpublic)。仮に上記の代入バグを修正したとしても、他の箇所からgetInstanceを経由せず直接PushHostHandlers(...)と呼び出せてしまいます

そして実際、プラグイン自身がその「直接呼び出し」を行っています。BackgroundFlutterAppLauncher.kt:35を再掲します。

private var pushHostHandlers: PushHostHandlers =
    PushHostHandlers(context, flutterEngine.dartExecutor.binaryMessenger)

これはgetInstanceを経由していません。つまり2つ目のインスタンスが生まれる経路は2系統あるわけです。

経路 場所 インスタンスができる理由
A PushPlugin.kt:31PushHostHandlers.getInstance(...) getInstanceの代入バグでキャッシュされず毎回新規生成
B BackgroundFlutterAppLauncher.kt:35PushHostHandlers(...) そもそもgetInstanceを経由していない

仮に経路Aを直しても、経路BがあるかぎりPush受信用とアプリ用は別インスタンスになります。

なぜハンドラが複数回呼ばれるのか

PushHostHandlersはコンストラクタの中で内部クラスBroadcastReceiverを生成し(PushHostHandlers.kt:30)、BroadcastReceiverはそのinitブロックでLocalBroadcastManagerにイベント登録を行います(PushHostHandlers.kt:222-232)。

private var broadcastReceiver = BroadcastReceiver(context)  // line 30

// …

private inner class BroadcastReceiver(context: Context) : android.content.BroadcastReceiver() {
    init {
        register(context)
    }

    fun register(context: Context) {
        val filter = IntentFilter()
        filter.addAction(ON_MESSAGE_RECEIVED)
        filter.addAction(ON_BACKGROUND_MESSAGE_RECEIVED)
        filter.addAction(ON_NEW_TOKEN)
        LocalBroadcastManager.getInstance(context).registerReceiver(this, filter)
    }
    // …
}

つまり、PushHostHandlersのインスタンスが2つあれば、LocalBroadcastManagerには2つのBroadcastReceiverが登録されることになります。

その結果、

  • 1つの通知に対してON_MESSAGE_RECEIVEDが両方のreceiverに届く
  • それぞれがpushFlutterApi.onMessage(...)を呼ぶ
  • Dart側でPush.instance.addOnMessage((message) { ... })に登録したハンドラが複数回呼ばれる

という現象につながります。これはローカルパッチで対処しましたが、本来はupstreamにIssueを上げるべき内容ですね(自分のTODOにも入れました)。

考察:そもそもFlutterに「1プロセス1Engine」を保証する仕組みがない

ここまでの問題は、それぞれのプラグインが悪いというよりは、Flutter本体がAndroidで「1プロセスにつき1つのFlutterEngineを共有して使う」というパターンを標準化していないことに起因していると思っています。

  • FlutterEngineCacheは存在しますが、キャッシュキーは各プラグインが自由に決めます。プラグイン間で互換性を取る仕組みがありません。
  • FlutterEngineGroupはsnapshot共有による起動コスト削減には役立ちますが、複数Engine問題そのものを解決するわけではありません。
  • ActivityのライフサイクルとServiceのライフサイクルの差を吸収する「公式の音楽再生サンプル」「公式のFCM background handlerサンプル」が、それぞれ別の前提で書かれている、というのも背景にあると思います。

結果として、

  • 音楽再生のような長時間バックグラウンドタスクを担うプラグイン:Activityと共有できるようEngineをキャッシュする。
  • プッシュ通知のような一発イベント駆動のプラグイン:受信のたびにEngineを生やし、処理後に捨てる。

という方針の違いが生まれ、組み合わせたときに今回のような衝突を起こします。

理想を言えば、Flutter本体に「Androidプロセス全体で共有される単一のFlutterEngineを取得するAPI」があり、すべてのプラグインがそれを利用する、という運用が望ましいです。あるいは少なくとも、「BackgroundIsolate的なものをアプリ全体で1つに統制するための規約」が公式に提示されれば、こうしたプラグイン同士の衝突は減るのではないでしょうか。

世の中の議論:似たような声はすでに上がっている

「自分が踏んだ問題はきっと誰かも踏んでいるはず」と思って関連Issueを探してみたら、案の定たくさんありました。代表的なものを紹介しておきます。

Flutter本体(flutter/flutter

audio_service(ryanheise/audio_service

  • #875 firebase + audio_service: application launched when it should not (2021-11, open)
    firebase_messagingとの組み合わせで「本来起動すべきでないFlutterアプリが完全に起動してしまう」という、今回と似た症状の報告。
  • #1145 AudioService.init blocking (2025-10, open)
    AndroidのみでAudioService.init()が15秒タイムアウトするという、今回我々が踏んだ症状そのもの。「ローカルでは再現せず、端末ごとに挙動が違う」とも書かれていて、今回の調査と完全に重なります。

firebase_messaging(firebase/flutterfire

  • #17163 [firebase_messaging] Unexpected Duplicate main in Call Stack on Android (2025-03, open)
    FirebaseMessaging.onBackgroundMessageを使ったときに、デバッガのコールスタックでmainが二重に表示される、と公式リポジトリでも報告されています。報告者自身が「BGハンドラが別のDart isolateで動くため、自然な現象かもしれない」と推測していますが、まさにこの「自然な現象」が、他プラグインと組み合わさったときに今回のような実害になるわけです。

push(uxduck/push、旧 ben-xD/push

つまり、Engineが2つ立ち上がってしまう問題と、それに起因するAndroid限定のANR・初期化ハングは、長年あちこちで観測・報告され続けているということです。それぞれの場所で断片的に語られているものの、「Androidプロセス全体で単一のFlutterEngineを共有するための公式パターン」という形で根治させる動きには至っていない、というのが現状のように見えました。

まとめ

  • main()が2回呼ばれていたら、FlutterEngineが2個できている。Androidでは比較的起こり得ます。
  • audio_servicepushは、Activity非依存でDartコードを動かすための工夫をそれぞれ独自に実装しているため、併用すると衝突します。
  • 暫定対策はpushのバックグラウンド受信を諦めること、根本対策はEngineキャッシュを共有することで「1プロセス1Engine」を維持することです。
  • Flutter本体に「Androidで1プロセス1Engineを共有する」公式パターンが欲しい、というのが調査を経た上での所感でした。

同じハマり方をしている方の参考になれば幸いです。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?