在开发应用中,往往避免不了需要使用消息推送的功能,本文将具体介绍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 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 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() {
@Override
public void onComplete(@NonNull Task 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中注册这个服务:
这样,客户端的集成工作基本就完成了。
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,大家还是优先阅读官方文档吧。