iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 17

Day 17: Firebase Cloud Messaging - 基礎理論與跨平台實戰

  • 分享至 

  • xImage
  •  

在完成了基礎功能後,今天我們要為 Crew Up! 裝上一個至關重要的系統:推播通知 (Push Notifications)。一個好的通知系統,就像 App 的即時心跳,能在關鍵時刻喚醒使用者,提升互動與黏著度。

今天,我們將專注於 Firebase Cloud Messaging (FCM) 的核心概念,並分享我們如何在 Android 和 iOS 雙平台上成功實作推播通知的完整過程。

🎯 核心理論:FCM 是如何工作的?

在動手之前,理解 FCM 的基本工作原理至關重要。

FCM 的演進與跨平台角色

Firebase Cloud Messaging (FCM) 是 Google Cloud Messaging (GCM) 的後繼者,於 2016 年推出。值得注意的是,FCM 在不同平台上扮演著不同的角色:

  • Android:FCM 直接與 Google Play Services 整合,提供原生推播支援。
  • iOS:FCM 實際上是一個代理 (Proxy) 角色,最終仍透過蘋果的 APNs (Apple Push Notification service) 來發送通知。FCM 幫助開發者統一後端邏輯,無需分別對接 APNs 和 Android 推播系統。

這種設計讓我們能用同一套後端程式碼,同時支援 Android 和 iOS 的推播功能。

訊息的生命週期

一則推播通知從發送到顯示,會經歷以下旅程:

後端伺服器/Firebase 控制台
    ↓ 發送推播請求
FCM 伺服器
    ↓ 路由到對應平台
    ├─→ Android: Google Play Services
    └─→ iOS: APNs → iOS 裝置
        ↓
使用者裝置接收
    ↓
App 處理 (前景) / 系統顯示 (背景/終止)
    ↓
使用者看到通知

可能失敗的環節:

  • ❌ 裝置離線或網路不穩定
  • ❌ 裝置處於省電模式,限制背景連線
  • ❌ 使用者關閉了 App 的通知權限
  • ❌ FCM Token 過期或無效
  • ❌ Android:Google Play Services 未安裝或版本過舊
  • ❌ iOS:未正確設定 APNs 憑證

訊息類型深度比較: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,直接導航到該活動的詳情頁面。

核心概念

  1. FCM Token:每個安裝 App 的裝置都會從 FCM 伺服器獲取一個獨一無二的 Token。您可以將其視為該裝置的「門牌號碼」。

  2. 應用狀態:App 接收通知時有三種狀態,處理方式各不相同。

    • 前景 (Foreground):App 正在螢幕上運行。此時所有通知都會交給 App 處理,系統不會自動顯示。
    • 背景 (Background):App 仍在運行,但使用者已切換到其他 App 或桌面。
    • 終止 (Terminated):App 已被完全關閉。

對於今天的目標——讓 Android 顯示通知,我們主要會用到「通知訊息」,並處理「前景」狀態下的顯示問題。

🔧 Android 實戰:從零到顯示第一則通知

以下是我們讓 Android 裝置成功顯示推播通知的詳細步驟。

1. 加入依賴套件

我們需要 FCM 核心套件:

# pubspec.yaml
dependencies:
  firebase_messaging: ^15.0.2  # FCM 核心套件

firebase_messaging 套件負責與 FCM 伺服器通訊,處理推播通知的接收與顯示。

2. Android 平台配置

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 開始,所有通知都必須歸屬於一個「渠道」。它允許使用者對不同類型的通知進行精細化管理(例如,關閉行銷通知,但保留帳戶通知)。

3. Flutter 程式碼實作

我們建立一個 FcmService 來封裝所有相關邏輯。

為什麼選擇獨立的 Service?

在設計時,我們考慮過幾種方案:

  1. 直接在 main.dart 中初始化 FCM
  2. 將邏輯寫在某個 Provider 或 Widget 中
  3. 建立獨立的 FcmService 類別

最終我們選擇了第三種方案,原因如下:

  • 單一職責原則:FCM 相關邏輯內聚在一處,易於維護
  • 可測試性:獨立的類別更容易撰寫單元測試
  • 可擴展性:未來要加入 Token 管理、主題訂閱等功能時,有清晰的擴充點
  • 關注點分離:UI 層(Provider/Widget)不需要知道推播的實作細節

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 初始化過程中,有幾個關鍵的失敗點需要特別注意:

  1. 權限被拒絕:使用者可能拒絕通知權限
  2. Token 獲取失敗:網路問題或配置錯誤可能導致 Token 為 null
  3. 初始化異常:其他未預期的錯誤

