iT邦幫忙

2

【Flutter】firebase_messaging 冷啟動點擊推播導航兩次:機制與解法,以及為什麼很少人遇到

  • 分享至 

  • xImage
  •  

現象

Flutter App 使用 firebase_messaging 處理推播點擊,把 getInitialMessage()
onMessageOpenedApp 接到同一個導航處理器。Android 上把 App 完全關閉、點擊
推播冷啟動,目標頁被推入兩次,返回一次看到同一頁。iOS 上同樣的操作只導航
一次。

以下行號以 firebase_messaging 16.6.0 為準。這個問題已在 16.7.0 由上游修掉,
見文末〈後續〉;前面的分析仍然有效:〈為什麼很少人遇到〉解釋的時序問題與版本
無關,〈直接原因〉則留作 16.6.0 以前的紀錄。


背景:點擊推播的兩條 API

firebase_messaging 提供兩條互補的點擊事件管道:

  • onMessageOpenedApp:stream,App 還開著(前景或背景)時的點擊
  • getInitialMessage():future,只回傳「把 App 從關閉狀態啟動起來的那一次
    點擊」,讀一次就消耗

設計意圖是一次點擊只會出現在其中一條。冷啟動的點擊應該只出現在
getInitialMessage()


直接原因:Android 原生端把同一次點擊送進兩條管道

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 上「看不到」重複。追進事件的完整路徑就知道
原因——那個多餘的事件其實有發出來,只是在半路被丟掉了:

  1. 原生的 Messaging#onMessageOpenedApp 在 activity attach 階段就發出,
    此時 Dart 端點都還沒開始執行
  2. engine 把沒有接收者的平台訊息放進 ChannelBuffers 暫存,每個 channel
    容量 1 則(channel_buffers.dart:319kDefaultBufferSize = 1
  3. Dart 端的接收器在第一次存取 FirebaseMessaging.instance 時才安裝
    platform_interface_messaging.dart:54-58)。注意,光是
    onMessageOpenedApp.listen(...) 不會安裝它,它只訂閱 Dart 這側的
    stream。安裝動作觸發暫存排空,每則訊息排進一個微任務
  4. 排空的事件被加進一個 broadcast StreamController
    method_channel_messaging.dart:103-107),broadcast stream 在沒有
    訂閱者時直接丟棄事件

於是重複與否只由一件事決定:排空微任務執行的那一刻,broadcast stream
有沒有訂閱者。有訂閱者就送達,重複;沒有就丟棄,不重複。而這取決於
.listen 相對排空的先後:

  • 官方範例先 await getInitialMessage().listengetInitialMessage()
    這行安裝接收器並排程排空,接著 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 時不去重,寧可
重複也不誤殺。


後續:16.7.0 已在上游修掉

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,即冷啟動)傳 falseonNewIntent
(背景恢復)傳 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 的專屬回報,大概是前面那場賽跑把現象蓋掉的緣故。

不過訊息編號去重仍然值得留著,因為它不依賴套件版本,升級之後也不用擔心回歸。


版本

  • 分析與行號以 firebase_messaging 16.6.0 為準
    (firebase_messaging_platform_interface 4.10.0);問題已在 16.7.0 修正
  • 行號對應各自套件內的原始碼,版本更新後可能位移

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言