开篇:用户说"我没收到推送"
运营同学发了一条活动推送,后台显示发送成功,但用户反馈"手机上什么都没弹"。排查下去往往有三类原因:设备令牌过期没有更新、iOS 没有配置 APNs 密钥、Android 厂商通道没接入导致进程被杀后收不到。推送看起来只是一个 API 调用,实际是一条跨越客户端、FCM、APNs、厂商服务器四段的长链路,任何一段断掉,用户端都是静默失败。
Flutter 侧推送依赖 firebase_messaging 与 flutter_local_notifications 两个插件:前者负责接收远程消息与令牌,后者负责展示本地通知与定时调度。本文沿着体系选型、FCM 接入、APNs 配置、本地调度、点击路由、可靠性保障这条链路,把 Flutter 推送一次讲清楚。
一、推送体系全景与选型
1.1 远程推送的链路
一条远程推送从服务端发出,到用户看到通知,要经过四段:业务服务端把消息交给 FCM;FCM 在 Android 上直接下发或转交厂商通道(小米、华为、OPPO、vivo);在 iOS 上转交 APNs;设备系统收到后唤起应用或直接展示通知。
| 环节 | Android | iOS |
|---|---|---|
| 官方通道 | FCM | APNs |
| 国内补充 | 厂商推送通道 | 无需(APNs 国内可用) |
| 客户端插件 | firebase_messaging | firebase_messaging |
| 令牌来源 | FCM Token | APNs Token 换 FCM Token |
| 后台唤起 | 数据消息可唤醒 | 静默推送有频率限制 |
1.2 插件职责划分
# pubspec.yaml
dependencies:
firebase_core: ^3.6.0
firebase_messaging: ^15.1.3
flutter_local_notifications: ^17.2.3
permission_handler: ^11.3.1
firebase_messaging:拿令牌、收前台消息、收后台消息、处理通知点击。flutter_local_notifications:展示本地通知、定时通知、渠道与优先级管理。permission_handler:统一 Android 13 与 iOS 的权限申请入口。
一句话总结:远程推送负责"送达到设备",本地通知负责"展示与调度",两者职责不能混。
1.3 权限申请的时机
推送权限不要在冷启动第一帧就弹,用户还没理解产品价值时拒绝率极高。推荐在用户完成首次关键动作后,用一个自定义说明弹窗解释用途,再触发系统权限申请。
二、FCM 接入与设备令牌管理
2.1 初始化与获取令牌
Future<void> initMessaging() async {
await Firebase.initializeApp();
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
debugPrint('授权状态:${settings.authorizationStatus}');
final token = await messaging.getToken();
debugPrint('FCM Token:$token');
}
2.2 令牌刷新与上报
令牌不是永久的:应用重装、清数据、恢复备份、长时间不活跃都会导致令牌变化。必须监听刷新并上报服务端,同时支持服务端失效旧令牌。
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
await api.updatePushToken(newToken); // 上报到业务服务端
});
| 触发场景 | 令牌是否变化 | 处理方式 |
|---|---|---|
| 首次安装 | 新生成 | 上报 |
| 应用重装 | 变化 | 上报 |
| 清应用数据 | 变化 | 上报 |
| 卸载重装 | 变化 | 上报并失效旧值 |
| 用户登出 | 不变 | 绑定关系解绑 |
2.3 前台、后台与终止态消息
三种状态下的回调完全不同,必须分别处理:
- 前台:
FirebaseMessaging.onMessage触发,系统不会自动展示通知,需要自己用本地通知展示。 - 后台:
FirebaseMessaging.onMessageOpenedApp在用户点击通知时触发。 - 终止态:
getInitialMessage返回冷启动时那条被点击的消息。
FirebaseMessaging.onMessage.listen((message) {
showLocalNotification(message); // 前台自己弹
});
FirebaseMessaging.onMessageOpenedApp.listen((message) {
handleRoute(message.data); // 后台点击跳转
});
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
handleRoute(initial.data); // 冷启动点击跳转
}
一句话总结:前台消息不会自动弹通知,这是新手最容易踩的坑;三态回调缺一个,就有一种场景收不到。
三、APNs 与 iOS 平台配置
3.1 证书与密钥
iOS 推送需要在 Apple Developer 后台创建 APNs 密钥(.p8 文件),上传到 Firebase 控制台的 Cloud Messaging 设置。使用 .p8 密钥比 .p12 证书更好,一份密钥可用于开发与生产环境且不会过期。
<!-- ios/Runner/Runner.entitlements 开启推送能力 -->
<key>aps-environment</key>
<string>development</string>
发布时需将 development 改为 production,并在 Xcode 的 Signing & Capabilities 中打开 Push Notifications 与 Background Modes 的 Remote notifications。
3.2 静默推送与后台限制
iOS 的静默推送(content-available: 1)可以唤醒应用后台执行代码,但系统有严格的频率限制与电量策略,不能作为可靠的数据同步手段。
| 类型 | 载荷标记 | 用户可见 | 可靠性 |
|---|---|---|---|
| 提醒推送 | alert | 是 | 高 |
| 静默推送 | content-available | 否 | 低,可能被节流 |
| 富媒体推送 | mutable-content | 是 | 高,可加载图片 |
3.3 Android 通知渠道
Android 8.0 起通知必须归属渠道,渠道的重要性决定是否响铃与横幅展示,且创建后用户可修改,应用无法覆盖用户选择。
const channel = AndroidNotificationChannel(
'high_importance_channel',
'重要通知',
importance: Importance.high,
);
await FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
一句话总结:iOS 靠密钥与能力开关,Android 靠通知渠道与厂商通道,两端配置缺一不可。
四、本地通知与定时调度
4.1 初始化与展示
final plugin = FlutterLocalNotificationsPlugin();
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await plugin.initialize(
initSettings,
onDidReceiveNotificationResponse: (response) {
handleRoute(response.payload); // 点击本地通知的回调
},
);
4.2 定时通知与重复通知
zonedSchedule 需要传入带时区的 TZDateTime,直接传本地时间会在跨时区或夏令时场景错乱。
await plugin.zonedSchedule(
0,
'每日提醒',
'别忘了完成今天的任务',
_nextInstanceOfTenAM(),
const NotificationDetails(
android: AndroidNotificationDetails('daily', '每日提醒'),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time, // 每天重复
);
4.3 Android 精确闹钟权限
Android 12 起,精确闹钟需要 SCHEDULE_EXACT_ALARM 权限,Android 14 起进一步收紧为 USE_EXACT_ALARM 或用户手动授权。若只是提醒类通知,可用 inexactAllowWhileIdle 避免权限问题。
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
4.4 通知分组与摘要
同类型通知应使用同一 groupKey 分组,避免刷屏;Android 还支持摘要通知,把多条合并成一条概览。
一句话总结:本地定时通知的关键是时区与精确闹钟权限,两者不处理会在真机上"有时准有时不准"。
五、通知点击路由与数据负载
5.1 数据负载的结构
推送负载分 notification 与 data 两部分:notification 由系统直接展示,data 交给应用处理。要保证通知在任何状态都能正确跳转,建议在 data 中携带完整的路由信息。
{
"notification": { "title": "新消息", "body": "你有一条新回复" },
"data": { "route": "/message/detail", "id": "1024" }
}
5.2 冷启动跳转的处理
冷启动时应用尚未构建好导航栈,此时直接 push 会失败。正确做法是先把待跳转路由存起来,等首帧渲染完成后再执行。
String? _pendingRoute;
void handleRoute(Map<String, dynamic> data) {
final route = data['route'] as String?;
if (route == null) return;
if (_navigatorKey.currentState == null) {
_pendingRoute = route; // 导航未就绪,先缓存
} else {
_navigatorKey.currentState!.pushNamed(route);
}
}
5.3 与深链路的配合
推送跳转本质上是一次深链路。若应用已接入统一的深链路路由表,推送只需把 URL 交给路由器即可,避免维护两套跳转逻辑。
一句话总结:推送跳转要复用深链路路由,且必须处理"导航栈未就绪"的冷启动场景。
六、可靠性与踩坑清单
6.1 送达率问题
Android 上应用被杀死后,普通 FCM 消息可能收不到,需要接入厂商通道;iOS 上静默推送被节流是正常现象。要提升送达率,业务侧应保留"拉取兜底":应用回到前台时主动拉取未读消息。
6.2 常见踩坑清单
- 令牌未上报或未更新:用户重装后收不到推送。
- 前台未处理
onMessage:前台完全看不到通知。 - 忘记请求权限:Android 13 与 iOS 上消息静默丢弃。
- 通知渠道用默认低重要性:通知不响铃、不弹横幅。
- 冷启动直接
pushNamed:导航栈未就绪导致崩溃。 - 定时通知传本地时间:跨时区后时间错乱。
data中携带过大数据:超过 4KB 会被拒收。
6.3 测试与验证
# 用 FCM 调试接口向指定令牌发送测试消息
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":{"token":"DEVICE_TOKEN","notification":{"title":"t","body":"b"}}}' \
https://fcm.googleapis.com/v1/projects/PROJECT_ID/messages:send
测试时务必覆盖:前台、后台、终止态三种状态,以及点击与不点击两种路径,共六种组合。
一句话总结:推送没有 100% 送达,工程上要做的是"把失败场景兜住",而不是假设它一定成功。
FAQ
常见问题:为什么 iOS 收不到推送,Android 正常?
答:多半是 APNs 侧配置问题:检查是否上传了正确的 .p8 密钥、aps-environment 是否与构建类型匹配、Xcode 是否开启了 Push Notifications 能力。另外模拟器从 iOS 16 起才支持推送,旧模拟器必须用真机测试。
常见问题:Android 应用被杀死后收不到推送怎么办?
答:标准 FCM 消息在进程被杀后可能无法送达。需要接入各厂商的推送通道(小米、华为、OPPO、vivo、荣耀),或使用聚合推送服务统一接入;同时在应用回到前台时做一次消息拉取兜底。
常见问题:前台收到消息为什么不弹通知?
答:这是设计如此。onMessage 只是把消息交给应用,系统不会自动展示。需要自己调用 flutter_local_notifications 展示一条本地通知,否则用户在前台完全感知不到。
常见问题:定时通知在真机上不准时?
答:两个原因:一是没有使用带时区的 TZDateTime,二是没有申请精确闹钟权限。Android 12 以后精确闹钟需要 SCHEDULE_EXACT_ALARM,Android 14 更严格;不需要精确到分钟的场景应使用非精确模式以规避权限。
常见问题:推送点击后应用崩溃或跳转失败?
答:典型原因是冷启动时导航栈尚未就绪。应先把目标路由缓存到变量,在首帧完成后再执行跳转;同时确保跳转逻辑复用统一的深链路路由表,避免两套规则冲突。
常见问题:设备令牌需要多久更新一次?
答:不需要主动轮询,监听 onTokenRefresh 即可。但要保证服务端能处理"同一用户多个令牌"与"令牌失效"两种情况,用户登出时应解绑当前设备与账号的关系。
常见问题:推送消息的数据负载有大小限制吗?
答:有。FCM 与 APNs 的单条消息负载通常限制在 4KB 左右,超出会被拒收。需要传大量数据时应只传 ID,客户端收到后回源拉取详情。
相关阅读
- Firebase 集成 — Firebase 各服务在 Flutter 中的初始化与配置
- 深链路与路由 — 通知点击跳转背后的统一路由表设计
- Flutter 导航与路由 — 导航栈与命名路由的完整用法
- Flutter 平台通道 — 推送插件如何与原生消息服务通信
- Flutter 错误处理与可靠性 — 消息失败场景的兜底与重试策略
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。