我們的錯誤處理策略:

  • 型別安全:使用 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 管理)           │
└────────────────────────────────────┘
  • FcmService:我們自己的類別,負責統籌管理
  • FirebaseMessaging:FCM SDK,處理與伺服器的通訊、Token 管理、訊息接收

C. 背景與終止狀態呢?

對於包含 notification 區塊的推播訊息,當我們的 App 處於背景終止狀態時,FCM SDK 會自動在系統通知欄顯示通知,我們目前無需編寫額外程式碼。使用者點擊該通知後,App 會被開啟。

4. 成果!

完成以上步驟後,我們從 Firebase 控制台發送一則測試通知:

測試結果:

  • 背景/終止狀態:✅ 系統自動顯示通知,完美運作
  • 前景狀態:⚠️ 訊息有收到,但通知不會自動顯示(這是 FCM 的預設行為)

前景通知的處理方式:
當 App 在前景時,我們可以選擇:

  1. 不顯示通知(因為使用者正在使用 App)
  2. 顯示 App 內通知(如 SnackBar、Dialog)
  3. 更新 UI 狀態(如未讀訊息數量)

目前我們的實作重點在背景通知,前景時主要記錄訊息並更新 App 狀態。

🍎 iOS 實戰:APNs 整合之路

在完成 Android 實作後,我們繼續挑戰 iOS 平台。iOS 的推播通知需要透過 Apple Push Notification service (APNs) 運作,設定相對複雜但也更完整。

1. iOS 平台配置

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)
  }
}

🎯 程式碼重點:

  1. UNUserNotificationCenter.current().delegate = self

    • 設定 AppDelegate 為通知處理的委託
    • 這樣才能接收通知事件的回調
  2. requestAuthorization

    • 請求使用者授權三種通知類型:提醒 (alert)、徽章 (badge)、聲音 (sound)
    • 首次執行時會彈出系統權限對話框
  3. registerForRemoteNotifications

    • 向 APNs 註冊裝置
    • 成功後會呼叫 didRegisterForRemoteNotificationsWithDeviceToken
    • 失敗則呼叫 didFailToRegisterForRemoteNotificationsWithError

2. Xcode 專案設定

除了程式碼,還需要在 Xcode 中完成以下設定:

A. 啟用 Push Notifications Capability

  1. 在 Xcode 中打開專案:ios/Runner.xcworkspace
  2. 選擇 Runner target
  3. 前往 "Signing & Capabilities" 頁籤
  4. 點擊 "+ Capability",加入 "Push Notifications"

B. 啟用 Background Modes

  1. 同樣在 "Signing & Capabilities" 頁籤
  2. 加入 "Background Modes"
  3. 勾選 "Remote notifications"

3. Firebase 專案設定 APNs

這是 iOS 推播最關鍵的一步:

A. 產生 APNs 金鑰

  1. 前往 Apple Developer 網站
  2. 建立新的 Key,勾選 "Apple Push Notifications service (APNs)"
  3. 下載 .p8 金鑰檔案(只能下載一次,請妥善保管)
  4. 記下 Key ID 和 Team ID

B. 上傳金鑰到 Firebase

  1. 前往 Firebase Console > 專案設定 > Cloud Messaging
  2. 在 "Apple app configuration" 區塊
  3. 上傳 APNs 金鑰檔案 (.p8)
  4. 填入 Key ID 和 Team ID

4. iOS 推播測試

完成設定後,使用實體 iOS 裝置測試:

// 取得 FCM Token
final token = await FirebaseMessaging.instance.getToken();
print('iOS FCM Token: $token');

從 Firebase Console 發送測試通知,應該能在 iOS 裝置上看到推播!

📱 iOS 特有的通知行為:

  • 前景時:通知不會自動顯示(需自行處理)
  • 背景時:系統自動顯示通知
  • 終止時:系統自動顯示通知
  • 通知中心:可查看歷史通知
  • 聲音和震動:根據裝置靜音模式決定

⚠️ 開發過程中踩到的坑

