Flutter App 使用 firebase_messaging 處理推播點擊,把 getInitialMessage() 與onMessageOpenedApp 接到同一個導航處理器。Android 上把 App 完全關閉、點擊
推播冷啟動,目標頁被推入兩次,返回一次看到同一頁。iOS 上同樣的操作只導航
一次。
以下行號以 firebase_messaging 16.6.0 為準。這個問題已在 16.7.0 由上游修掉,
見文末〈後續〉;前面的分析仍然有效:〈為什麼很少人遇到〉解釋的時序問題與版本
無關,〈直接原因〉則留作 16.6.0 以前的紀錄。
firebase_messaging 提供兩條互補的點擊事件管道:
onMessageOpenedApp:stream,App 還開著(前景或背景)時的點擊getInitialMessage():future,只回傳「把 App 從關閉狀態啟動起來的那一次設計意圖是一次點擊只會出現在其中一條。冷啟動的點擊應該只出現在getInitialMessage()。
Android 端處理點擊 intent 的 handleNotificationIntent
(FlutterFirebaseMessagingPlugin.java)在冷啟動時同時做了兩件事:
// Store this message for later use by getInitialMessage.
initialMessage = remoteMessage; // :733
...
channel.invokeMethod("Messaging#onMessageOpenedApp", message); // :743
存給 getInitialMessage(),又發了 onMessageOpenedApp。兩條都接的 App,
同一次點擊就處理兩次。
iOS 端有防這件事。FLTFirebaseMessagingPlugin.m 的userNotificationCenter:didReceiveNotificationResponse:(起於 :530)有這道比對:
// We only want to handle FCM notifications and stop firing `onMessageOpenedApp()` when app is
// coming from a terminated state.
if (_notificationOpenedAppID != nil &&
![_initialNotificationID isEqualToString:_notificationOpenedAppID]) { // :554-559
[_channel invokeMethod:@"Messaging#onMessageOpenedApp" arguments:notificationDict];
}
點擊的訊息編號等於啟動 App 的那則時,不發 onMessageOpenedApp。這道比對
來自 flutterfire 的修正 #9315,只修了 iOS,Android 沒有對應版本。
官方文件(Handling interaction)的範例把兩條 API 都放在 root widget 的initState 裡:
Future<void> setupInteractedMessage() async {
RemoteMessage? initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) _handleMessage(initialMessage);
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessage);
}
照抄這個寫法的 App 在 Android 上「看不到」重複。追進事件的完整路徑就知道
原因——那個多餘的事件其實有發出來,只是在半路被丟掉了:
Messaging#onMessageOpenedApp 在 activity attach 階段就發出,channel_buffers.dart:319,kDefaultBufferSize = 1)FirebaseMessaging.instance 時才安裝platform_interface_messaging.dart:54-58)。注意,光是onMessageOpenedApp.listen(...) 不會安裝它,它只訂閱 Dart 這側的StreamControllermethod_channel_messaging.dart:103-107),broadcast stream 在沒有於是重複與否只由一件事決定:排空微任務執行的那一刻,broadcast stream
有沒有訂閱者。有訂閱者就送達,重複;沒有就丟棄,不重複。而這取決於.listen 相對排空的先後:
await getInitialMessage() 再 .listen:getInitialMessage()await 讓出執行權,排空就在讓出點跑完;.listen 排在 await 之後,此時還沒訂閱,事件被丟棄。重複「自動」消失,await(改用 .then,或在別處提早 .listen):訂閱先就位,排空的時機還受另一個因素影響:安裝接收器的是「第一次存取FirebaseMessaging.instance」,不是 .listen。啟動流程只要更早存取過
instance(例如在 main() 請求通知權限、取 token),排空會提早,到initState 訂閱時事件早已丟棄,這也是為什麼有些 App 加了這類無關呼叫後,
即使不 await 也看不到重複。
這些行為都由啟動時序決定,隱性而脆弱。穩妥的做法是不依賴它,改用下一節的
訊息編號去重。
Android 上 issue 的大宗其實是反方向的抱怨,例如「onMessageOpenedApp 沒觸發」
(#11959),那是同一個「事件被 broadcast stream 丟棄」機制的另一面。
同型的重複問題有人回報過(#4965、#9077,當時發生在 iOS),issue 本身
都被機器人以過期關閉,iOS 的問題後來由 #9315 修掉;Android 的雙送則
查不到專屬的 issue,16.6.0 的原始碼裡也沒有對應的修正。常見的處理方式:
| 做法 | 評價 |
|---|---|
| 照官方範例的位置與順序 | 多數 App 在此,靠上述賽跑掩蓋,時序依賴脆弱 |
| 訊息編號去重 | 與官方 iOS 原生修正同一機制,最穩 |
在共用的點擊處理器記住最後處理過的 messageId,相同編號連續出現時跳過:
String? _lastHandledPushTapMessageId;
void _handlePushTap(RemoteMessage message) {
if (message.messageId != null &&
message.messageId == _lastHandledPushTapMessageId) {
return;
}
_lastHandledPushTapMessageId = message.messageId;
// 導航等實際處理
}
這就是 iOS 原生端那道比對的 Dart 版,不改套件與原生層,只在自己的程式碼
裡補上 Android 缺的那道防線。FCM 的
訊息編號每次發送都不同,不會誤擋不同的訊息;編號為 null 時不去重,寧可
重複也不誤殺。
firebase_messaging 16.7.0 給 Android 的 handleNotificationIntent 加上shouldNotifyStream 參數,依呼叫端分流:
private boolean handleNotificationIntent(@NonNull Intent intent, boolean shouldNotifyStream) {
...
// On a terminated launch, getInitialMessage() owns this message.
if (shouldNotifyStream) {
channel.invokeMethod("Messaging#onMessageOpenedApp", message);
}
mainActivity.setIntent(intent);
return true;
}
onAttachedToActivity(點擊建立了 Activity,即冷啟動)傳 false,onNewIntent
(背景恢復)傳 true。冷啟動的點擊因此只交給 getInitialMessage(),行為與 iOS
對齊。
兩個平台殊途同歸:iOS 比對訊息編號擋掉後者,Android 改成依呼叫端決定發不發,
結果都是讓 getInitialMessage() 獨佔冷啟動那一次點擊。上游的 issue 是 #18661,
修正進了 #18663,隨 16.7.0 發佈(見 changelog)。
本文寫於 16.6.0,當時的結論是「Android 的雙送查不到專屬的 issue,原始碼裡也沒有
對應的修正」,沒想到我發佈文章後就發現有相關 issue 了。但值得吐槽的是,這個問題
為什麼能放著這麼久——iOS 的同一個問題早在 2022 年 8 月就由 #9315 修掉,
Android 的版本一路留到 2026 年 9 月才見到 #18661,而 issue 開出來的同一天
就被 #18663 合併,前後不到六小時。當天就能合併,表示不是難在技術上;
而在那之前查不到 Android 的專屬回報,大概是前面那場賽跑把現象蓋掉的緣故。
不過訊息編號去重仍然值得留著,因為它不依賴套件版本,升級之後也不用擔心回歸。