返回

解决 Flutter iOS 收不到 FCM 静默数据消息的坑

IOS

解决 Flutter iOS 收不到 FCM 静默数据消息的坑

咱们团队最近在用 Flutter 和 Firebase Cloud Messaging (FCM) 时碰到了一个头疼的问题:在 iOS 上,死活收不到 data 类型的静默消息。不管是 App 在前台、后台,还是被杀掉,FCM 的消息监听器 (onMessage, onBackgroundMessage) 就跟没事人一样,一点反应都没有。

有意思的是,普通的通知消息(Notification Messages)倒是工作得好好的。

我们确认过,该配的都配了:

  • Firebase 控制台里设置了 APNs Authentication Key
  • Xcode 项目里添加了 Push Notifications capability。
  • Xcode 的 Background Modes 里也启用了 Background FetchRemote Notifications

更奇怪的是,只要在 Info.plist 里把 Firebase 的方法注入(Method Swizzling)关掉,像这样:

<key>FirebaseAppDelegateProxyEnabled</key>
<false/>

欸,data 消息又能收到了!但这并不是我们想要的,因为 Firebase 官方文档特意叮嘱 Flutter 开发者,不要 禁用这个选项,说是为了确保插件正常工作(见下图,虽然是英文的,但意思很明确)。

Flutter FCM Apple Integration Guidance Image

我们测试用的 FCM payload 长这样:

{
    "message": {
        "token": "{你的设备FCM令牌}",
        "data": {
            "type": "TEST",
            "任意自定义数据": "值"
        },
        "apns": {
            "payload": {
                "aps": {
                    "content-available": 1
                }
            },
            "headers": {
                "apns-priority": "5"
            }
        }
    }
}

补充一下,我们用的 firebase_messaging 库版本是 14.8.1

那么问题来了,怎么才能在保持 FirebaseAppDelegateProxyEnabledtrue (默认开启) 的情况下,让我们的 Flutter App 在 iOS 上 reliably 收到这些静默的 data 消息呢?

为啥会这样?剖析问题根源

要搞明白这个问题,得先弄清楚几件事:

  1. iOS 上的推送类型: APNs(Apple Push Notification service)主要有两种推送:

    • 提醒推送 (Alert Push): 就是我们常见的带 alert, sound, badge 的通知,用户能直接看到。对应 FCM 的 notification 字段。
    • 后台推送 (Background Push / Silent Push): 主要用来唤醒 App 在后台处理一些事情,用户是无感知的。关键在于 aps 字典里包含 content-available: 1。这种推送对应 FCM 的 data 消息,或者只包含 data 字段、同时在 apns 配置里设置了 content-available 的消息。
  2. content-available: 1 的作用: 告诉 iOS:“嘿,这条推送来了,让对应的 App 在后台醒一小会儿,处理点数据吧!” iOS 收到后会尝试唤醒 App,并调用 AppDelegateapplication(_:didReceiveRemoteNotification:fetchCompletionHandler:) 方法。

  3. apns-priority: 5 的作用: 这是苹果建议用于后台唤醒推送的优先级。优先级为 10 通常用于立即显示给用户的提醒推送。设置为 5 有助于节省设备电量,但并不能保证 App 一定会被唤醒或获得执行时间,iOS 会根据当前设备状态(比如低电量模式)来决定。

  4. Firebase Method Swizzling (FirebaseAppDelegateProxyEnabled): 这是 Firebase iOS SDK(包括 Flutter 插件底层依赖的)为了简化集成而采用的一种技术。它在运行时动态地替换掉 App 的 AppDelegate 中处理推送、URL Schemes 等的关键方法,插入 Firebase 自己的逻辑(比如自动初始化、上报推送回执等),然后再调用你原来的实现(如果有的话)。对 Flutter 开发者来说,好处是通常不需要手动去写很多原生 AppDelegate 代码来对接 Firebase。

