返回

FCM后台通知:图标白色?点击无效?解决方法

Android

搞定 FCM 后台通知:图标变白圈、点击意图不对?看这里!

用 Firebase Cloud Messaging (FCM) 给 App 推送消息挺方便,但你可能遇到过这种头疼事:App 在前台时,通知好好的,图标是你设置的,点击也能跳到指定页面或打开链接;可一旦 App 退到后台或者被杀掉,收到的通知图标就变成了一个白色的圆圈(或方块),点一下通知,竟然只是启动了 App,而不是执行你预设的操作,比如打开某个网页。

这到底是咋回事?明明代码里 setSmallIconPendingIntent 都设置好了呀?别急,咱们来分析分析,再看看怎么解决。

一、 问题在哪?为啥后台表现不一样?

这背后其实有两个关键点在“捣鬼”:

  1. 小图标 (setSmallIcon) 的显示规则变了:

    • 从 Android 5.0 (Lollipop) 开始,系统状态栏通知图标(就是用 setSmallIcon 设置的那个)有个硬性要求:必须是纯色图标,带 Alpha 透明通道 。系统会自动把它渲染成白色(在深色背景下)或深灰色(在浅色背景下)。如果你给的是一个彩色的、复杂的图标,系统没办法正确处理,很多时候就直接显示成一个纯色的色块(通常是白色),形状跟你图标的轮廓差不多,所以看起来就是个白圈或白方块。
    • 前台时为啥能显示?这可能是因为前台通知的渲染方式有时略有不同,或者你看到的不是状态栏里那个小小的图标,而是通知下拉后展开的大图标(setLargeIcon 设置的,这个可以是彩色的)。但状态栏那个小图标,规则是统一的。
  2. FCM 消息类型与 App 状态决定了谁来处理通知:

    • FCM 可以发送两种主要类型的消息:通知消息 (Notification Message)数据消息 (Data Message) ,也可以两者混合。
    • 当 App 在前台运行时: 不管你发的是通知消息还是数据消息,FCM SDK 都会把消息传递给你自己写的 FirebaseMessagingServiceonMessageReceived 方法。也就是说,你的 sendNotification 函数会被调用,完全由你自己的代码来创建和显示通知。图标、点击意图(Intent)自然都是你代码里写的那样。
    • 当 App 在后台或已关闭时:
      • 如果发送的是 通知消息 *:为了省电和效率,Android 系统(或者说 Google Play 服务)会直接接管* 这个通知的显示!它会读取你从 FCM 控制台或服务器发送的 notification 载荷里的 title, body, icon, sound, click_action 等字段,然后自己创建并显示通知。你的 FirebaseMessagingServiceonMessageReceived 方法根本不会被调用! 这就解释了为什么图标和点击行为不对了:系统用了它自己的一套逻辑,而不是你 App 里的 sendNotification 函数。默认情况下,点击系统处理的通知,行为通常就是启动你的 App 主 Activity。
      • *如果发送的是 数据消息 *:这时,系统会将消息传递给你的 FirebaseMessagingServiceonMessageReceived 方法(后台运行时可能会有延迟,受系统Doze模式等影响)。这样一来,你的 sendNotification 函数就有机会执行,通知的图标和点击行为就能按你的代码来。

现在清楚了吧?你遇到的问题,根源在于:

  • 后台收到的“通知消息”是系统直接显示的,没走你代码里的 sendNotification
  • 提供给 setSmallIcon 的图标不符合 Android 系统规范。

二、 对症下药:解决方案来了

知道了原因,解决起来就思路清晰了。我们需要分别处理图标和点击意图的问题。

方案一:让小图标乖乖现身

原理: 提供一个符合 Android 规范的单色 Alpha 图标。

