系列:30 天用 Google AI 打造臺灣防災速報 App(Day 25/30)
示警列表要使用者自己打開才看得到。半夜的豪雨紅色警戒,需要 App 主動通知。今天用 Firebase 雲端通訊(Firebase Cloud Messaging,FCM,Google 的推播服務)做重大示警推播。
先說現況:Android 版 10/7 04:02 起已經正式推送。iOS 還收不到推播,最後一節列出還差什麼。
通知太多,使用者可能乾脆關掉通知,真正嚴重的那則也就收不到了。所以只推最嚴重的兩級,而且要不要推由官方等級決定:
| 條件 | 推不推 |
|---|---|
| 極重大(紅):官方等級 Extreme,或電文明寫紅色警戒 | 推 |
| 重大(橙):官方等級 Severe | 推 |
| 重大(橙):官方沒給等級、由 AI 補判 | 不推,App 裡照常顯示 |
| 警戒、資訊 | 不推 |
| 演習、測試、解除的電文,或已過期 | 不推 |
| 地震 | 先不推 |
縣市由使用者自己選。通知的內文是 AI 摘要(Day 13),最多 200 字。
這裡要注意 msgType 的判斷。CAP 電文(示警的標準格式,Day 2)有一個欄位 msgType,表示這份是新發布(Alert)、更新(Update)還是解除(Cancel)。直覺會寫「Update 就不推」,但我們抽查 30 份生效中的電文,有 12 份是 Update,其中中央氣象署的降雨、強風示警 6 份全是 Update。照「Update 不推」,這次抽查的 6 份氣象署降雨、強風示警都會被排除。
每份 Update 電文都會在 <references> 列出它更新的是哪幾份舊電文,串起來就是一條「更新鏈」。所以去重的單位改成更新鏈:
推過的紀錄寫在 Firestore 的 push_sent(只驗證不送出的期間,寫在另一個集合 push_sent_dryrun,以免之後切換時被當成已經推過)。判斷和紀錄放在同一個交易裡,先寫下「要推了」再送出,可以避免兩個執行個體同時各送一次。要注意這個順序的代價:寫下紀錄後如果送出失敗,同一則就可能漏推。所以個別訊息送出失敗時,隔 2 秒重送一次;仍然失敗,或整批發送出錯,就只撤回這份電文自己的紀錄,並在日誌記一筆 failed_released,之後再觸發時可以重推。另外有定期補推,重新處理還沒推成功的示警。
更新鏈只合併「同一件事的更新」。另一種情況它擋不掉:同一縣市、同一分鐘,發出好幾份各自獨立的電文。
推播接上正式流程後,我們先只判斷、不送出,跑了 3.5 小時(10/3 14:40–18:10)。如果當時真的送了,屏東縣的使用者會在一個半小時內收到 7 則通知:17:10 同一分鐘有三份淹水警戒,18:00 又有兩份雷雨訊息在同一分鐘發布。
所以加了一條冷卻規則,先在只驗證不送出的期間跑過,再跟著正式送出一起上線:
比對條件要包含「類別」。只比縣市和等級的話,剛推過雷雨,10 分鐘後的豪雨特報也會被擋掉,新出現的災害類型就收不到通知。用同一段資料模擬:14 則裡擋下 2 則,都是同一分鐘、同一類的重複電文;屏東從 7 則變成 5 則。
FCM 可以對單一裝置送,也可以對「主題」送:裝置訂閱主題,後端只要對主題發訊息。我們用主題,命名是 alert_{縣市}_{等級}_{語言},等級是 extreme 或 critical,例如 alert_tpe_extreme_zh。
我們的後端因此不用保存裝置的推播識別碼。訂閱由 App 直接向 FCM 登記,後端只知道「要對哪些主題送」。
一則示警常常涵蓋好幾個縣市。FCM 可以用條件一次送給多個主題(最多 5 個),所以後端組成這樣的訊息(簡化)。key 是合併同一則通知用的識別值,ttl 是通知最多可以延後多久送達,apns 開頭的是給 iOS 推播服務 APNs(Apple Push Notification service)的設定,下面都會說明:
messaging.Message(
condition="'alert_tpe_critical_zh' in topics || 'alert_nwt_critical_zh' in topics",
notification=messaging.Notification(title="重大示警", body=summary),
data={"uid": uid, "title": "重大示警", "level": "critical"},
android=messaging.AndroidConfig(
priority="high", ttl=ttl,
notification=messaging.AndroidNotification(tag=key, channel_id="major_alerts")),
apns=messaging.APNSConfig(headers={"apns-collapse-id": key, "apns-priority": "10",
"apns-expiration": str(expire_epoch)}),
)
兩個設定要注意:
tag 和 iOS 的 apns-collapse-id 是系統合併通知用的識別值。一則示警分批送到不同縣市的主題,同一支手機如果同時訂了兩個縣市,系統只會留一則通知。apns-collapse-id 上限是 64 位元組,uid 太長時改用它的 SHA-256 雜湊前 32 碼。ttl,iOS 用 apns-expiration,都依示警的到期時間設定(示警沒寫到期時間就用 6 小時),手機離線太久就不會收到舊通知。程式把有效時間下限設成 5 分鐘,所以接近到期的示警,仍可能在到期後幾分鐘內送達。1. 使用者選了縣市才問通知權限。 不在一打開 App 就跳權限視窗;等使用者在設定頁選了縣市、知道通知是為了什麼,才請求權限。
2. Android 要先建好通知頻道。 後端指定的頻道 major_alerts,App 啟動時要先建立,重要性設成 HIGH,通知才會跳出橫幅。Android 13 以上還要取得通知權限(POST_NOTIFICATIONS)。
3. iOS 要先拿到 APNs 權杖才能訂閱。 iOS 的推播要經過 APNs。firebase_messaging 在還沒拿到 APNs 權杖時訂閱主題,會直接丟錯誤,所以先檢查,拿不到就稍後重試(簡化):
if (defaultTargetPlatform == TargetPlatform.iOS &&
await messaging.getAPNSToken() == null) {
retryLater(); // 每 5 秒再試,最多 1 分鐘;之後等權杖更新或下次啟動
return;
}
await messaging.subscribeToTopic(topic);
4. 點通知要開到那則示警,三種狀態都要處理。 App 在前景、在背景、已經關閉,接收通知的入口各不相同(onMessage、onMessageOpenedApp、getInitialMessage)。通知的資料欄位有 uid、title、level,App 用 uid 去 Firestore 找。示警到期後會被搬到 history,所以先查 alerts,沒有再查 history;都找不到就顯示「這則示警已結束。」,不顯示錯誤畫面。

