返回

Firebase背景通知:搞定JSON解析与自定义图片显示

Android

Firebase 背景通知:搞定 JSON Body 解析与自定义图片显示

用 Firebase Cloud Messaging (FCM) 推送通知,标题是字符串,这没毛病。但 body 部分,如果塞的是 JSON 数据,并且还想在通知里头带上一张自定义图片,那麻烦就来了。

App 在前台运行时,onMessageReceived() 方法能正常接收到通知,解析 JSON、显示图片都好说。

一旦 App 退到后台或者被干掉了,onMessageReceived() 就拜拜了。Firebase 会自个儿把通知怼到系统通知栏。这时候,JSON 格式的 body 内容没法儿解析,自定义图片更是想都别想,Firebase 只会用默认图标。这体验,简直了!

一、到底为啥会这样?消息类型的锅!

要弄明白这个问题,得先搞清楚 FCM 的两种消息类型:

  1. 通知消息 (Notification Messages):

    • 有时也叫“显示消息”。这类消息,FCM SDK 会自动处理,直接在系统通知栏展示。
    • 优点:简单省事,不用写啥代码。
    • 缺点:自定义能力差。当 App 在后台或被杀死时,onMessageReceived() 不会 被调用。通知内容(标题、文本)直接取自 notification 载荷,图片通常是应用图标。
  2. 数据消息 (Data Messages):

    • 这类消息,无论 App 在前台、后台还是被杀死,onMessageReceived() 总是 会被调用(App 被强行停止除外)。
    • 优点:完全控制权。你可以在 onMessageReceived() 里解析数据,然后自己构建和显示一个完全自定义的通知。
    • 缺点:稍微麻烦点,得自己写代码处理。

我们遇到的问题,症结就在于:当 App 在后台时,如果发送的是包含 notification 载荷的 FCM 消息 ,系统就会接管通知的显示,导致我们无法介入处理 JSON body 和自定义图片。

FCM Message Handling Diagram
图片来源: Firebase官方文档,展示了消息处理流程

当你的 FCM 载荷长这样:

{
  "to": "DEVICE_TOKEN",
  "notification": {
    "title": "打折啦!",
    "body": "{\"product_name\": \"神仙水\", \"discount\": \"5折\", \"expires_at\": \"2024-12-31\"}"
    // "image": "some_image_url" // 这个 image 字段,虽然 FCM 支持,但行为可能不完全符合预期,尤其是在后台
  },
  "data": {
    "extra_info": "一些额外数据"
  }
}

App 在后台时,notification 里的 body 会被直接当成字符串显示,JSON 根本没机会解析。data 载荷里的数据虽然能收到,但得等用户点击通知,在启动的 Activity 的 Intent 里才能拿到,对通知本身的展示没帮助。

二、解决方案:把控制权夺回来!

核心思路:统一使用数据消息 (Data Messages) 。这样,onMessageReceived() 就能稳定发挥作用,我们想怎么玩都行。

方案:只发送数据消息 (Data-Only Messages)

这是最推荐,也是最能解决问题的方法。

1. 原理和作用

完全抛弃 FCM 载荷中的 notification 字段,只用 data 字段来传递所有信息,包括标题、JSON 格式的 body、图片 URL 等。
这样,不论 App 处于何种状态(前台、后台、被杀死后首次接收),消息都会被路由到 FirebaseMessagingServiceonMessageReceived() 方法。我们就能在这里:

  • 解析 data 载荷中的 JSON body。
  • 根据图片 URL 下载图片。
  • 构建一个完全自定义的本地通知,并显示它。

2. 发送端调整 (以 Firebase Admin SDK for Node.js 为例)

假设你想发送的 JSON body 结构是:

{
  "item_name": "酷炫T恤",
  "price": "¥99.9",
  "promo_details": "夏日特惠,全场包邮!",
  "image_url": "https://example.com/tshirt.png"
}

你需要把它作为字符串放到 data 载荷的一个字段里,比如叫 custom_payload。同时,通知的标题、要显示的图片 URL 也都放进 data

// 使用 Firebase Admin SDK (Node.js)
const admin = require('firebase-admin');