操作步骤:

  1. 准备图标资源:

    • 你需要创建一个只包含白色和透明部分 的图标。通常是 PNG 格式,或者更推荐使用 Vector Drawable (XML 矢量图形)。
    • 图标内容应该是你希望在状态栏显示的那个小小的、简洁的标识。想想 Wi-Fi、蓝牙那些系统图标,就那个感觉。
    • 确保图标尺寸合适,一般状态栏图标建议尺寸为 24x24 dp。对于不同分辨率,系统会自动缩放,或者你也可以提供不同 mipmap 目录下的版本 (mdpi, hdpi, xhdpi, etc.)。
    • 快速生成工具: 可以使用 Android Studio 自带的 "Image Asset" 或 "Vector Asset" 工具来创建。右键点击 res 目录 -> New -> Image Asset/Vector Asset。选择 "Notification Icons" 类型,它会自动帮你处理成单色。也可以用 Figma、Sketch 等设计工具导出符合要求的资源。
    • 假设你生成了一个名为 ic_notification_mono.xml (Vector Drawable) 或 ic_notification_mono.png 的图标。
  2. 在代码中引用新图标:
    修改 sendNotification 函数,将 setSmallIcon 指向这个新的单色图标:

    // Build the notification
    val notification = NotificationCompat.Builder(context, channelID)
        .setSmallIcon(R.drawable.ic_notification_mono) // <--- 使用单色图标!
        .setContentTitle(title)
        .setContentText(message)
        .setPriority(NotificationCompat.PRIORITY_DEFAULT)
        .setAutoCancel(true)
        .setContentIntent(pendingIntent)
        // 如果需要,可以设置彩色的大图标,它会在通知下拉展开时显示
        // .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.your_color_launcher_icon))
        .build()
    

注意事项:

  • 这个修改同时适用于前台和后台 。因为即使是后台,如果你采用下面的“数据消息”方案让 sendNotification 得以执行,这个图标也是它来设置的。如果是系统处理的“通知消息”,它也会尝试读取你 Manifest 里设置的默认图标,或者你在 FCM 载荷里指定的 icon 字段,但最佳实践还是在客户端代码里用 setSmallIcon 指定。确保这个资源存在且符合规范至关重要。
  • 测试时,务必将 App 完全杀掉(从最近任务列表划掉)再发送通知,模拟后台/关闭状态。

方案二:让后台点击行为听你的话

如前所述,后台的“通知消息”默认不走你的 onMessageReceived。要让点击行为符合预期(比如打开特定 URL),你有两种主要思路:

思路 A:强制走 onMessageReceived (推荐)

原理: 从服务端发送 纯数据消息 (Data Message) ,或者包含 data 载荷的消息(根据平台和需求配置)。这样,即使 App 在后台,系统也会唤醒你的 FirebaseMessagingService (受系统限制) 并调用 onMessageReceived,让你有机会执行 sendNotification,从而使用你在代码里精心构造的 PendingIntent

