A
第 16 章JAVA45 分钟

推送 Firebase Cloud Messaging

完成 Firebase Cloud Messaging 接入、Token 管理与刷新、前台/后台消息处理、notification 与 data 消息区分、Topic 订阅等核心推送能力

学习目标

  • 完成 FCM SDK 接入与 google-services.json 配置
  • 实现 FirebaseMessagingService 处理前台与后台消息
  • 管理注册 Token 的获取、刷新与上报后端
  • 区分 notification 与 data 两种消息类型的处理路径
  • 使用 Topic 订阅实现分群推送

学习目标

FCM(Firebase Cloud Messaging)是 Google 提供的免费跨平台推送服务,是除华为/小米等厂商通道外最常用的海外推送方案。本章目标:

  • 完成 FCM SDK 接入与 google-services.json 配置;
  • 自定义 FirebaseMessagingService 处理前台与后台消息;
  • 管理注册 Token 的获取、刷新与上报后端;
  • 区分 notification 与 data 两种消息的处理路径;
  • 使用 Topic 订阅实现分群推送。

FCM 接入准备

1. 创建 Firebase 项目

  1. 访问 Firebase 控制台;
  2. 新建 Project,添加 Android 应用,填写包名(如 com.example.myapp);
  3. 下载 google-services.json 文件并放入项目的 app/ 目录;
  4. 在控制台 → 项目设置 → 云消息传递 中获取 Server Key(旧版)或配置 OAuth2 服务账号(新版推荐)。

2. Gradle 配置

// 项目根目录 build.gradle
plugins {
    id 'com.android.application' version '8.5.0' apply false
    id 'com.google.gms.google-services' version '4.4.2' apply false
}
// app/build.gradle
plugins {
    id 'com.android.application'
    id 'com.google.gms.google-services'
}

android {
    namespace 'com.example.myapp'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.myapp"
        minSdk 21
        targetSdk 34
    }
}

dependencies {
    // 使用 Firebase BoM 统一版本
    implementation platform('com.google.firebase:firebase-bom:33.4.0')
    implementation 'com.google.firebase:firebase-messaging'
    // 可选:Firebase Analytics 用于统计推送转化
    implementation 'com.google.firebase:firebase-analytics'
}

3. google-services.json 放置

将控制台下载的 google-services.json 放到 app/ 目录下,与 build.gradle 同级。google-services 插件会在编译期把它解析成 firebase_options.xml 等资源。

4. Manifest 声明 Service

<!-- AndroidManifest.xml -->
<application ...>

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

    <!-- 可选:自定义默认通知图标 -->
    <meta-data
        android:name="com.google.firebase.messaging.default_notification_icon"
        android:resource="@drawable/ic_notification" />
    <meta-data
        android:name="com.google.firebase.messaging.default_notification_color"
        android:resource="@color/purple_500" />
</application>

FirebaseMessagingService 实现

public class MyFirebaseMessagingService extends FirebaseMessagingService {

    private static final String TAG = "FCM";
    private static final String CHANNEL_ID = "channel_push";

    @Override
    public void onNewToken(@NonNull String token) {
        Log.d(TAG, "Refreshed token: " + token);
        sendTokenToServer(token);
    }

    @Override
    public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
        Log.d(TAG, "From: " + remoteMessage.getFrom());

        // 1. 处理 notification 负载(仅前台时才会回调)
        if (remoteMessage.getNotification() != null) {
            String title = remoteMessage.getNotification().getTitle();
            String body = remoteMessage.getNotification().getBody();
            showNotification(title, body);
        }

