消息推送——FCM集成与测试

在开发应用中,往往避免不了需要使用消息推送的功能,本文将具体介绍Google Firebase Messaging在安卓端的集成与使用。

1. FCM的集成

集成FCM的步骤如下:

(1)使用Google账号登录Firebase,并注册App,注册成功后,需要下载对应的配置文件google-services.json,并放到项目的app目录下;

(2)在项目根目录下的builde.gradle文件中,确保添加如下内容:

buildscript {
    repositories {
        google()
    }
    dependencies {
        classpath 'com.google.gms:google-services:4.3.3'
    }
}

allprojects {
    repositories {
        google()
    }
}

(3)在app/build.gradle中添加Firebase插件依赖:

implementation 'com.google.firebase:firebase-core:17.4.2'
implementation 'com.google.firebase:firebase-messaging:20.2.0'

并在文件最下方添加,然后sync project:

apply plugin: 'com.google.gms.google-services'

(4)接下来就需要编写代码了,使用FCM推送,我们首先需要自定义类并继承自FirebaseMessagingService服务类,同时重写对应的方法。获取更新的fcm token以及接收消息,显示通知,持久化数据等操作都是在这个类中完成的, 例如:

public class NotifyService extends FirebaseMessagingService {
    private final String TAG = "NotifyService";
    public final static String GATEWAY_LOG_PREFS = "GATEWAY_LOG_PREFS";  // 保存网关日志的XML文件名
    public final static String OTHER_LOG = "OTHER_LOG";  // 其他需要在消息中心显示的日志
    private int requestCode = 0;    