问题的可能症结就在于这个 Method Swizzling 和静默推送的组合:

  • 冲突点: 当开启 Method Swizzling (FirebaseAppDelegateProxyEnabledtrue) 时,Firebase 的代码接管了 application(_:didReceiveRemoteNotification:fetchCompletionHandler:)。理论上,它处理完自己的逻辑后,应该将消息(尤其是 data 部分)正确地传递给 firebase_messaging 插件,进而触发 Dart 层的 onBackgroundMessageonMessage 回调。但实际情况是,对于纯静默数据消息 (content-available: 1 且没有 alert/sound/badge),这个传递链条在某些情况下似乎断了,或者没有按预期工作。可能是 Firebase 的 Swizzling 实现对这类消息的处理有 bug,或者与 firebase_messaging Flutter 插件的对接存在问题。

  • 关闭 Swizzling 为何有效: 当你设置 FirebaseAppDelegateProxyEnabledfalse 时,Firebase 不再替换 AppDelegate 的方法。这时,iOS 系统直接调用你 App 的原生 AppDelegate 中的 application(_:didReceiveRemoteNotification:fetchCompletionHandler:)。如果你的 Flutter 工程模板包含或者你手动添加了调用 FirebaseMessagingPlugin.registrar().application(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: completionHandler) 类似的代码(这是旧版或手动集成方式),那么消息就能被传递到 Dart 层。即使你没加这段代码,消息本身确实送达了 App 的原生层(因为系统调用了回调),只是没有桥接到 Flutter。这反证了问题出在 Swizzling 开启后的消息传递环节。

  • 为什么 Firebase 推荐开启 Swizzling: 因为它能自动处理很多集成细节,特别是对于推送点击事件的处理、消息回执等,可以省去开发者编写大量原生代码的麻烦。禁用它意味着你需要手动在 AppDelegate 中处理更多 Firebase 相关的逻辑,这对于 Flutter 开发者来说可能更复杂且容易出错。

所以,我们的目标是在开启 Swizzling 的前提下,找到让静默 data 消息顺利抵达 Dart 层的方法。

怎么办?动手解决

既然知道了问题可能出在哪,咱们就来试试几个解决方案。

方案一:核对并优化 Payload 与 APNs 配置

虽然你的 payload 看起来没问题,但魔鬼藏在细节中。再仔细检查一遍:

  1. content-available: 1 必须在 aps 字典内:
    确保 content-available 字段确实是在 aps 这个 key 下面,并且值为数字 1,不是字符串 "1"

    // 正确的结构
    "apns": {
        "payload": {
            "aps": {
                "content-available": 1
                // 注意:这里不要有 alert, sound, badge,否则就不是纯静默推送了
            }
        },
        "headers": {
            "apns-priority": "5" // 或者 "10" 如果需要更快唤醒,但通常 5 用于后台
        }
    }
    
  2. apns-priority 设置:
    确认 headers 中的 apns-priority 设置为 "5" (推荐) 或 "10"。字符串形式的值。省略这个 header 也可以,APNs 会有默认行为,但显式设置更好。

  3. 移除 notification 字段 (如果只想发纯数据消息):
    如果你的目的是纯粹的后台数据处理,确保 FCM 消息体中没有 顶层的 notification 字段。如果同时有 notificationdata,并且 App 在后台,系统可能会优先展示通知,行为会变得更复杂。

    // 纯数据消息 Payload 示例
    {
        "message": {
            "token": "{FCM_TOKEN}",
            "data": { // 只有 data 字段
                "score": "850",
                "time": "2:45"
            },
            "apns": { // APNs 特有配置,用于静默唤醒
                "payload": {
                    "aps": {
                        "content-available": 1
                    }
                },
                "headers": {
                    "apns-priority": "5"
                }
            }
        }
    }
    
  4. APNs Key 检查:
    登录 Firebase 控制台 -> 项目设置 -> Cloud Messaging -> Apple 应用配置。确认你的 APNs 认证密钥 (.p8 文件) 或 APNs 证书仍然有效,并且 Team ID 和 Key ID 配置正确。

方案二:确保顶层后台消息处理函数正确设置

对于在后台或终止状态下收到的 FCM 消息(特别是静默消息),Flutter firebase_messaging 依赖一个顶层函数 (top-level function)静态方法 (static method) 来处理。这个函数必须在你的 main() 函数之外定义,并且用 @pragma('vm:entry-point') 注解标记。

检查或添加你的后台消息处理函数:

在你的 main.dart 文件或者一个单独的 firebase_messaging_background_handler.dart 文件里:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:developer'; // 用于打印日志