// 初始化你的 Firebase Admin SDK
// admin.initializeApp({ ... });

const deviceRegistrationToken = 'YOUR_DEVICE_TOKEN';

const jsonDataBody = {
  item_name: "酷炫T恤",
  price: "¥99.9",
  promo_details: "夏日特惠,全场包邮!"
};

const message = {
  data: {
    notification_title: "上新通知!", // 以前放 notification.title 的内容
    notification_body_json: JSON.stringify(jsonDataBody), // 以前放 notification.body 的 JSON 内容
    notification_image_url: "https://example.com/tshirt.png" // 图片 URL
    // 你还可以加其他自定义数据字段
    // "click_action": "FLUTTER_NOTIFICATION_CLICK", // 如果是Flutter,可能需要这个
    // "screen": "/product/123" // 自定义深链接参数
  },
  token: deviceRegistrationToken
};

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

看清楚,我们没有 notification 对象了,所有东西都在 data 里。

3. 客户端处理 (FirebaseMessagingService - Android Java/Kotlin)

在你的 FirebaseMessagingService 子类中,覆写 onMessageReceived() 方法:

首先,确保你的 AndroidManifest.xml 配置正确:

<manifest ...>
    <application ...>
        <service
            android:name=".MyFirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
        <!-- Android 8.0 (API level 26) 及以上版本需要通知渠道 -->
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="@string/default_notification_channel_id" />
        <!-- 你可能还需要自定义通知图标和颜色 -->
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_icon"
            android:resource="@drawable/ic_notification_custom" />
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_color"
            android:resource="@color/colorAccent" />
    </application>
</manifest>

然后是 MyFirebaseMessagingService.java

import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.util.Log;

import androidx.core.app.NotificationCompat;

import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;

import org.json.JSONObject; // 使用 org.json 进行简单解析

import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Map;

public class MyFirebaseMessagingService extends FirebaseMessagingService {

    private static final String TAG = "MyFirebaseMsgService";

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

        // Check if message contains a data payload.
        if (remoteMessage.getData().size() > 0) {
            Log.d(TAG, "Message data payload: " + remoteMessage.getData());
            Map<String, String> data = remoteMessage.getData();

            String title = data.get("notification_title");
            String jsonBodyStr = data.get("notification_body_json");
            String imageUrl = data.get("notification_image_url");

            String parsedBodyText = ""; // 解析后的 JSON body 用于通知内容

            // 1. 解析 JSON Body
            if (jsonBodyStr != null) {
                try {
                    JSONObject jsonObject = new JSONObject(jsonBodyStr);
                    String itemName = jsonObject.optString("item_name", "");
                    String price = jsonObject.optString("price", "");
                    String promoDetails = jsonObject.optString("promo_details", "");

                    // 你可以根据需要组合这些信息
                    parsedBodyText = itemName + "仅售" + price + "!" + promoDetails;
                    if (parsedBodyText.isEmpty()){
                        parsedBodyText = "点击查看详情"; // 兜底文案
                    }

                } catch (Exception e) {
                    Log.e(TAG, "JSON parsing error: " + e.getMessage());
                    parsedBodyText = "您有一条新消息,点击查看。"; // 解析失败的兜底文案
                }
            } else {
                 // 如果服务端忘了传 notification_body_json,给个默认提示
                 parsedBodyText = "您有一条新消息";
            }


            // 2. 下载图片 (这是一个简单示例,实际项目中推荐使用 Glide, Picasso 等库)
            Bitmap largeIconBitmap = null;
            if (imageUrl != null && !imageUrl.isEmpty()) {
                largeIconBitmap = getBitmapFromUrl(imageUrl);
            }

            // 3. 构建并显示通知
            sendNotification(title, parsedBodyText, largeIconBitmap, data);
        }