    @Override
    public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
        super.onMessageReceived(remoteMessage);
        // 接收到数据,执行如下操作:
        // 1. 解析并持久化消息;
        // 2. 创建并显示通知;
        if (remoteMessage.getData().size() > 0) {
            String userId = getCurrentUserId();
            Log.d(TAG, "userId is " + userId);
            if (!userId.equals("")) {
                Map<String, String> data = remoteMessage.getData();
                String msgType = data.get("msg_type");
                String body = data.get("body");
                Log.d(TAG, "msgType is " + msgType + " body is " + body);

                if (msgType != null) {
                    if (msgType.equals("PowerOff")) {
                        try {
                            long currentTime = System.currentTimeMillis();

                            JSONObject jsonObject = new JSONObject();
                            jsonObject.putOpt("msgType", msgType);
                            jsonObject.putOpt("body", body);
                            String GATEWAY_LOG_PREFS_WITH_USER_ID = userId + "_" + GATEWAY_LOG_PREFS;  // user id + 特定名称 构造存储消息文件名
                            saveNotifyMsgToPrefs(GATEWAY_LOG_PREFS_WITH_USER_ID, String.valueOf(currentTime), jsonObject.toString());
                        } catch (JSONException e) {
                            e.printStackTrace();
                        }
                    } else {
                        try {
                            long currentTime = System.currentTimeMillis();

                            JSONObject jsonObject = new JSONObject();
                            jsonObject.putOpt("msgType", msgType);
                            jsonObject.putOpt("body", body);
                            String OTHER_LOG_PREFS_WITH_USER_ID = userId + "_" + OTHER_LOG;  // user id + 特定名称 构造存储消息文件名
                            saveNotifyMsgToPrefs(OTHER_LOG_PREFS_WITH_USER_ID, String.valueOf(currentTime), jsonObject.toString());
                        } catch (JSONException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }

        if (remoteMessage.getNotification() != null) {
            String notifyTitle = remoteMessage.getNotification().getTitle();
            String notifyBody = remoteMessage.getNotification().getBody();
            Log.d(TAG, "notification title is " + notifyTitle);
            Log.d(TAG, "notification body is " + notifyBody);
            sendNotification(notifyTitle, notifyBody);
        }
    }

    @Override
    public void onNewToken(@NonNull String refreshToken) {
        super.onNewToken(refreshToken);
        Log.d(TAG, "refreshed token: " + refreshToken);
        // 1. 持久化生成的token,
        // 2. 发送事件通知RN层,分为两种情况:
        //      用户未登录,RN层不做处理(待用户登录后读取本地存储的token,并上报)
        //      用户已登录,RN层获取当前用户id、token及当前语言上报服务端
        SharedPreferences.Editor editor = getSharedPreferences("fcmToken", MODE_PRIVATE).edit();
        editor.putString("token", refreshToken);
        editor.apply();
        sendRefreshTokenBroadcast(refreshToken);
    }

    /**
     * 获取当前已登录用户的id
     * @return 用户id,如果未登录则为空
     */
    private String getCurrentUserId() {
        SharedPreferences prefs = getSharedPreferences("userMsg", MODE_PRIVATE);
        return prefs.getString("userId", "");
    }

    /**
     * 发送通知
     * @param contentTitle 通知标题
     * @param contentText 通知内容
     */
    private void sendNotification(String contentTitle, String contentText) {
        requestCode++;
        String channel_id = getString(R.string.default_notify_channel_id);
        String channel_name = getString(R.string.default_notify_channel_name);
        Uri defaultNotifySound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);

        Intent intent = new Intent(this, MainActivity.class);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_ONE_SHOT);
        NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, channel_id);
        notificationBuilder.setContentTitle(contentTitle)
                .setContentText(contentText)
                .setSmallIcon(R.mipmap.ic_launcher)
                .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher))
                .setAutoCancel(true)
                .setSound(defaultNotifySound)
                .setContentIntent(pendingIntent);
        NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);

        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
            NotificationChannel notificationChannel = new NotificationChannel(channel_id, channel_name, NotificationManager.IMPORTANCE_DEFAULT);
            notificationManager.createNotificationChannel(notificationChannel);
        }

        notificationManager.notify(requestCode, notificationBuilder.build());
    }

    /**
     * 发送广播通知NotificationModule更新token,并发送给RN层
     * @param refreshToken 更新的token
     */
    private void sendRefreshTokenBroadcast(String refreshToken) {
        LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(this);
        Intent intent = new Intent(getString(R.string.REFRESH_TOKEN_BROADCAST_ACTION));
        intent.putExtra("refreshToken", refreshToken);
        localBroadcastManager.sendBroadcast(intent);
    }

    /**
     * 持久化通知消息
     * @param prefsName 文件名
     * @param key 键
     * @param value 值
     */
    private void saveNotifyMsgToPrefs(String prefsName, String key, String value) {
        SharedPreferences.Editor editor = getSharedPreferences(prefsName, MODE_PRIVATE).edit();
        editor.putString(key, value);
        editor.apply();
    }
}

服务端推送过来的消息可以分为两个部分,一个是notification对象,另一个是data对象,例如:

var message = {
  notification: {
    title: "Alert",
    body: "description for message poweroff from DT"
  },
  data: {
    msg_type: "PowerOff",
    body: "description for message poweroff from DT"
  },
  token: registrationToken
};

notification对象的title和body分别是我们需要在通知栏显示的内容。data对象中的键值对我们可以自行定义,这部分数据往往需要我们在接收到通知后持久化在本地,供用户在APP内随时查看。

在实现的父类方法onMessageReceived方法中,通过RemoteMessage remoteMessage,我们可以获取到以上内容。

获取通知栏显示文本:

String notifyTitle = remoteMessage.getNotification().getTitle();
String notifyBody = reomteMessage.getNotification().getBody();

注:在显示通知时一定要注意递增requestCode,如果requestCode保持不变,即使收到多条消息也仅会有一个通知。

获取待持久化消息数据:

Map<String, String> data = remoteMessage.getData();
String msgType = data.get("msg_type");
String body = data.get("body");

然后就是onNewToken回调,这个方法会在首次获取refreshToken以及更新refreshToken时执行。这个token是服务端推送指定设备的依据,所以在这个回调中,我们需要把refreshToken持久化到本地。

