2. FCM 客户端 Token 获取与刷新监听

FCM 客户端 Token 获取与刷新监听 学习卡片

Token 的获取与刷新:两条路径,一个上报出口

FCM 的注册 Token(Registration Token)是服务端向某台具体设备投递消息的唯一地址。它有两个来源:一是应用启动后主动调用 FirebaseMessaging.getInstance().getToken() 拉取当前 Token;二是 Token 因应用重装、数据清除、恢复出厂、卸载重装或长期未使用被 Google 轮换时,由 FirebaseMessagingService.onNewToken() 回调推送新值。两条路径最终都必须落到同一个上报动作上,否则服务端会继续向旧 Token 发送,表现为“部分设备收不到推送”。

Token 生命周期与上报出口 路径 A · 主动获取 App 启动 / 登录成功后 getToken() 返回 Task<String> addOnCompleteListener 取结果 失败:IOException / 无网络 路径 B · 刷新回调 FirebaseMessagingService onNewToken(String token) 触发时机:重装 / 清数据 / 轮换 回调可能在后台线程执行 统一上报出口 1. 本地持久化 Token 2. 与已存值比对 3. 变化才 POST 服务端 4. 携带 userId / deviceId 自建服务端 POST /api/device/token body: token, userId, platform 按 userId 覆盖旧 Token 返回 200 视为上报成功 上报失败与重试判据 · getToken 失败:检查 google-services.json 与网络,稍后重试,勿把空串写入本地 · 上报非 2xx:保留本地 Token,标记 pending,下次启动或网络恢复时补报 · onNewToken 与主动获取可能先后到达:以最后一次成功上报的值为准

主动获取: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,并携带登录态校验。