        // 2. 处理 data 负载(无论前后台都会回调)
        if (!remoteMessage.getData().isEmpty()) {
            Map<String, String> data = remoteMessage.getData();
            String type = data.get("type");
            String id = data.get("id");
            handleDataMessage(type, id);
        }
    }

    private void sendTokenToServer(String token) {
        // 上报到自己的后端
        ApiClient.getInstance().uploadFcmToken(token);
    }

    private void showNotification(String title, String body) {
        ensureChannel();
        Intent intent = new Intent(this, MainActivity.class);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent pi = PendingIntent.getActivity(this, 0, intent,
                PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);

        Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle(title != null ? title : "新消息")
                .setContentText(body)
                .setAutoCancel(true)
                .setContentIntent(pi)
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .build();

        NotificationManagerCompat.from(this).notify(9001, notification);
    }

    private void ensureChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = new NotificationChannel(CHANNEL_ID,
                    "推送通知", NotificationManager.IMPORTANCE_HIGH);
            NotificationManager nm = getSystemService(NotificationManager.class);
            if (nm != null) {
                nm.createNotificationChannel(channel);
            }
        }
    }

    private void handleDataMessage(String type, String id) {
        // 根据 type 决定跳转 / 拉取 / 缓存等
        Intent intent = new Intent("com.example.myapp.PUSH_RECEIVED");
        intent.putExtra("type", type);
        intent.putExtra("id", id);
        LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
    }
}

Token 获取与刷新

1. 获取当前 Token

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "FCM_TOKEN";

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        fetchFcmToken();
    }

    private void fetchFcmToken() {
        FirebaseMessaging.getInstance().getToken()
                .addOnCompleteListener(task -> {
                    if (!task.isSuccessful()) {
                        Log.w(TAG, "Fetching FCM token failed", task.getException());
                        return;
                    }
                    String token = task.getResult();
                    Log.d(TAG, "Token: " + token);
                    ApiClient.getInstance().uploadFcmToken(token);
                });
    }
}

2. 监听 Token 刷新

Token 在以下场景会刷新:

  • App 在新设备上恢复数据;
  • 用户卸载/重装 App;
  • 用户清除 Google Play Services 数据;
  • App 升级改变了 FirebaseApp 初始化配置。

只需在 MyFirebaseMessagingService.onNewToken 中处理即可,系统会自动回调。

3. 后端推送示例(参考)

服务端通过 HTTP v1 API 推送:

POST https://fcm.googleapis.com/v1/projects/<project_id>/messages:send
Authorization: Bearer <OAuth2 access_token>
Content-Type: application/json

{
  "message": {
    "token": "<device_token>",
    "notification": {
      "title": "Hello",
      "body": "World"
    },
    "data": {
      "type": "promotion",
      "id": "p_001"
    }
  }
}

前台消息与后台消息

1. 前台消息

App 处于前台时,所有消息(含 notification 类型)都会回调 onMessageReceived,由 App 自行展示通知。这是 FCM 给开发者最大的灵活度,可自定义 UI 提示而非走系统通知。

2. 后台消息

  • 仅 notification:系统托盘自动展示,不会回调 onMessageReceived;点击通知会启动默认 Launcher Activity 并把 RemoteMessage 数据放进 Intent.extras。
  • 含 data:若后台且包含 data 字段,系统托盘同样自动展示 notification 部分;用户点击后才会回调 onMessageReceived 或通过 Intent extras 传递到启动 Activity。
  • 纯 data 后台消息:在后台时 不会 立刻回调 onMessageReceived,需要等用户点击通知后由系统在启动 Activity 时传递;若想后台也立刻处理,必须把它做成 高优先级 data 消息 并保持为前台/后台但服务存在。
消息类型 前台 后台
纯 notification onMessageReceived 系统托盘自动显示
notification + data onMessageReceived 系统托盘显示 notification;点击后 data 通过 Intent extras 传递
纯 data onMessageReceived 高优先级才会立刻回调;普通会被延迟到下次 App 前台

消息类型:notification vs data

1. notification 消息

{
  "message": {
    "token": "...",
    "notification": {
      "title": "促销活动",
      "body": "全场 5 折"
    }
  }
}

特点:

  • 由 FCM SDK 自动展示(后台时);
  • 不可自定义通知样式(前台可拦截);
  • 适合“只需展示”的简单消息。

2. data 消息

{
  "message": {
    "token": "...",
    "data": {
      "type": "chat_message",
      "id": "msg_9283",
      "title": "Alice",
      "body": "在吗?"
    },
    "android": {
      "priority": "high"
    }
  }
}

特点:

  • 由 onMessageReceived 全权处理;
  • 可自定义通知样式、跳转、缓存等;
  • 适合“业务化”的推送(IM、订单、运动数据)。

3. 推荐做法

  • IM/订单等业务消息用 纯 data + high priority;
  • 营销类简单通知用 notification + data(让系统托盘兜底展示,data 用于点击跳转)。

