Flutter中如何使用Firebase 做消息推送(Notification)

又到了发文的时候了,不懒散,不娇作,写就完了~
今天我们的主题是推送,这在所有的App中都是最基本的功能,第三方做推送的平台挺多的,这里就不一一列举了,我们主要是介绍如何使用Firebase Clound Messaging功能做推送。

老规矩,先上酸菜~
《Flutter的拨云见日》系列文章如下:
1、Flutter中指定字体(全局或者局部,自有字库或第三方)
2、Flutter发布Package(Pub.dev或私有Pub仓库)
3、Flutter中解决输入框(TextField)被键盘遮挡问题
4、Flutter 如何在不同环境上运行和打包(多环境部署)
5、Flutter 中为Firebase提供多个构建环境分离配置
6、Flutter中Firebase实时数据库Database使用
7、Flutter中如何使用Firebase 做消息推送(Notification)

一、引入firebase_messaging库

因为我们前面几篇写了关于Firebase的工程建立、项目创建和接入、多环境分离部署等,这也是为我们这篇推送做一些前期的准备工作。这里就不细讲了,请参考前文~

1.1 首先,我们需要引入pub.dev上的firebase_messaging第三方库

在pubspec.yaml文件中加入

dependencies:
  flutter:
    sdk: flutter
  firebase_messaging: 7.0.3
1.2 使用flutter pub get或者在Android Studio中使用图形化操作(如图1.2),下载firebase推送库到本地

二、分别设置Android和Ios工程推送配置

2.1 Android端配置

2.1.1 首先,我们前一篇文章讲了如何分离Firebase环境,讲了如何配置不同环境的google-service.json,当然如果你不分离环境,只是先玩一玩也可以直接将该文件放在android/app目录下。