// IMPORTANT: 必须是顶层函数(不能是类方法或闭包)
// IMPORTANT: 必须使用 @pragma('vm:entry-point') 注解
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 如果你在这里需要使用其他插件(如 SharedPreferences, http),
  // 确保在这里也初始化它们,或者确保你的 App 结构支持从这里访问已初始化的实例。
  // 由于这是一个独立的 Isolate,它不共享 main Isolate 的内存。
  // 在调用 Firebase 相关功能前,可能需要确保 Firebase 已初始化
  await Firebase.initializeApp(); // 根据你的初始化逻辑可能需要调整

  log("后台消息处理函数触发! Message data: ${message.data}");

  if (message.data.containsKey('type')) {
    log("收到的类型是: ${message.data['type']}");
    // 在这里根据 message.data 执行你的后台任务
    // 比如:更新本地数据库、发起一个短暂的网络请求等
    // 注意:iOS 给的后台执行时间非常有限(通常几十秒),任务要轻量快速。
  }

  // 这里不需要手动调用 completionHandler,插件内部会处理
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
      // options: DefaultFirebaseOptions.currentPlatform, // 如果你用了 FlutterFire CLI 生成的配置
      );

  // 在 App 启动时就设置后台消息处理器
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ...你的 App UI
    return MaterialApp(
      // ...
    );
  }
}

// 别忘了在你的 App 启动逻辑中,也可能需要请求通知权限
// 虽然静默推送理论上不需要用户授权,但完整的 FCM 设置通常包含权限请求
Future<void> setupFirebaseMessaging() async {
  FirebaseMessaging messaging = FirebaseMessaging.instance;

  NotificationSettings settings = await messaging.requestPermission(
    alert: true,
    announcement: false,
    badge: true,
    carPlay: false,
    criticalAlert: false,
    provisional: false,
    sound: true,
  );

  log('用户通知权限状态: ${settings.authorizationStatus}');

  // 监听前台消息 (这个主要用于非静默消息,但设置好总没错)
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    log('收到前台消息!');
    log('Message data: ${message.data}');

    if (message.notification != null) {
      log('Message also contained a notification: ${message.notification}');
    }
    // 在这里处理前台收到的消息,比如弹个 In-App notification
  });

  // 处理从终止状态启动 App 时携带的消息(通常是用户点击通知栏)
  // 静默消息通常不会通过这种方式触发 UI 交互
  RemoteMessage? initialMessage = await messaging.getInitialMessage();
  if (initialMessage != null) {
     log("从终止状态启动,携带的消息: ${initialMessage.data}");
     // 根据 initialMessage.data 跳转到特定页面等
     _handleMessageNavigation(initialMessage); // 示例函数
  }

  // 处理 App 在后台时,用户点击通知栏导致 App 恢复到前台的情况
  FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
    log('通知被点击,App 从后台打开!');
    log('Message data: ${message.data}');
    _handleMessageNavigation(message); // 示例函数
  });
}

void _handleMessageNavigation(RemoteMessage message) {
  // 实现你的导航逻辑,比如根据 message.data['screen'] 跳转
}

关键点:

  • @pragma('vm:entry-point') 不能少,它告诉 AOT 编译器保留这个函数作为入口点。
  • 函数必须是顶级的或者静态的。
  • 后台处理函数在一个独立的 Isolate 中运行,不能直接访问 main Isolate 的状态或 UI。如果需要共享数据,得通过数据库、文件、SharedPreferences 等持久化存储机制。
  • 在这里进行的操作必须快速完成,iOS 对后台执行时间有严格限制。长时间运行的任务需要使用专门的后台任务 API (如 workmanager 插件,或者原生方案)。

方案三:理解并适应 iOS 后台限制

即使所有配置都正确,iOS 系统本身也可能阻止你的 App 被静默推送唤醒。以下情况需要注意:

  • 低电量模式 (Low Power Mode): 会显著减少后台活动,包括后台获取和静默推送的唤醒频率。
  • 后台应用刷新 (Background App Refresh) 被禁用: 如果用户在系统设置中关闭了你的 App 或全局的后台应用刷新,静默推送唤醒会受影响。
  • 设备存储空间不足: 可能导致系统积极终止后台进程。
  • App 长时间未使用或被强制退出: 系统可能会降低该 App 的唤醒优先级。
  • 网络连接不稳定或关闭: App 需要网络来接收 APNs 消息和执行后续任务。
  • 系统优化: iOS 会学习用户习惯,可能会推迟或合并后台唤醒以节省电量。

