解决 Flutter iOS 收不到 FCM 静默数据消息的坑
2025-04-21 10:59:11
解决 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 Fetch
和Remote 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
。
那么问题来了,怎么才能在保持 FirebaseAppDelegateProxyEnabled
为 true
(默认开启) 的情况下,让我们的 Flutter App 在 iOS 上 reliably 收到这些静默的 data
消息呢?
为啥会这样?剖析问题根源
要搞明白这个问题,得先弄清楚几件事:
-
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
的消息。
- 提醒推送 (Alert Push): 就是我们常见的带
-
content-available: 1
的作用: 告诉 iOS:“嘿,这条推送来了,让对应的 App 在后台醒一小会儿,处理点数据吧!” iOS 收到后会尝试唤醒 App,并调用AppDelegate
的application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
方法。 -
apns-priority: 5
的作用: 这是苹果建议用于后台唤醒推送的优先级。优先级为10
通常用于立即显示给用户的提醒推送。设置为5
有助于节省设备电量,但并不能保证 App 一定会被唤醒或获得执行时间,iOS 会根据当前设备状态(比如低电量模式)来决定。 -
Firebase Method Swizzling (
FirebaseAppDelegateProxyEnabled
): 这是 Firebase iOS SDK(包括 Flutter 插件底层依赖的)为了简化集成而采用的一种技术。它在运行时动态地替换掉 App 的AppDelegate
中处理推送、URL Schemes 等的关键方法,插入 Firebase 自己的逻辑(比如自动初始化、上报推送回执等),然后再调用你原来的实现(如果有的话)。对 Flutter 开发者来说,好处是通常不需要手动去写很多原生AppDelegate
代码来对接 Firebase。
问题的可能症结就在于这个 Method Swizzling 和静默推送的组合:
-
冲突点: 当开启 Method Swizzling (
FirebaseAppDelegateProxyEnabled
为true
) 时,Firebase 的代码接管了application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
。理论上,它处理完自己的逻辑后,应该将消息(尤其是data
部分)正确地传递给firebase_messaging
插件,进而触发 Dart 层的onBackgroundMessage
或onMessage
回调。但实际情况是,对于纯静默数据消息 (content-available: 1
且没有alert
/sound
/badge
),这个传递链条在某些情况下似乎断了,或者没有按预期工作。可能是 Firebase 的 Swizzling 实现对这类消息的处理有 bug,或者与firebase_messaging
Flutter 插件的对接存在问题。 -
关闭 Swizzling 为何有效: 当你设置
FirebaseAppDelegateProxyEnabled
为false
时,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 看起来没问题,但魔鬼藏在细节中。再仔细检查一遍:
-
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 用于后台 } }
-
apns-priority
设置:
确认headers
中的apns-priority
设置为"5"
(推荐) 或"10"
。字符串形式的值。省略这个 header 也可以,APNs 会有默认行为,但显式设置更好。 -
移除
notification
字段 (如果只想发纯数据消息):
如果你的目的是纯粹的后台数据处理,确保 FCM 消息体中没有 顶层的notification
字段。如果同时有notification
和data
,并且 App 在后台,系统可能会优先展示通知,行为会变得更复杂。// 纯数据消息 Payload 示例 { "message": { "token": "{FCM_TOKEN}", "data": { // 只有 data 字段 "score": "850", "time": "2:45" }, "apns": { // APNs 特有配置,用于静默唤醒 "payload": { "aps": { "content-available": 1 } }, "headers": { "apns-priority": "5" } } } }
-
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,以便观察实际运行情况。
方案四:深入调试 - 查看原生层日志
如果以上方法都无效,可能需要深入到原生层看看发生了什么。
-
启用 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 处理推送的消息。
-
使用物理设备测试:
模拟器对于推送(尤其是静默推送和后台模式)的支持有限。务必在真实的 iOS 设备上测试。 -
检查原生
AppDelegate
(仅供观察,不建议修改):
虽然我们希望避免修改原生代码,但可以打开ios/Runner/AppDelegate.swift
(或.m
) 文件看看。默认情况下,Flutter 创建的模板可能只包含很少的代码。确认没有奇怪的自定义逻辑意外干扰了 Firebase Swizzling。 -
尝试简化测试:
- 创建一个全新的 Flutter 项目,只集成
firebase_core
和firebase_messaging
,用最少的代码复现后台消息处理逻辑。看看在这个干净的环境下是否能收到静默消息。如果可以,说明可能是你现有项目中的其他代码或插件冲突。 - 发送最简单的静默推送 payload,只包含
content-available: 1
和必要的data
。
- 创建一个全新的 Flutter 项目,只集成
额外的安全考量
- 保护 FCM 服务器密钥: 不要将你的服务器密钥硬编码在客户端 App 里或暴露在不安全的地方。它应该只存在于你的可信服务器环境中。
- 验证消息来源和内容: 后台处理函数接收到的
data
可能被篡改。如果数据很重要,考虑加入签名或其他验证机制。不要直接信任来自推送的所有数据,尤其是用于执行敏感操作时。
进阶技巧
- 根据 App 状态区分处理:
结合使用FirebaseMessaging.onMessage
(前台),FirebaseMessaging.onBackgroundMessage
(后台/终止),FirebaseMessaging.instance.getInitialMessage()
(从终止状态启动) 和FirebaseMessaging.onMessageOpenedApp
(后台点击通知打开),可以在不同场景下执行不同的逻辑。比如,前台收到静默消息可能只想更新数据,后台收到则执行静默任务。 - 使用 FCM Topics:
如果需要向一组设备发送消息,使用 Topics (主题订阅) 比维护大量 token 列表更高效。 - 优化后台任务:
如果静默推送触发的任务比较耗时,考虑使用workmanager
插件来调度更健壮的后台执行,它能更好地处理系统限制。
希望以上分析和方案能帮你定位并解决 Flutter iOS 上接收不到 FCM 静默数据消息的问题。关键通常在于后台处理函数的正确设置、对 iOS 后台机制的理解,以及仔细排查 Payload 和项目配置。