2. FCM 客户端 Token 获取与刷新监听
Token 的获取与刷新:两条路径,一个上报出口
FCM 的注册 Token(Registration Token)是服务端向某台具体设备投递消息的唯一地址。它有两个来源:一是应用启动后主动调用 FirebaseMessaging.getInstance().getToken() 拉取当前 Token;二是 Token 因应用重装、数据清除、恢复出厂、卸载重装或长期未使用被 Google 轮换时,由 FirebaseMessagingService.onNewToken() 回调推送新值。两条路径最终都必须落到同一个上报动作上,否则服务端会继续向旧 Token 发送,表现为“部分设备收不到推送”。
主动获取:getToken 的正确写法
getToken() 返回的是 Task<String>,必须异步处理,不能假设它立即返回。下面这段代码放在 Application.onCreate() 或登录成功后的回调里都成立,关键是失败分支不能吞掉。
public class TokenManager {
private static final String PREF = "fcm_pref";
private static final String KEY_TOKEN = "saved_token";
private static final String KEY_PENDING = "pending_upload";
// 主动获取并上报
public static void fetchAndUpload(Context ctx, String userId) {
FirebaseMessaging.getInstance().getToken()
.addOnCompleteListener(task -> {
if (!task.isSuccessful()) {
Log.w("FCM", "getToken failed", task.getException());
return; // 不写空值,等下次重试
}
String token = task.getResult();
uploadIfChanged(ctx, token, userId);
});
}
// 统一出口:变化才上报
static void uploadIfChanged(Context ctx, String token, String userId) {
SharedPreferences sp = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE);
String saved = sp.getString(KEY_TOKEN, null);
if (token.equals(saved)) return; // 未变化,跳过
sp.edit().putString(KEY_TOKEN, token)
.putBoolean(KEY_PENDING, true).apply();
postToServer(ctx, token, userId);
}
private static void postToServer(Context ctx, String token, String userId) {
// 用 OkHttp / Retrofit 均可,这里给出请求体结构
JSONObject body = new JSONObject();
try {
body.put("token", token);
body.put("userId", userId);
body.put("platform", "android");
} catch (JSONException e) { return; }
// ... 发起 POST /api/device/token
// 成功回调中:sp.edit().putBoolean(KEY_PENDING, false).apply();
}
}
刷新监听:onNewToken 的落点
Token 轮换时系统会调用 onNewToken(),它可能在应用未启动时被拉起,因此不能依赖内存中的 userId。正确做法是从持久化存储读取用户标识,再走同一个 uploadIfChanged 出口。
public class MyFirebaseMessagingService extends FirebaseMessagingService {
@Override
public void onNewToken(@NonNull String token) {
super.onNewToken(token);
// 从本地读取上次登录的 userId,避免内存为空
String userId = getSharedPreferences("app_pref", MODE_PRIVATE)
.getString("user_id", null);
if (userId == null) {
// 尚未登录:只落盘,等登录后再补报
getSharedPreferences("fcm_pref", MODE_PRIVATE)
.edit().putString("saved_token", token)
.putBoolean("pending_upload", true).apply();
return;
}
TokenManager.uploadIfChanged(this, token, userId);
}
}
在
AndroidManifest.xml 中注册该 Service,并声明 INTERNET 权限;onNewToken 不保证在主线程,涉及 UI 更新需切回主线程。验证与判读
| 观察点 | 预期现象 | 异常判读 |
|---|---|---|
| 首次启动日志 | 打印出形如 dGh... 的长字符串 Token |
为空或抛 IOException:检查 google-services.json 与网络 |
| 服务端接口日志 | 收到一次 POST,token 与客户端一致 | 未收到:确认上报出口是否被 equals 提前 return |
| 清除应用数据后重启 | onNewToken 触发,服务端 Token 被覆盖为新值 | 服务端仍是旧值:检查 pending 补报逻辑 |
| 同一用户换设备登录 | 服务端按 userId 覆盖,旧设备 Token 失效 | 两台设备都收到:服务端未做覆盖,需按 userId 唯一化 |
不要把 Token 硬编码进代码或提交到版本库;它等同于设备的推送地址,泄露后可被第三方伪造投递。上报接口应使用 HTTPS,并携带登录态校验。