        // 如果消息包含 notification 载荷,这里也可以处理,但对于本问题,我们关注 data 消息
        if (remoteMessage.getNotification() != null) {
            Log.d(TAG, "Message Notification Body: " + remoteMessage.getNotification().getBody());
            // 如果你的策略是混合使用,可以在这里处理只含 notification 的消息
            // 但对于需要自定义图片和 JSON 解析的场景,主要依赖上面的 data 处理逻辑
        }
    }

    private Bitmap getBitmapFromUrl(String imageUrl) {
        try {
            URL url = new URL(imageUrl);
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setDoInput(true);
            connection.connect();
            InputStream input = connection.getInputStream();
            return BitmapFactory.decodeStream(input);
        } catch (IOException e) {
            Log.e(TAG, "Error downloading image: " + e.getMessage());
            return null;
        }
    }

    private void sendNotification(String title, String messageBody, Bitmap image, Map<String, String> data) {
        Intent intent = new Intent(this, MainActivity.class); // 点击通知后打开的 Activity
        // 你可以将 data 里的其他数据通过 intent extras 传递给 Activity
        for (Map.Entry<String, String> entry : data.entrySet()) {
            intent.putExtra(entry.getKey(), entry.getValue());
        }
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent,
                PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);

        String channelId = getString(R.string.default_notification_channel_id); // 在 strings.xml 中定义
        Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);

        NotificationCompat.Builder notificationBuilder =
                new NotificationCompat.Builder(this, channelId)
                        .setSmallIcon(R.drawable.ic_notification_custom) // 小图标,必须
                        .setContentTitle(title)
                        .setContentText(messageBody)
                        .setAutoCancel(true)
                        .setSound(defaultSoundUri)
                        .setContentIntent(pendingIntent)
                        .setStyle(new NotificationCompat.BigTextStyle().bigText(messageBody)); // 让长文本能完整显示

        if (image != null) {
            notificationBuilder.setLargeIcon(image);
            // 如果图片较大,且想作为通知的主要内容展示 (像微信聊天图片那样),可以使用 BigPictureStyle
            // notificationBuilder.setStyle(new NotificationCompat.BigPictureStyle()
            //        .bigPicture(image)
            //        .bigLargeIcon(null)); // 设置了 BigPictureStyle 后,大图标通常设为 null 或不设
        }

        NotificationManager notificationManager =
                (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);

        // Android 8.0 (API level 26) 及以上版本需要通知渠道.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = new NotificationChannel(channelId,
                    "默认通知", // 用户可见的渠道名称
                    NotificationManager.IMPORTANCE_DEFAULT);
            notificationManager.createNotificationChannel(channel);
        }

        notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
    }
}

说明和注意事项:

  • ic_notification_custom : 这是一个必须提供的小图标,通常是应用 Logo 的单色剪影。放在 res/drawable 目录下。

  • default_notification_channel_id : 在 res/values/strings.xml 中定义,例如:

    <resources>
        <string name="default_notification_channel_id">fcm_default_channel</string>
    </resources>
    
  • JSON 解析 : 上例中用了 org.json.JSONObject。对于复杂 JSON,推荐使用 Gson 或 Moshi 库,会更健壮、方便。

  • 图片下载 : getBitmapFromUrl() 方法是一个非常基础的同步下载实现。在实际生产中,它会阻塞 onMessageReceived (虽然它不在主线程,但也不宜耗时过长)。强烈推荐使用 Glide、Picasso 或 Coil 等图片加载库 ,它们能处理缓存、异步加载、图片转换等,更高效也更稳定。例如用 Glide:

    // 伪代码,需要在合适的作用域和线程中执行
    // Glide.with(applicationContext)
    //      .asBitmap()
    //      .load(imageUrl)
    //      .into(new CustomTarget<Bitmap>() {
    //          @Override
    //          public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
    //              // 在这里用 resource (下载好的Bitmap) 更新通知
    //              sendNotification(title, parsedBodyText, resource, data);
    //          }
    //          @Override
    //          public void onLoadCleared(@Nullable Drawable placeholder) { }
    //      });
    // 这引入了异步问题,因为 onMessageReceived 可能已经返回了。
    // 一个常见的做法是 onMessageReceived 中先发一个基础通知,然后启动一个 JobIntentService 或 WorkManager 任务去下载图片并更新通知。
    // 或者,如果下载时间可控(比如图片服务器很快,图片不大),简单同步下载有时也行,但要注意 ANR 风险。
    

    更简单直接的、如果允许一点点延迟(例如图片非常小,或者有缓存机制时):
    可以在 getBitmapFromUrl 内使用 Glide 的同步加载 API(如果你的 Glide 配置支持,并且确认你是在后台线程中调用它):

    // private Bitmap getBitmapFromUrlWithGlide(String imageUrl) {
    //    try {
    //        return Glide.with(this)
    //                .asBitmap()
    //                .load(imageUrl)
    //                .submit() // submit() returns a Future
    //                .get(); //  .get() blocks, so ensure this is not on main thread.
    //    } catch (Exception e) {
    //        Log.e(TAG, "Error downloading image with Glide: " + e.getMessage());
    //        return null;
    //    }
    // }
    

    实际项目中,由于 onMessageReceived 的生命周期限制(通常 10-20 秒内要处理完毕),处理图片下载这类耗时操作,更稳妥的方式是启动一个 WorkManager 任务来完成下载和显示通知。

  • 通知渠道 (Notification Channels) : Android 8.0 (API 26) 以上是必需的。用户可以在系统设置里管理应用的通知渠道。