Flutter 的 Firebase 套件在網頁上不能自己訂閱主題,呼叫 subscribeToTopic() 會直接丟出「not supported on the web clients」。要做就得另外寫一個後端服務代替網頁訂閱,這次先不做。網頁版改成一行說明,請使用者安裝 App 接收通知。
推播不能拿真的使用者來試。測試分成兩部分:
test_ 開頭的主題,後端用測試腳本只對這些主題送。正式使用者永遠不會訂到。切換時要注意定期補推的範圍。補推會找「生效中、但還沒有推播紀錄」的示警重推;只驗證期間的紀錄寫在另一個集合,所以一切換,所有生效中的舊示警看起來都「還沒推過」。我們用一個切換時間當起點,補推只處理這個時間之後才進來的示警。
Android 模擬器上測了四種情況,都通過:App 在前景時畫面下方出現提示列;在背景時通知欄出現通知;App 關閉時點通知直接開到詳情;示警已結束時顯示「這則示警已結束。」。後端每種語言各送一則,模擬器只收到介面語言那一則,沒有重複。切成正式送出後,再用一支實體 Android 手機訂閱正式主題測一次,收到通知、點開到詳情都正常。
iOS 的程式已經寫好,但要真的收到推播,還要:
在這之前,iOS 版的推播設定頁會說明 iPhone/iPad 目前還不支援推播。
tag 和 iOS 的 apns-collapse-id 用同一個合併鍵,同一則只留一個通知。明天 Day 26:三則警報,該合成一則通知嗎?讓 Gemini 當防災代理的界線。