《推送通知:FCM、APNs 与本地通知调度》

系统讲解 Flutter 推送通知的完整链路:FCM 与 APNs 的令牌机制、前台与后台消息处理、本地通知与定时调度、通知点击后的路由跳转、权限申请时机,以及送达率与厂商通道的踩坑清单,附可直接复用的 Dart 与平台配置片段。

开篇:用户说"我没收到推送"

运营同学发了一条活动推送,后台显示发送成功,但用户反馈"手机上什么都没弹"。排查下去往往有三类原因:设备令牌过期没有更新、iOS 没有配置 APNs 密钥、Android 厂商通道没接入导致进程被杀后收不到。推送看起来只是一个 API 调用,实际是一条跨越客户端、FCM、APNs、厂商服务器四段的长链路,任何一段断掉,用户端都是静默失败。

Flutter 侧推送依赖 firebase_messaging 与 flutter_local_notifications 两个插件:前者负责接收远程消息与令牌,后者负责展示本地通知与定时调度。本文沿着体系选型、FCM 接入、APNs 配置、本地调度、点击路由、可靠性保障这条链路,把 Flutter 推送一次讲清楚。


一、推送体系全景与选型

1.1 远程推送的链路

一条远程推送从服务端发出,到用户看到通知,要经过四段:业务服务端把消息交给 FCM;FCM 在 Android 上直接下发或转交厂商通道(小米、华为、OPPO、vivo);在 iOS 上转交 APNs;设备系统收到后唤起应用或直接展示通知。

环节AndroidiOS
官方通道FCMAPNs
国内补充厂商推送通道无需(APNs 国内可用)
客户端插件firebase_messagingfirebase_messaging
令牌来源FCM TokenAPNs 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,客户端收到后回源拉取详情。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 《GraphQL 与 gRPC 客户端:类型安全 API 与代码生成》
  2. 《图表与数据可视化:fl_chart、自绘图表与实时数据展示》
  3. 《支付与内购:应用内购买、订阅与第三方支付》