2.1.2 然后我们需要在根build.gradle文件中添加google-services依赖,这都是老生常谈了,不多讲了~

    dependencies {
        classpath 'com.google.gms:google-services:4.3.3'

2.1.3 在app/build.gradle文件中添加插件和依赖(PS: 熟悉Android都应该知道有两个build.gradle文件)

apply plugin: 'com.google.gms.google-services'
dependencies {
    implementation 'com.google.firebase:firebase-messaging:20.2.4'

2.1.4 在app/src/main/AndroidManifest.xml文件中添加如下intentfilter,这是为了消息通知被点击时,被firebase-message捕获

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="*******">
    <application
        <!-- 推送通知图标-->
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_icon"
            android:resource="@drawable/ic_notification_icon" />
        <activity
            android:name=".MainActivity"
            <!-- 通知点击intentfilter -->
            <intent-filter>
                <action android:name="FLUTTER_NOTIFICATION_CLICK"/>
                <category android:name="android.intent.category.DEFAULT"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

2.1.5 如果android工程下没有Application.java文件,新建一个(android/app/main/kotlin)/**Application.kt),并且记得修改AndroidMainfest.xml中application的名字与其一致

class ***Application : FlutterApplication(),  PluginRegistry.PluginRegistrantCallback{
    override fun onCreate() {
        super.onCreate()
        FlutterFirebaseMessagingService.setPluginRegistrant(this)
    override fun registerWith(registry: PluginRegistry?) {
        FirebaseMessagingPlugin.registerWith(registry?.registrarFor("io.flutter.plugins.firebasemessaging.FirebaseMessagingPlugin"))
2.2 iOS端配置

2.2.1 首先,也是firebase ios配置文件GoogleService-Info.plist的引入,可以参照如何分离Firebase环境,如不分离环境也可以直接放置在Runner下如图2.2.1

图2.2.2.png

2.2.3 如果您需要禁用FCM iOS SDK完成的方法转换(以便可以将此插件与其他Notificatio plugin一起使用),则将以下内容添加到应用程序的Info.plist文件中

<key>FirebaseAppDelegateProxyEnabled</key>
<false/>

2.2.4 在AppDelegete.m或AppDelegete.swift中加入以下代码
Swift:

import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    if #available(iOS 10.0, *) {
      UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate

Objective-C:

if (@available(iOS 10.0, *)) {
  [UNUserNotificationCenter currentNotificationCenter].delegate = (id<UNUserNotificationCenterDelegate>) self;

2.2.5 在Apple Store开发者账号中获取APNs令牌
我们需要在Apple Store, 配置FCM APNS(https://firebase.google.com/docs/cloud-messaging/ios/certs)
这里分为两个部分:创建身份验证密钥和创建应用ID。因为文章篇幅问题,而且官网写的比较详细,请大家自行参考官网链接,已附上。

2.2.6 最后需要配置将APNs令牌映射到FCM注册令牌
接下来,将你刚刚创建好的的 APNs 身份验证密钥上传到 Firebase。如果您还没有 APNs 身份验证密钥,请参阅配置 FCM APNs。

  • 在 Firebase 控制台中,在您的项目内依次选择齿轮图标、项目设置以及 Cloud Messaging 标签页。

  • 在 iOS 应用配置下的 APNs 身份验证密钥中,点击上传按钮。如图
    Notification on Android onMessage Notification被传递到系统,当用户点击推送通知时,如果设置了click_action: FLUTTER_NOTIFICATION_CLICK, 则onResume被触发 Notification被传递到系统,当用户点击推送通知时,如果设置了click_action: FLUTTER_NOTIFICATION_CLICK, 则onLaunch被触发。 Notification on iOS onMessage Notification被传递到系统,当用户点击推送通知时, 则onResume被触发 Notification被传递到系统,当用户点击推送通知时,则onLaunch被触发 Data Msg on Android onMessage onMessage 插件不支持,消息丢失 Data Msg on iOS onMessage 消息由FCM存储,并在应用回到前台时通过onMessage触发 消息由FCM存储,并在应用回到前台时通过onMessage触发

    因为Firebase 推送消息有两种一种是Notification ,一种是Data message消息,以上表格是两种消息分别在Android、iOS平台应用状态不同时的回调接口情况

    Flutter 处理代码如下:

      static void handleNotification() {
        if(firebaseMessaging == null){
          firebaseMessaging = FirebaseMessaging();
        firebaseMessaging.configure(
          //处理前台app接受消息,可以在使用flutter_local_notifications插件再发出一个本地通知
          onMessage: (message) => handleMessage(message), 
          //处理从系统通知栏点击推送时的页面跳转问题
          onLaunch: (message) => startToRedirectByNotification(message, source: 'onLaunch'),
          onResume: (message) => startToRedirectByNotification(message, source: 'onResume'),
          onBackgroundMessage: backgroundMessageHandler //todo
      static handleMessage(Map<String, dynamic> message) {
        try {
          String notificationPayload = '';
          String notificationTitle = '';
          String notificationContent = '';
          if (Platform.isAndroid) {
            notificationPayload = json.encode(message['data']);
            notificationTitle = message['notification']['title'];
            notificationContent = message['notification']['body'];
          } else {
            notificationPayload = json.encode(message);
            notificationTitle = message['aps']['alert']['title'];
            notificationContent = message['aps']['alert']['body'];
          sendLocalNotification(notificationTitle, notificationContent, notificationPayload);
        }catch(error){
          LogUtil.e(error);
      static Future<void> backgroundMessageHandler(Map<String, dynamic> message) {
        // to do
    
    3.2 firebase_messaging库如何本地解绑

    基于因为Login之后,token会有过期的行为。当token过期后(401) ,一般App会退出登录重定向到登录界面,这是一般要解绑推送,不然都退出登录了还能收到推送,这看似不太合适。

    如果是正常Sign Out流程话,我们会调用后台的unbind接口和服务端解绑,这样就收不到推送了。

    但是这种就不适用token过期的情况了,token过期后,后台和服务器解绑的unbind接口已经调不通了,这时就尴尬了,不可能退出登录还在收推送吧?

    这样就需要使用firebase_messaging库进行本地解绑,使本地推送库不处理服务端发来的推送。

    //这里调用这个方法就可以了,删除绑定的token
    /// Resets Instance ID and revokes all tokens. In iOS, it also unregisters from remote notifications.
    /// A new Instance ID is generated asynchronously if Firebase Cloud Messaging auto-init is enabled.
    /// returns true if the operations executed successfully and false if an error ocurred
    FirebaseMessaging().deleteInstanceID();
    

    四、本地推送库引入(flutter_local_notifications)

    4.1 本地推送库发送本地Notification

    根据三,我们在处理Firebase message onMessage消息时,根据项目需要也需要是一个推送,从推送时机表格中,我们看到如果app在前台,收到firebase推送消息时,是不会产生一个系统消息通知出来,所以这里需要自己本地发出一个Notification.

    这里我们使用到的是pub.dev上的flutter_local_notifications插件解决,具体用法就自己上去看一看,我这边配置大概如下,使用大同小异。

    class LocalNotificationServer {
      static FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
      static initLocalNotification(){
        var initializationSettingsAndroid = AndroidInitializationSettings('ic_notification_icon');
        var initializationSettingsIOS = new IOSInitializationSettings(
            onDidReceiveLocalNotification: onDidReceiveLocalNotification);
        var initializationSettings = new InitializationSettings(
            android: initializationSettingsAndroid, iOS: initializationSettingsIOS);
        flutterLocalNotificationsPlugin.initialize(initializationSettings,
            onSelectNotification: onSelectNotification);
      static Future onDidReceiveLocalNotification(
          int id, String title, String body, String payload) async {
      static Future onSelectNotification(String payload) async {
      static Future showNotification({int id, String title, String content, String payload}) async {
        //安卓的通知配置,必填参数是渠道id, 名称, 和描述, 可选填通知的图标,重要度等等。
        var androidPlatformChannelSpecifics = new AndroidNotificationDetails(
            'your channel id',
            'your channel name',
            'your channel description',
            importance: Importance.max,
            priority: Priority.high,
            styleInformation: BigTextStyleInformation('')
        //IOS的通知配置
        var iOSPlatformChannelSpecifics = new IOSNotificationDetails();
        var platformChannelSpecifics = new NotificationDetails(
            android: androidPlatformChannelSpecifics, iOS: iOSPlatformChannelSpecifics);
        //显示通知,其中 0 代表通知的 id,用于区分通知。
        await flutterLocalNotificationsPlugin.show(
            id, title, content, platformChannelSpecifics,
            payload: payload);
    
    4.2 本地推送库退出登录时删除显示在状态栏的推送通知

    当我们退出登录时,我们有需求清理本App显示在状态栏的的通知,不然都退出登录了,点击推送通知还能跳到应用内部页面,不合适!!!
    可以使用下面的方法

      /// Cancel/remove the notification with the specified id.
      /// This applies to notifications that have been scheduled and those that
      /// have already been presented.
      Future<void> cancel(int id) async {
        await FlutterLocalNotificationsPlatform.instance?.cancel(id);
      /// Cancels/removes all notifications.
      /// This applies to notifications that have been scheduled and those that
      /// have already been presented.
      Future<void> cancelAll() async {
        await FlutterLocalNotificationsPlatform.instance?.cancelAll();
    

    五、firebase_messaging和flutter_local_notifications存在的问题和解决

    我在使用firebase_messaging和flutter_local_notifications两个库做功能测试时,发现绝大部分情况都是非常正常,我测着测着就发现了问题,着两个库都存在在某些情况下都调用多次处理推送的回调方法,如firebase_messaging的onLaunch方法和flutter_local_notifications的onSelectNotification方法。

    5.1 firebase_messaging库问题

    这个库在我使用的时候是7.0.3版本,在Android机型上,将app置于后台时,发出推送通过系统通知点击后会触发多次onLaunch。这个问题当时在github issue上有提过和解决方案,当时flutter官方没有采纳,我等不及,就自己copy了一份自己修改上传到本地pub.dev上了。

    但是在实际测试使用推送过程中,我发现其实这问题在ios上也有,也是需要修改ios端本地代码的。

    我的修改是基于firebase_messaging 7.0.3上修改的,最近看了下好像更新了,不一样的写法了,可能官方已经解决了这个问题,这里还是记录下,给与参考。

    5.1.1 Android端的代码修改

    Android端出现问题呢,原因在于:当App在后台进程被杀时,插件接受到推送会直接通过系统通知形式展示,当你点击Notification时,通过调用OnLaunch 来启动应用并通过你的逻辑完成相应功能,这并没啥问题,当你再次返回桌面,也就是将应用置于后台时,再进入应用,会启动onLaunch方法,将你的消息通知再来走一边。

    修改文件android/src/main/java/io/flutter/plugins/firebasemessaging/FirebaseMessagingPlugin.java

     @Override
      public void onMethodCall(final MethodCall call, final Result result) {
        if ("FcmDartService#start".equals(call.method)) {
        } else if ("FcmDartService#initialized".equals(call.method)) {
        } else if ("configure".equals(call.method)) {
          //我主要修改了下这里,增加了intent的Flag校正,是从History来的不回调OnLaunch方法
          if (mainActivity != null && !launchedActivityFromHistory(mainActivity.getIntent())) {
            sendMessageFromIntent("onLaunch", mainActivity.getIntent());
          result.success(null);
        } else if ("subscribeToTopic".equals(call.method)) {
      private static boolean launchedActivityFromHistory(Intent intent) {
         return intent != null && (intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY;
    
    5.1.2 IOS端的代码修改

    IOS端消息通知出现两次原因呢:场景和Android一致,这里不多说,原因在于:首先消息通知先在onLaunch方法中消费一次,置于后台后,再次进入应用,本身的IOS系统的消息中心缓存的通知信息又会再次触发了一次,所以这里我在configure方法通道中将其屏蔽了

    修改文件:ios/Classes/FLTFirebaseMessagingPlugin.m

    - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result {
      NSString *method = call.method;
      if ([@"requestNotificationPermissions" isEqualToString:method]) {
      } else if ([@"configure" isEqualToString:method]) {
        [FIRMessaging messaging].shouldEstablishDirectChannel = true;
        [[UIApplication sharedApplication] registerForRemoteNotifications];
        //modify by ** 20201015 for firebase Notification display twice
    //    if (_launchNotification != nil && _launchNotification[kGCMMessageIDKey]) {
    //      [_channel invokeMethod:@"onLaunch" arguments:_launchNotification];
    //    }
        result(nil);
      } else if ([@"subscribeToTopic" isEqualToString:method]) {
      } else if ([@"unsubscribeFromTopic" isEqualToString:method]) {
    
    5.2 flutter_local_notifications库问题

    这个库了我使用的是2.0.0版本,该库是用于发送本地消息通知用的,这个库到是Android端没啥问题,不出出现两次的情况,因为解决firebase_messaging Android端消息重复问题是从这里得到的灵感。而后来,我发现了firebase_messaging在IOS端也有消息消费两次的问题,我就考虑了这个库IOS端是不是也有这问题,果不其然,确实有。

    PS: 问题发生场景和firebase_messaging一致,不重复说了。

    这里就解决IOS端重复的问题:
    位置:ios/Classes/FlutterLocalNotificationsPlugin.m

    - (void)requestPermissionsImpl:(bool)soundPermission
                   alertPermission:(bool)alertPermission
                   badgePermission:(bool)badgePermission
           checkLaunchNotification:(bool)checkLaunchNotification result:(FlutterResult _Nonnull)result{
        if(@available(iOS 10.0, *)) {
                if(checkLaunchNotification && self->_launchPayload != nil) {  
                    [self handleSelectNotification:self->_launchPayload];
                    self->_launchPayload = nil; //modify by ** 20201015 for LocalNotification display twice
        } else {
            if(checkLaunchNotification && _launchNotification != nil && [self isAFlutterLocalNotification:_launchNotification.userInfo]) {
                NSString *payload = _launchNotification.userInfo[PAYLOAD];
                //modify by ** 20201015 for LocalNotification display twice
                if(payload && payload != NULL && ![payload isEqualToString:@""]){
                    [self handleSelectNotification:payload];
                _launchNotification.userInfo = nil;
            result(@YES);
    

    至于原因吗,也和firebase_messaging IOS端一致

    六、后台初始化SDK, 需要在Firebase控制台生成私钥文件给后台