操作步骤:

  1. 修改服务端发送的 FCM 载荷:

    • 确保你的服务器发送的 JSON 载荷中,不包含 notification 键,只包含 data 键。把原来放在 notification 里的 titlebody 等信息,都挪到 data 对象里。

    例如,修改前的载荷可能是:

    {
      "to": "DEVICE_TOKEN",
      "notification": {
        "title": "App Update Available!",
        "body": "Click to see what's new.",
        "icon": "notification_icon", // 这个 icon 字段效果往往不如客户端设置的好
        "click_action": "YOUR_ACTIVITY_TO_OPEN" // 这个也容易出问题
      },
      "data": {
         "url": "https://github.com/georgeclensy/escape"
         // 其他自定义数据
      }
    }
    

    修改后的载荷应该是 (只保留 data

    {
      "to": "DEVICE_TOKEN",
      "priority": "high", // 建议设为 high,增加后台唤醒概率
      "data": {
        "title": "App Update Available!",
        "message": "Click to see what's new.",
        "url_to_open": "https://github.com/georgeclensy/escape",
        // 任何你需要的信息都放在 data 里
        "channel_id": "updates",
        "channel_name": "Updates"
      }
    }
    

    注意: 使用 data 消息时,后台唤醒 onMessageReceived 依赖于设备状态(Doze 模式、应用限制等)。设置 priority: "high" (Android) 有助于提高送达和处理优先级,但不能完全保证实时。

  2. 修改 onMessageReceived 以解析 data 载荷:
    现在 onMessageReceived 会被调用了(即使在后台),你需要从 remoteMessage.data 而不是 remoteMessage.notification 中提取信息。

    import android.content.Intent
    import android.net.Uri
    import android.util.Log
    import com.google.firebase.messaging.FirebaseMessagingService
    import com.google.firebase.messaging.RemoteMessage
    
    class MyFirebaseMessagingService : FirebaseMessagingService() {
    
        override fun onMessageReceived(remoteMessage: RemoteMessage) {
            super.onMessageReceived(remoteMessage)
    
            Log.d("FCM", "From: ${remoteMessage.from}")
    
            // 重点:现在从 data 载荷获取数据
            remoteMessage.data.isNotEmpty().let {
                Log.d("FCM", "Message data payload: " + remoteMessage.data)
    
                // 从 data 中取出你发送的字段
                val title = remoteMessage.data["title"]
                val message = remoteMessage.data["message"]
                val urlString = remoteMessage.data["url_to_open"]
                // 你可以传递 channel ID 等信息,如果需要动态创建的话
                val channelId = remoteMessage.data["channel_id"] ?: "default_channel"
                val channelName = remoteMessage.data["channel_name"] ?: "Default Channel"
    
    
                if (!title.isNullOrBlank() && !message.isNullOrBlank()) {
                    // 创建用于点击通知的 Intent
                    val notificationIntent: Intent = if (!urlString.isNullOrBlank()) {
                        // 如果有 URL,创建 ACTION_VIEW Intent
                        Intent(Intent.ACTION_VIEW, Uri.parse(urlString)).apply {
                             // 重要:添加 FLAG_ACTIVITY_NEW_TASK 是个好习惯,
                             // 因为 Service 的 Context 启动 Activity 需要这个 flag。
                             // 如果你用 Activity Context 创建 Intent 则不需要。
                             // 也可以考虑 FLAG_ACTIVITY_CLEAR_TOP | FLAG_ACTIVITY_SINGLE_TOP
                             // 取决于你希望如何管理 Activity 栈。
                             flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
                         }
                    } else {
                         // 没有 URL,可以默认启动主 Activity
                         // 假设你的主 Activity 是 MainActivity
                         Intent(this, MainActivity::class.java).apply { // 注意 Context 换成了 this (Service)
                             flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
                         }
                         // 或者根据 data 里其他字段决定跳转到哪个 Activity
                    }
    
                    // 调用你的通知发送函数
                    sendNotification(
                        this, // 使用 Service Context
                        title,
                        message,
                        channelId,
                        channelName,
                        notificationIntent
                    )
                }
            }
    
             // 如果你的消息同时包含了 notification 和 data,这里的逻辑会更复杂
             // 但我们推荐使用纯 data 消息来统一处理行为
        }
    
        // onNewToken 和 sendNotification 函数保持不变 (除了图标已在方案一修改)
        // ... (确保 sendNotification 函数在这里或者可以被 MyFirebaseMessagingService 访问)
    }
    
    // 你的 sendNotification 函数 (确认图标已改为单色)
    // fun sendNotification(...) { ... }
    

这种方法的优点:

  • 行为统一: 无论 App 在前台、后台还是关闭,通知的创建和显示逻辑都由你的 sendNotification 函数控制。
  • 灵活性高: 你可以在 onMessageReceived 中执行任意复杂的逻辑,根据 data 内容动态决定通知样式、点击行为等。

安全建议:

  • 对于 PendingIntent,使用 PendingIntent.FLAG_IMMUTABLE 是 Android 12 (API 31) 及更高版本的强制要求和安全最佳实践。它能防止其他应用篡改你的 PendingIntent。如果你的 Intent 包含需要修改的数据(很少见于通知点击),才考虑 FLAG_MUTABLE 并做好安全防护。你代码里 FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 的组合是正确的,表示如果存在相同请求码的 PendingIntent,则更新其 Extra 数据,同时保持其不可变性。
  • 对从 remoteMessage.data 获取的 URL 或其他数据进行校验,防止潜在的安全风险。

进阶技巧:

  • 你可以利用 data 载荷传递更丰富的指令,比如不仅仅是打开 URL,还可以触发应用内的特定动作(如下载、同步、导航到特定页面片段等)。这通常需要你设计一套健壮的“深层链接”(Deep Linking) 机制或内部路由方案。

思路 B:利用 notification 载荷的标准字段(有限制)

原理: 如果你必须或倾向于使用“通知消息”,可以尝试利用 FCM notification 载荷里的一些标准字段,让系统“猜对”你的意图。但这通常功能有限,且不如数据消息灵活。

  • click_action 这个字段可以指定一个 Action 字符串 ,对应你 AndroidManifest.xml 中某个 Activity 的 <intent-filter><action>。当用户点击系统处理的通知时,系统会尝试发出一个带有这个 Action 的 Intent 来启动匹配的 Activity。

    • 局限性:
      1. 你需要在 Manifest 里为目标 Activity (比如一个专门处理通知跳转的透明 Activity,或者你的主 Activity) 配置好对应的 Intent Filter。
      2. 传递动态数据(比如特定的 URL)比较麻烦。你通常只能在 Intent Filter 中声明要启动哪个 Activity,该 Activity 启动后需要自己从 Intent 的 extras (如果有的话,系统如何传递不确定) 或通过其他方式获取具体操作所需的数据。
      3. 对于简单地打开 主 Activity,这通常是默认行为,click_action 可能不是必须的。对于打开 特定 URL,直接用 click_action 难以直接实现 Intent.ACTION_VIEW 的效果。
  • link (较新,作为 android.notification.click_action 的替代方案之一,并跨平台) 在 notification 载荷下可以尝试添加 link 字段,值为你想打开的 URL。

    {
      "to": "DEVICE_TOKEN",
      "notification": {
        "title": "Check this out!",
        "body": "Visit our website.",
        "link": "https://your-target-url.com" // 尝试使用 link 字段
      }
    }
    
    • 效果: FCM SDK 或系统可能会识别这个 link 并尝试创建一个 ACTION_VIEW Intent 来打开它。但这并非所有 Android 版本或设备都保证的行为 ,且可能没有使用 FLAG_ACTIVITY_NEW_TASK,在某些情况下表现可能不符合预期。它可能没有你通过 PendingIntent 控制得那么精确。

对比与选择:

  • 方案 A (数据消息) 是目前控制 Android 后台通知行为最可靠、最灵活 的方式。它保证了行为一致性,并允许你在 App 代码中完全掌控通知的呈现和交互。
  • 方案 B (利用 notification 字段) 更像是利用系统提供的“快捷方式”,适用于非常简单的场景(如总是打开主 Activity),或者在特定情况下尝试利用 link 字段。但对于需要精确控制、传递动态数据或执行复杂操作的场景,往往不够用或者行为不稳定。

综上,强烈推荐采用 方案 A (数据消息) 来解决后台点击意图的问题,并结合 方案一 解决图标问题。

三、 总结一下

遇到 FCM 后台通知图标变白、点击行为不对的问题时:

  1. 图标问题: 确保 setSmallIcon() 使用的是纯白 + 透明背景 的单色图标资源(推荐 Vector Drawable)。
  2. 点击意图问题: 最佳实践是改用数据消息 (Data Message) ,从服务器只发送 data 载荷(或优先处理 data),然后在 FirebaseMessagingServiceonMessageReceived 中解析 data 内容,并调用你自己的 sendNotification 函数来构建和显示通知,包括设置正确的 PendingIntent。记得给 Intent 设置合适的 Flags (FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_CLEAR_TOP等) 并使用 PendingIntent.FLAG_IMMUTABLE

完成这两步修改后,你的 FCM 通知在 App 后台或关闭状态下,应该就能正常显示小图标,并且点击后执行你预期的操作了。