Topic 订阅

1. 客户端订阅

// 订阅"科技"主题
FirebaseMessaging.getInstance().subscribeToTopic("tech_news")
        .addOnCompleteListener(task -> {
            if (task.isSuccessful()) {
                Log.d("FCM", "Subscribed to tech_news");
            } else {
                Log.w("FCM", "Subscribe failed", task.getException());
            }
        });

// 取消订阅
FirebaseMessaging.getInstance().unsubscribeFromTopic("tech_news");

2. 后端向 Topic 推送

{
  "message": {
    "topic": "tech_news",
    "notification": {
      "title": "科技快讯",
      "body": "Android 15 正式发布"
    }
  }
}

3. 条件订阅(如“住在上海且订阅科技”)

{
  "message": {
    "topic": "shanghai AND tech_news",
    "data": { "type": "local" }
  }
}

Android 13+ 通知权限

FCM 后台消息展示系统通知也需要 POST_NOTIFICATIONS:

private final ActivityResultLauncher<String> mRequestNoti =
        registerForActivityResult(new ActivityResultContracts.RequestPermission(),
                granted -> {
                    if (granted) {
                        fetchFcmToken();
                    } else {
                        Toast.makeText(this, "通知权限被拒绝,部分推送将无法显示",
                                Toast.LENGTH_SHORT).show();
                    }
                });

private void ensureNotificationPermissionThenFetchToken() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        if (ContextCompat.checkSelfPermission(this,
                Manifest.permission.POST_NOTIFICATIONS)
                != PackageManager.PERMISSION_GRANTED) {
            mRequestNoti.launch(Manifest.permission.POST_NOTIFICATIONS);
            return;
        }
    }
    fetchFcmToken();
}

国内厂商通道补充

在国内市场,FCM 因为依赖 Google Play Services 在大部分国行机型不可用。生产环境通常会接入华为、小米、OPPO、VIVO、魅族等厂商推送:

  • 上报 Token 时一并上报厂商 Token 与品牌;
  • 服务端按品牌分发:华为用 HMS Push、小米用 MiPush;
  • 通用业务消息通道用统一封装(如极光、个推、友盟 Push)。

常见坑与最佳实践

  1. google-services.json 放错位置:必须放在 app/ 模块根目录,不是项目根目录,否则插件找不到。
  2. 包名不一致:Firebase 控制台注册的包名必须与 applicationId 完全一致(含 build flavor 后缀),否则 getToken 返回空。
  3. 后台 data 消息被延迟:必须设置 android.priority = "high" 才能立刻回调,普通优先级会被合并延迟。
  4. 在 onMessageReceived 中主线程耗时操作:会被系统杀掉。建议把数据写入数据库或转交 WorkManager。
  5. onNewToken 上报失败没有重试:Token 刷新后不上报后端,老 Token 失效,推送丢失。建议本地缓存并做重试队列。
  6. PendingIntent 未指定 FLAG_IMMUTABLE:Android 12+ 崩溃,FCM 自身构建的通知在 SDK 升级后才修复,自定义构建通知务必自己加。
  7. Topic 订阅过多:每个 App 默认无订阅上限,但建议只订阅与用户强相关的主题,避免大量无效推送。
  8. DOZE 模式下的高优先级限制:Android 6+ Doze 模式下,FCM 高优先级消息有每日配额(Firebase 后台限流),过多会自动降级。
  9. 未配置 default_notification_icon:后台通知会显示默认 Firebase 图标,体验割裂。

章节小结

  • FCM 接入需 google-services.json + firebase-bom 依赖 + Manifest 注册 Service;
  • FirebaseMessagingService.onMessageReceived 处理前台消息,onNewToken 监听 Token 刷新;
  • notification 消息由系统托盘展示,data 消息由 App 自行处理,建议业务消息用 data;
  • Topic 订阅适合分群推送,客户端订阅后端按 topic 推送;
  • Android 13+ 通知权限申请、国内厂商通道补充,是 FCM 落地到生产的关键补充。

下一章预告

第 17 章将进入 Service 与后台任务:Service 类型(Started / Bound)、startService 与 bindService、前台服务(startForeground)、onStartCommand 返回值、Android 8+ 后台执行限制与保活替代方案。