坑 #1:模擬器與 Google 登入的相容性問題

在測試 FCM 的過程中,我們發現了一個關鍵問題:由於專案中整合了 Google 登入功能,模擬器無法正常驗證 FCM

當嘗試在模擬器上測試時,會遇到 Request interrupted by user 的錯誤,導致 FCM Token 無法正確取得,推播功能也就無法驗證。

解決方案:使用實體 Android 手機進行測試。

💡 給準備整合 FCM 的開發者的建議:

如果您的專案也有 Google 登入或其他 Google Play Services 相依功能,請務必盡早準備一台實體測試機。不要等到要測試推播時才發現模擬器不可用,這會大幅影響開發進度。

實體裝置除了能驗證 FCM,也能更真實地測試推播通知的顯示效果、聲音震動等使用者體驗細節。

坑 #2:iOS 模擬器無法取得 FCM Token

當準備開始測試 iOS 推播時,我們發現了一個重要限制:iOS 模擬器無法取得 FCM Token

原因:
FCM 在 iOS 上需要透過 APNs (Apple Push Notification service) 來運作,而 APNs 不支援模擬器。因此:

  • 在 iOS 模擬器上呼叫 getToken() 會返回 null
  • 無法接收任何推播通知
  • 即使所有設定都正確,模擬器就是無法測試推播功能

解決方案:必須使用實體 iOS 裝置進行測試。

此外,iOS 推播還需要:

  1. Apple Developer 付費帳號
  2. 正確設定 APNs 金鑰或憑證
  3. 在 Xcode 中啟用 Push Notifications capability
  4. 實體裝置連接並安裝 App

⚠️ 重要提醒

  • Android 模擬器:大部分可以測試 FCM(需安裝 Google Play Services)
  • iOS 模擬器:完全無法測試 FCM,這是系統限制

如果您要開發 iOS 推播功能,請務必提前準備實體測試裝置和 Apple Developer 帳號!

🚀 未來展望

今天我們成功地在 Android 和 iOS 雙平台上完成了推播通知的整合,但一個完整的推播系統還有很長的路要走。以下是我們接下來的計畫:

  1. 深度連結 (Deep Linking):實作點擊通知後,直接導航到 App 內特定頁面(如某個活動詳情頁或聊天室)的功能。
  2. 後端整合:開發 Cloud Function,讓我們的後端可以在特定事件發生時(例如:有新活動建立)自動發送通知給所有使用者。
  3. Token 管理:將裝置的 FCM Token 儲存到 Firestore,以便後端可以發送個人化通知。
  4. 主題訂閱 (Topic Subscription):提供設定選項,讓使用者可以自由訂閱或取消訂閱他們感興趣的通知類型(如「新活動通知」、「訊息提醒」等)。
  5. 前景通知優化:改善 App 在前景時的通知顯示體驗,提供更友善的應用內通知。

🎯 結語

今天我們從理論到實踐,成功打通了 FCM 在 Android 和 iOS 雙平台上的完整流程。從 Android 的 Notification Channel 到 iOS 的 APNs 整合,每個平台都有其獨特的挑戰。透過這個過程,我們深刻體會到,在處理原生功能時,仔細閱讀文件和理解平台差異是多麼重要。

關鍵收穫:

  • ✅ Android 需要設定 Notification Channel 和權限
  • ✅ iOS 需要 APNs 金鑰和 Xcode Capabilities 設定
  • ✅ 雙平台都需要實體裝置才能測試推播功能
  • ✅ Firebase 提供統一的 API,簡化跨平台開發

雖然設定過程有些複雜,但一旦打通,後續的推播功能開發就會順暢許多。這為我們後續的進階功能(如深度連結、主題訂閱)打下了堅實的基礎。

下一步

明天,我們將深入探討 Firebase Crashlytics,學習如何建立完整的錯誤追蹤與分析系統,讓我們能即時掌握 App 的穩定性狀況。

期待與您在 Day 18 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 17 - Firebase Cloud Messaging:基礎理論與 Android 實戰
  • 文章日期: 2025-09-30
  • 技術棧: Firebase Cloud Messaging, APNs, Google Play Services

上一篇
Day 16 - Firebase Storage:檔案上傳與管理的最佳實踐
下一篇
Day 18: Firebase Crashlytics - 錯誤追蹤與分析系統
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言