提到refreshToken,就不得不说到FCM中推送特定设备的方式了,FCM中推送设备支持两种方式,一种是通过refreshToken,另一种则是通过特定的主题(可以给待推送设备进行分组)。服务端既可以通过指定单个token推送到特定的设备,也可以一次性指定多个token,进行批量推送。如果客户端针对某类用户订阅了特定的主题,服务端也可以通过这个主题作为用户群体的标识进行批量推送。

在实际开发中不仅仅是refreshToken需要上报服务端,可能还需要包含其他信息,例如:登录用户的id,当前设备的语言环境(服务端需要根据不同的语言推送特定语言的notification)等。这里根据需求(登录、退出登录、多语言切换、token更新)可能会包含如下场景:

1、登录:在应用首次启动时,如果网络正常(可以翻墙,能够访问google服务器),onRefreshToken会立刻执行,返回当前设备分配的token值,此时我们需要将其保存在本地。之后,如果用户执行登录操作,在登录成功后,需要将token、userid、language(optional)一并上报给服务端,这样服务端可以将推送设备和用户进行关联(服务端会使用userid+token作为联合主键)。

2. 退出登录:当用户退出登录时,为了避免用户在未登录状态下也收到相应的消息,我们需要将设备和用户进行“解绑”。可以在退出登录前上报一个空的token值。

3. 多语言切换:如果应用内支持多语言切换的功能,需要在用户切换时,重新上报当前设备,当前用户的语言环境,以便服务端对推送消息的语言进行同步切换。

4. token更新:当token更新时,我们同样需要重新上报更新后的token,与当前登录用户重新构建绑定关系。在FCM的官方文档中,在以下情景中会更新token值:

    a. 应用删除实例id;

    b. 应用在新设备上恢复;

    c. 用户卸载/重新安装应用;

    d. 用户清除应用数据;

主题订阅:

我们可以在客户端代码中为特定的用户群订阅特定的主题,例如订阅一个weather主题:

FirebaseMessaging.getInstance().subscribeToTopic("weather")
                        .addOnCompleteListener(new OnCompleteListener<Void>() {
                            @Override
                            public void onComplete(@NonNull Task<Void> task) {
                                String msg = getString(R.string.msg_subscribed);
                                if (!task.isSuccessful()) {
                                    msg = getString(R.string.msg_subscribe_failed);
                                }
                                Log.d(TAG, msg);
                                Toast.makeText(MainActivity.this, msg, Toast.LENGTH_SHORT).show();
                            }
                        });

服务端也可以为部分token订阅主题,以node为例:

// These registration tokens come from the client FCM SDKs.
var registrationTokens = [
  'YOUR_REGISTRATION_TOKEN_1',
  // ...
  'YOUR_REGISTRATION_TOKEN_n'
];

// Subscribe the devices corresponding to the registration tokens to the
// topic.
admin.messaging().subscribeToTopic(registrationTokens, topic)
  .then(function(response) {
    // See the MessagingTopicManagementResponse reference documentation
    // for the contents of response.
    console.log('Successfully subscribed to topic:', response);
  })
  .catch(function(error) {
    console.log('Error subscribing to topic:', error);
  });

还可以通过unsubscribeFromTopic退订主题,具体的可以官网查看。

回到我们的NotifyService类中,接收消息的逻辑编写完成后,需要在AndroidManifest.xml中注册这个服务:

 <service
    android:name=".notification.NotifyService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

这样,客户端的集成工作基本就完成了。

 

2. FCM服务端推送

在编写完客户端代码后,我们可能跃跃欲试,希望能够看看整个推送流程能否跑通。但是实际推送需要服务端来完成,每推送一次可能就需要麻烦一下后端研发,这样会导致调试的效率降低。所幸,FCM提供了支持不同服务端语言版本的插件,我们可以在本地构建推送服务,在与后端约定好推送消息的格式后,本地模拟推送。然后在代码自测基本没问题后,再与服务端进行线上的联调。这里以node为例:

