在完成了基礎功能後,今天我們要為 Crew Up! 裝上一個至關重要的系統:推播通知 (Push Notifications)。一個好的通知系統,就像 App 的即時心跳,能在關鍵時刻喚醒使用者,提升互動與黏著度。
今天,我們將專注於 Firebase Cloud Messaging (FCM) 的核心概念,並分享我們如何在 Android 和 iOS 雙平台上成功實作推播通知的完整過程。
在動手之前,理解 FCM 的基本工作原理至關重要。
Firebase Cloud Messaging (FCM) 是 Google Cloud Messaging (GCM) 的後繼者,於 2016 年推出。值得注意的是,FCM 在不同平台上扮演著不同的角色:
這種設計讓我們能用同一套後端程式碼,同時支援 Android 和 iOS 的推播功能。
一則推播通知從發送到顯示,會經歷以下旅程:
後端伺服器/Firebase 控制台
↓ 發送推播請求
FCM 伺服器
↓ 路由到對應平台
├─→ Android: Google Play Services
└─→ iOS: APNs → iOS 裝置
↓
使用者裝置接收
↓
App 處理 (前景) / 系統顯示 (背景/終止)
↓
使用者看到通知
可能失敗的環節:
notification
vs. data
特性 | notification 訊息 | data 訊息 |
---|---|---|
優點 | • 簡單、可靠• App 在背景/終止時自動顯示• 無需額外程式碼 | • 靈活、強大• 可完全自訂通知樣式• 可在背景靜默處理資料 |
缺點 | • 客製化程度低• 無法在背景執行邏輯• 前景時需手動顯示 | • 實作複雜• 需自行編寫前景、背景、終止狀態的處理邏輯• 容易出錯 |
適用場景 | • 簡單的內容更新• 系統公告• 行銷訊息 | • 即時通訊 (IM) 訊息• 需要更新本地資料庫的通知• 複雜的業務邏輯觸發 |
範例 | 「您有新的活動邀請!」 | 收到聊天訊息後,更新本地資料庫並顯示未讀數 |
在 Crew Up! 中的選擇:
我們使用混合模式:notification
區塊負責顯示通知,data
區塊傳遞額外資料(如深度連結、活動 ID)。這讓我們既能享受系統自動顯示通知的便利性,又能在使用者點擊通知時導航到正確的頁面。
實際的 FCM 訊息範例:
{
"notification": {
"title": "新活動邀請",
"body": "張小明邀請您參加「週末爬山活動」"
},
"data": {
"type": "activity_invitation",
"activityId": "activity_123",
"senderId": "user_456"
}
}
當使用者點擊通知時,我們可以從 data
區塊取得 activityId
,直接導航到該活動的詳情頁面。
FCM Token:每個安裝 App 的裝置都會從 FCM 伺服器獲取一個獨一無二的 Token。您可以將其視為該裝置的「門牌號碼」。
應用狀態:App 接收通知時有三種狀態,處理方式各不相同。
對於今天的目標——讓 Android 顯示通知,我們主要會用到「通知訊息」,並處理「前景」狀態下的顯示問題。
以下是我們讓 Android 裝置成功顯示推播通知的詳細步驟。
我們需要 FCM 核心套件:
# pubspec.yaml
dependencies:
firebase_messaging: ^15.0.2 # FCM 核心套件
firebase_messaging
套件負責與 FCM 伺服器通訊,處理推播通知的接收與顯示。
A. Gradle 設定
確保您的 android/app/build.gradle
檔案中已應用 google-services
插件。
// android/app/build.gradle
apply plugin: 'com.google.gms.google-services'
B. AndroidManifest.xml 設定
這是最關鍵的一步。我們需要加入通知權限,並設定一個預設的通知渠道 (Notification Channel)。
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest ...>
<!-- Android 13 (API 33) 以上版本需要的通知權限 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application ...>
<activity ...>
...
</activity>
<!-- 設定預設的通知渠道 ID,FCM 會用它來顯示通知 -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel" />
</application>
</manifest>
什麼是通知渠道 (Notification Channel)?
從 Android 8.0 開始,所有通知都必須歸屬於一個「渠道」。它允許使用者對不同類型的通知進行精細化管理(例如,關閉行銷通知,但保留帳戶通知)。
我們建立一個 FcmService
來封裝所有相關邏輯。
為什麼選擇獨立的 Service?
在設計時,我們考慮過幾種方案:
main.dart
中初始化 FCMFcmService
類別最終我們選擇了第三種方案,原因如下:
A. 初始化與權限請求
// lib/features/notification/data/services/fcm_service.dart
// (imports omitted)
class FcmService {
static final FirebaseMessaging _messaging = FirebaseMessaging.instance;
static Future<void> initialize() async {
try {
developer.log('🚀 初始化 FCM 服務', name: 'FCMService');
// 1. 請求權限 (對 Android 13+ 生效)
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
// 檢查權限狀態
if (settings.authorizationStatus == AuthorizationStatus.denied) {
developer.log('⚠️ 通知權限被拒絕', name: 'FCMService');
// 記錄使用者拒絕權限的錯誤
await ErrorHandler.handleUserActionError(
'requestNotificationPermission',
'User denied push notification permission',
null,
context: {
'authorization_status': settings.authorizationStatus.toString(),
'service': 'FCMService',
},
);
return; // 提前返回,不繼續初始化
}
// 2. 獲取 FCM Token
final token = await _messaging.getToken();
if (token == null) {
developer.log('⚠️ 無法取得 FCM Token', name: 'FCMService');
// 記錄 Token 為 null 的錯誤
await ErrorHandler.handleUserActionError(
'getFCMToken',
'FCM token is null - this may indicate a configuration issue',
null,
context: {
'service': 'FCMService',
'operation': 'getToken',
},
);
return; // 提前返回,不繼續處理
}
developer.log('🔑 FCM Token: ${token.substring(0, 20)}...', name: 'FCMService');
// 3. 設定訊息處理
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
developer.log('✅ FCM 服務初始化完成', name: 'FCMService');
} on Exception catch (e) {
developer.log('❌ FCM 初始化失敗: $e', name: 'FCMService');
await ErrorHandler.handleException(
'FCM initialization failed: $e',
e,
context: {'service': 'FCMService', 'operation': 'initialize'},
);
}
}
}
錯誤處理的重要性
在 FCM 初始化過程中,有幾個關鍵的失敗點需要特別注意:
我們的錯誤處理策略:
ErrorHandler.handleUserActionError()
記錄使用者操作錯誤權限請求的時機策略
在 Crew Up! 中,我們不會在 App 啟動時立即請求通知權限。根據使用者體驗最佳實踐,我們計畫在以下時機才溫和地引導使用者:
📱 最佳請求時機:使用者成功加入第一個活動後
當使用者完成加入活動的操作,我們會顯示一個友善的對話框:
「太好了!您已成功加入活動。開啟通知提醒,就能即時收到活動的最新動態和訊息喔!」
此時使用者剛體驗到產品價值,對「為什麼需要通知」有清晰的認知,授權率會大幅提升。
通知渠道與使用者體驗
值得注意的是,您在程式碼中定義的渠道名稱「重要通知」,會直接顯示在使用者手機的系統設定 > 應用程式 > Crew Up! > 通知 中。使用者可以在這裡精細控制每個渠道的行為(是否顯示、聲音、震動等)。
因此,給予渠道一個易於理解且反映用途的名稱至關重要。「重要通知」比「Channel_1」更能讓使用者理解這個渠道的用途。
B. 處理前景訊息
當 App 在前景時,FCM 訊息的處理方式:
前景訊息處理流程:
FCM 伺服器發送訊息
↓
裝置接收(App 在前景)
↓
FirebaseMessaging.onMessage 觸發
↓
_handleForegroundMessage() 被呼叫
↓
記錄訊息內容並處理相關邏輯
實作程式碼:
// 續 FcmService 類別
static void _handleForegroundMessage(RemoteMessage message) {
developer.log('📨 收到前景推播訊息');
developer.log('標題: ${message.notification?.title}');
developer.log('內容: ${message.notification?.body}');
developer.log('資料: ${message.data}');
// 在前景時,我們可以:
// 1. 更新 UI 狀態
// 2. 儲存訊息到本地資料庫
// 3. 顯示 App 內通知(如 SnackBar)
}
static void _handleNotificationTap(RemoteMessage message) {
developer.log('👆 用戶點擊了推播通知');
// 處理深度連結,導航到相應頁面
if (message.data.isNotEmpty) {
final activityId = message.data['activityId'];
// 導航到活動詳情頁
}
}
核心架構:
┌─────────────────────────────────────┐
│ FcmService │
│ (統籌管理、初始化、監聽訊息) │
└────────────────┬────────────────────┘
│
↓
┌────────────────────────────────────┐
│ FirebaseMessaging │
│ (與 FCM 通訊、Token 管理) │
└────────────────────────────────────┘
C. 背景與終止狀態呢?
對於包含 notification
區塊的推播訊息,當我們的 App 處於背景或終止狀態時,FCM SDK 會自動在系統通知欄顯示通知,我們目前無需編寫額外程式碼。使用者點擊該通知後,App 會被開啟。
完成以上步驟後,我們從 Firebase 控制台發送一則測試通知:
測試結果:
前景通知的處理方式:
當 App 在前景時,我們可以選擇:
目前我們的實作重點在背景通知,前景時主要記錄訊息並更新 App 狀態。
在完成 Android 實作後,我們繼續挑戰 iOS 平台。iOS 的推播通知需要透過 Apple Push Notification service (APNs) 運作,設定相對複雜但也更完整。
A. Info.plist 設定
首先,我們需要在 Info.plist
中啟用背景模式和設定相關權限:
<!-- ios/Runner/Info.plist -->
<dict>
<!-- 啟用背景模式,允許 App 在背景接收遠端通知 -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<!-- 停用 Firebase 自動代理,改用手動設定 -->
<key>FirebaseAppDelegateProxyEnabled</key>
<false/>
<!-- APNs 環境設定(development 或 production) -->
<key>aps-environment</key>
<string>development</string>
</dict>
🎯 設定說明:
UIBackgroundModes
:告訴 iOS 系統,這個 App 需要在背景處理遠端通知FirebaseAppDelegateProxyEnabled
:設為 false
讓我們完全控制通知處理流程aps-environment
:指定使用開發環境的 APNs,上架時改為 production
B. AppDelegate.swift 實作
iOS 的推播設定需要在 AppDelegate.swift
中完成:
// ios/Runner/AppDelegate.swift
import Flutter
import UIKit
import Firebase
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// 初始化 Firebase
FirebaseApp.configure()
GeneratedPluginRegistrant.register(with: self)
// 設定通知中心的委託
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self
// 請求通知權限:提醒、徽章、聲音
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: { _, _ in }
)
} else {
// iOS 10 以下的舊版本支援
let settings: UIUserNotificationSettings =
UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
application.registerUserNotificationSettings(settings)
}
// 向 APNs 註冊推播通知
application.registerForRemoteNotifications()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// 處理 APNs Token 註冊成功
override func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
}
// 處理 APNs Token 註冊失敗
override func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
}
}
🎯 程式碼重點:
UNUserNotificationCenter.current().delegate = self
AppDelegate
為通知處理的委託requestAuthorization
registerForRemoteNotifications
didRegisterForRemoteNotificationsWithDeviceToken
didFailToRegisterForRemoteNotificationsWithError
除了程式碼,還需要在 Xcode 中完成以下設定:
A. 啟用 Push Notifications Capability
ios/Runner.xcworkspace
B. 啟用 Background Modes
這是 iOS 推播最關鍵的一步:
A. 產生 APNs 金鑰
.p8
金鑰檔案(只能下載一次,請妥善保管)B. 上傳金鑰到 Firebase
完成設定後,使用實體 iOS 裝置測試:
// 取得 FCM Token
final token = await FirebaseMessaging.instance.getToken();
print('iOS FCM Token: $token');
從 Firebase Console 發送測試通知,應該能在 iOS 裝置上看到推播!
📱 iOS 特有的通知行為:
在測試 FCM 的過程中,我們發現了一個關鍵問題:由於專案中整合了 Google 登入功能,模擬器無法正常驗證 FCM。
當嘗試在模擬器上測試時,會遇到 Request interrupted by user
的錯誤,導致 FCM Token 無法正確取得,推播功能也就無法驗證。
解決方案:使用實體 Android 手機進行測試。
💡 給準備整合 FCM 的開發者的建議:
如果您的專案也有 Google 登入或其他 Google Play Services 相依功能,請務必盡早準備一台實體測試機。不要等到要測試推播時才發現模擬器不可用,這會大幅影響開發進度。
實體裝置除了能驗證 FCM,也能更真實地測試推播通知的顯示效果、聲音震動等使用者體驗細節。
當準備開始測試 iOS 推播時,我們發現了一個重要限制:iOS 模擬器無法取得 FCM Token。
原因:
FCM 在 iOS 上需要透過 APNs (Apple Push Notification service) 來運作,而 APNs 不支援模擬器。因此:
getToken()
會返回 null
解決方案:必須使用實體 iOS 裝置進行測試。
此外,iOS 推播還需要:
⚠️ 重要提醒:
- Android 模擬器:大部分可以測試 FCM(需安裝 Google Play Services)
- iOS 模擬器:完全無法測試 FCM,這是系統限制
如果您要開發 iOS 推播功能,請務必提前準備實體測試裝置和 Apple Developer 帳號!
今天我們成功地在 Android 和 iOS 雙平台上完成了推播通知的整合,但一個完整的推播系統還有很長的路要走。以下是我們接下來的計畫:
今天我們從理論到實踐,成功打通了 FCM 在 Android 和 iOS 雙平台上的完整流程。從 Android 的 Notification Channel 到 iOS 的 APNs 整合,每個平台都有其獨特的挑戰。透過這個過程,我們深刻體會到,在處理原生功能時,仔細閱讀文件和理解平台差異是多麼重要。
關鍵收穫:
雖然設定過程有些複雜,但一旦打通,後續的推播功能開發就會順暢許多。這為我們後續的進階功能(如深度連結、主題訂閱)打下了堅實的基礎。
明天,我們將深入探討 Firebase Crashlytics,學習如何建立完整的錯誤追蹤與分析系統,讓我們能即時掌握 App 的穩定性狀況。
期待與您在 Day 18 相見!