4. 安全建议

  • HTTPS : 确保你的图片 URL 使用 HTTPS,防止中间人攻击。
  • 服务端校验 : 服务端在发送 notification_image_url 之前,应对 URL 进行校验,确保是合法的、可访问的图片资源地址,避免指向恶意内容或无效链接。
  • 客户端数据校验 : 客户端在解析 notification_body_json 时,要做好异常处理,防止因为 JSON 格式错误或缺少字段导致应用崩溃。使用 optString, optInt 等方法会更安全。
  • 资源大小限制 : 考虑通知图片的尺寸和文件大小。太大的图片下载耗时,也消耗用户流量,甚至可能导致下载失败或通知显示异常。服务端可以对图片进行预处理,提供适合通知展示的尺寸。

5. 进阶使用技巧

  • 自定义通知布局 : 如果标准的通知样式无法满足需求,可以考虑使用 NotificationCompat.BuildersetCustomContentView(), setCustomBigContentView() 等方法,配合 RemoteViews 创建完全自定义的通知界面。但这会增加复杂性。
  • 通知操作按钮 : 使用 addAction() 方法给通知添加快速操作按钮,例如“立即购买”、“查看详情”等。
    // 示例:添加一个“查看”按钮
    Intent viewIntent = new Intent(this, DetailActivity.class);
    viewIntent.putExtra("product_id", "123"); // 假设从 data 中获取 product_id
    PendingIntent viewPendingIntent = PendingIntent.getActivity(this, 1, viewIntent,
            PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
    
    notificationBuilder.addAction(R.drawable.ic_action_view, "查看", viewPendingIntent);
    
  • 分组通知 : 如果短时间内收到多条相关通知,可以使用 setGroup() 将它们聚合起来,提升用户体验。
  • 深链接处理 : 在 PendingIntent 中配置的 Intent,可以携带参数,并结合 intent-filter 实现深链接,直接打开 App 的特定页面。比如前面 MainActivityintent.putExtra(entry.getKey(), entry.getValue()); 就是为这个做准备的。在 MainActivityonCreateonNewIntent 中可以获取这些数据:
    // 在 MainActivity.java 的 onCreate
    // String screen = getIntent().getStringExtra("screen");
    // if (screen != null && screen.equals("/product/123")) {
    //     // 导航到产品123的页面
    // }
    
  • 使用 WorkManager 处理图片下载 : 为了确保 onMessageReceived 不会因为图片下载超时而被系统终止,可以将图片下载和通知更新的任务交给 WorkManageronMessageReceived 快速返回,启动一个 OneTimeWorkRequest
    1. onMessageReceived:保存通知数据,启动 Worker。
    2. ImageDownloaderWorker:执行下载,下载完成后构建并显示通知。

通过这种“只发送数据消息”的策略,就能完全掌控 App 在后台或被杀死状态下 FCM 通知的行为,轻松实现 JSON Body 的解析和自定义图片的展示。代码可能多了点,但换来的是灵活度和最佳用户体验。