在本地新建一个node项目,安装firebase-admin-node插件:

npm init
npm install --save firebase-admin

然后需要回到Firebase控制台,下载对应的服务端配置文件:

接着创建index.js,编写消息推送脚本:

var admin = require("firebase-admin");
var serviceAccount = require("./multirouter-xxxxx-firebase-adminsdk-g8jqq-a4cf04a792.json");
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

var registrationToken = 'dnV7QVGUQjOgvPk1ZCKflA:APA91bGIn09-QyDoGObbjmZLjlbhP5P4tonw9wnuAP6iKcScWZWmpWReVL8476IzEyVAsvrb0r9z0s-a_Xzlme7RlHUns0Vo0EA_6apZI2jJSDvQ7HUGnODKcYJE54MXpqY_A1joWdyt';

var message = {
  notification: {
    title: "Alert test background",
    body: "description for message poweroff from DT"
  },
  data: {
    msg_type: "PowerOff",
    body: "description for message poweroff from DT background"
  },
  token: registrationToken
};

admin.messaging().send(message)
  .then((response) => {
    console.log('Successfully sent message:', response);
  })
  .catch((error) => {
    console.log('Error sending message:', error);
  });

我们只需要将测试机上App生成的token,拷贝到脚本中,运行以上脚本就会立即发送消息至App上。还是非常简单的。

 

3. 实际使用中的问题与解决方法:

在实际使用FCM的过程中,发现存在如下问题(现象),测试手机是Redmi K20Pro,系统是MIUI11:

(1)使用国内运营商的网络很可能无法获取到FCM token(国内需要翻墙);

(2)App如果处于未运行状态,无法收到推送的通知,但是在app启动后,大概等待几分钟,可以收到;

(3)App处于后台运行状态时,能够接收到通知并显示notification字段的内容,但是onMessageReceived回调未执行,这会导致消息因无法持久化而丢失;

(4)App处于后台运行状态时,如果用户手动清空通知栏的通知,没有点击触发pendingIntent,这会导致在启动的 Activity 里通过getIntent().getExtras()获取的bundle中不会包含接收到的消息;

针对问题(1),考虑到国内的网络环境,实际上是挺无解的。所以之前在做APP的过程中,国内的推送服务都是采用的极光推送(还是非常好用的)。而这次开发的APP需要上架google应用市场,并在俄罗斯使用,才选用的google的FCM作为推送方案。

针对问题(2),这个应该是正常的现象。毕竟App未启动状态下,代码是无法执行的。至于像微信这些大厂的应用,能够随时收到消息可能是和系统厂商有合作吧(瞎猜的 ̄□ ̄)。

针对问题(3),查了一下stackoverflow,应该也属于正常的情况,而且data数据也并没有丢失,如果用户点击通知栏,唤醒应用,我们可以在拉起的Activity的onResume生命周期方法中,通过getIntent().getExtras()获取的bundle对象中拿到。例如:

@Override
  protected void onResume() {
    super.onResume();
    Log.d(TAG, "onResume");
    Bundle bundle = getIntent().getExtras();
    if (bundle != null) {
      String msgType = bundle.getString("msg_type");
      String body = bundle.getString("body");
      Log.d(TAG, "onResume msgType is " + msgType + " body is " + body);
    }
  }

不过还有一个小问题,就是如果Activity没有被回收,bundle对象中保存的data数据会一直存在,这可能在某些情况下导致写入重复的消息内容,所以建议在持久化数据完成后,清空bundle中的这部分数据。

getIntent().removeExtra("msg_type");
getIntent().removeExtra("body");

针对问题(4),这个还没有找到比较好的解决办法,这种情况会导致应用内展示历史消息的消息中心丢失部分消息(消息仅持久化在APP本地为前提)。

以上就是本文的全部内容了,也是我在实际使用FCM的个人总结,如有错误,欢迎大家指正哈。FCM这部分内容官方文档还是非常详细的,而且也提供了对应的demo,大家还是优先阅读官方文档吧。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值