应对策略:

  • 设计容错性: 不要假设静默推送总能 100% 实时送达并执行。把它看作是一种“尽力而为”的机制。如果需要强保证的数据同步,应在 App 启动或恢复到前台时主动检查更新。
  • 告知用户: 如果你的功能严重依赖后台刷新,可以在 App 内适当提示用户检查系统设置(低电量模式、后台应用刷新)。
  • 监控与日志: 在后台处理函数中加入详细的日志记录,发送到你自己的日志服务或 Firebase Crashlytics/Analytics,以便观察实际运行情况。

方案四:深入调试 - 查看原生层日志

如果以上方法都无效,可能需要深入到原生层看看发生了什么。

  1. 启用 APNs 调试日志:
    在 Xcode 中,编辑你的运行 Scheme (Product -> Scheme -> Edit Scheme...)。选择 "Run" 阶段,在 "Arguments" 标签页下的 "Arguments Passed On Launch" 中添加:
    -FIRDebugEnabled (启用 Firebase 详细日志)
    -FIRAnalyticsDebugEnabled (启用 Analytics 详细日志,可能也有帮助)
    可能还需要添加环境变量来启用 APNs 自身的日志 (参考 Apple 文档,但这通常更复杂)。 -com.apple.CoreData.SQLDebug 1 这种是 CoreData 的,APNs 可能没有直接的环境变量开关,但 Firebase 日志可能会包含 APNs 交互信息。

    重新运行你的 App,然后在 Xcode 的控制台或设备的控制台 (通过 Xcode -> Window -> Devices and Simulators -> 选择你的设备 -> Open Console) 中查看详细输出。找找看有没有关于接收到推送、Firebase 处理推送的消息。

  2. 使用物理设备测试:
    模拟器对于推送(尤其是静默推送和后台模式)的支持有限。务必在真实的 iOS 设备上测试。

  3. 检查原生 AppDelegate (仅供观察,不建议修改):
    虽然我们希望避免修改原生代码,但可以打开 ios/Runner/AppDelegate.swift (或 .m) 文件看看。默认情况下,Flutter 创建的模板可能只包含很少的代码。确认没有奇怪的自定义逻辑意外干扰了 Firebase Swizzling。

  4. 尝试简化测试:

    • 创建一个全新的 Flutter 项目,只集成 firebase_corefirebase_messaging,用最少的代码复现后台消息处理逻辑。看看在这个干净的环境下是否能收到静默消息。如果可以,说明可能是你现有项目中的其他代码或插件冲突。
    • 发送最简单的静默推送 payload,只包含 content-available: 1 和必要的 data

额外的安全考量

  • 保护 FCM 服务器密钥: 不要将你的服务器密钥硬编码在客户端 App 里或暴露在不安全的地方。它应该只存在于你的可信服务器环境中。
  • 验证消息来源和内容: 后台处理函数接收到的 data 可能被篡改。如果数据很重要,考虑加入签名或其他验证机制。不要直接信任来自推送的所有数据,尤其是用于执行敏感操作时。

进阶技巧

  • 根据 App 状态区分处理:
    结合使用 FirebaseMessaging.onMessage (前台), FirebaseMessaging.onBackgroundMessage (后台/终止), FirebaseMessaging.instance.getInitialMessage() (从终止状态启动) 和 FirebaseMessaging.onMessageOpenedApp (后台点击通知打开),可以在不同场景下执行不同的逻辑。比如,前台收到静默消息可能只想更新数据,后台收到则执行静默任务。
  • 使用 FCM Topics:
    如果需要向一组设备发送消息,使用 Topics (主题订阅) 比维护大量 token 列表更高效。
  • 优化后台任务:
    如果静默推送触发的任务比较耗时,考虑使用 workmanager 插件来调度更健壮的后台执行,它能更好地处理系统限制。

希望以上分析和方案能帮你定位并解决 Flutter iOS 上接收不到 FCM 静默数据消息的问题。关键通常在于后台处理函数的正确设置、对 iOS 后台机制的理解,以及仔细排查 Payload 和项目配置。