适用场景:为什么“控制台发送成功”不等于用户真的收到
很多 Flutter Android 团队第一次接 FCM 时,都会先用 Firebase Console 发一条测试消息,手机偶尔弹了,就以为链路已经通了。真正上线后才发现问题并不集中在“能不能发”,而是散在四个点上:Android 13 权限没有拿到、token 只在首次安装时上报过一次、通知渠道和前台展示策略不一致、用户点击通知后冷启动进了首页而不是业务页。结果就是控制台显示 send 成功,日志里也能看到回调,但用户感知还是“没收到”或“点开没用”。
这篇文章只处理一个常青问题:Flutter Android 项目的通知链路怎么收口,才能把 token、渠道、点击路由和验收顺序对齐。它更适合已经有测试包或线上包、已经接入 Firebase、并且要把“收到通知”定义成可验证结果的团队。如果你目前卡在 App Links 本身不稳定,先补站内这篇 Flutter Android 深链排查:App Links 校验与 adb 验证;通知点击最终也应该落到同一套路由边界,而不是再造一条只给 push 用的特殊分支。
先把问题拆成四段:权限、Token、渠道、点击路由
我建议把通知链路固定拆成四段,而不是一上来就盯着 Firebase Console:
- Android 13 及以上的
POST_NOTIFICATIONS权限是否真的通过。 - 当前设备的 FCM token 是否在最近一次启动后成功上报到你的服务端。
- Android 8.0 及以上是否已经创建正确的通知渠道,且前台消息有明确展示策略。
- 用户点击通知后,冷启动、后台唤回和前台点击是否都走同一个路由入口。
本文的验证环境按 Flutter stable、Android 13/14 真机、targetSdkVersion 33+ 的边界来写;如果你的包还在 Android 12 及以下,权限这一段会简化,但 token、渠道和点击路由问题仍然存在。正文里的代码和命令可以直接抄到一个最小目录里验证:lib/bootstrap/push_bootstrap.dart 负责初始化,android/app/src/main/AndroidManifest.xml 放权限和 Firebase 配置,android/app/src/main/kotlin/.../MainActivity.kt 或 Application 负责创建通知渠道。
步骤一:初始化别只拿一次 Token,要把权限状态和刷新一起记账
很多线上漏消息不是 FCM 宕了,而是设备 token 早就变了,服务端却还拿着旧值继续推。Flutter 侧最稳的做法不是只在登录成功时拿一次 token,而是把“权限状态 + 当前 token + token 刷新时间”当成一份可审计状态。
AndroidManifest.xml 先把 Android 13 的权限写清楚:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
然后在 Flutter 启动步骤里同时做三件事:请求权限、读取当前 token、监听刷新并回写服务端。
import 'package:firebase_messaging/firebase_messaging.dart';
class PushBootstrap {
PushBootstrap(this.repo);
final PushTokenRepository repo;
Future<void> configure() async {
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
final token = await messaging.getToken();
await repo.saveState(
authorizationStatus: settings.authorizationStatus.name,
token: token,
refreshedAt: DateTime.now().toUtc(),
);
messaging.onTokenRefresh.listen((nextToken) {
repo.saveState(
authorizationStatus: settings.authorizationStatus.name,
token: nextToken,
refreshedAt: DateTime.now().toUtc(),
);
});
}
}
这里不要只把 token 存本地。更关键的是让服务端也保存 device_id / user_id / token / refreshed_at / last_seen_app_version,这样你后面看到投递指标下降时,才能先排除 stale token,而不是直接怀疑消息通道。Android 13 还有一个容易误判的边界:FlutterFire 在 Android 13+ 上返回 denied 时,既可能是用户明确拒绝,也可能只是你还没弹过授权框,所以客户端自己要记一位 has_requested_notification_permission,别把“未请求”与“被拒绝”混成一个状态。
步骤二:通知渠道要在启动时创建,前台展示别指望系统自动兜底
第二个高频故障是:后台和冷启动偶尔能看到通知,前台完全静默,团队误以为是 Android 厂商拦截。实际上 FlutterFire 官方文档已经写得很明确,前台收到 notification message 时,Android 默认不会给你展示可见通知;如果产品要求前台也有用户可见提醒,就要自己补展示策略。
Android 侧先固定通知渠道,且在进程启动时创建,而不是等首条消息到了再懒创建:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"orders_high",
"订单通知",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "支付、发货和异常提醒"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
然后在前台消息回调里把用户可见通知补出来,最小代码可以是:
FirebaseMessaging.onMessage.listen((message) {
final notification = message.notification;
if (notification == null) return;
localNotifications.show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
'orders_high',
'订单通知',
importance: Importance.max,
priority: Priority.high,
),
),
payload: jsonEncode(message.data),
);
});
这里有两个配置边界必须记住。第一,Android 8.0+ 的通知如果没指定渠道,系统不会显示,而且日志里会直接报错。第二,渠道一旦创建,重要级别等行为就不能靠改代码回写;如果你把一个错误的 channel id 发布出去了,后面通常只能新建 channel id,再配一次客户端和服务端 payload。不要把“改 importance 不生效”误判成 ROM 问题。
步骤三:点击通知只留一个入口,冷启动和后台唤回都走同一套路由
真正让业务同学抱怨的,往往不是“有没有提示”,而是用户点开后进错页。常见反模式有两个:一套逻辑写在 getInitialMessage(),另一套逻辑写在 onMessageOpenedApp;或者前台本地通知和后台系统通知各自拼路由字符串,最后线上出现 /orders?id=1、/order/1、order_detail 三套格式。
更稳的做法是统一收口到一个入口函数,只解析一次 payload:
Future<void> bindNotificationOpen(AppRouter router) async {
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_openFromPush(initialMessage.data, router, source: 'cold_start');
}
FirebaseMessaging.onMessageOpenedApp.listen((message) {
_openFromPush(message.data, router, source: 'background_tap');
});
}
void _openFromPush(
Map<String, dynamic> data,
AppRouter router, {
required String source,
}) {
final route = data['route'] as String?;
final orderId = data['orderId'] as String?;
if (route == '/orders/detail' && orderId != null) {
router.go('/orders/$orderId', extra: {'entry': source});
return;
}
router.go('/notifications/inbox');
}
最好把这个入口和 App Links 的解析器复用同一份业务路由映射。通知只是多了一层 transport,业务目标页不应该分成“从浏览器进来一套规则,从通知点开又一套规则”。如果你已经有深链验收脚本,那么 push 点击测试也应该共用那份目标页清单,而不是重新抄一份常量。
验证顺序:先看设备状态,再看日志、测试消息和指标
通知链路排查最怕顺序反过来。很多团队先开 Firebase Console 看 send 成功,再看代码,最后才发现设备根本没授权或者渠道配错了。更省时间的验证顺序是:
adb shell cmd appops get --uid com.example.app POST_NOTIFICATION
adb shell dumpsys notification | rg com.example.app
adb logcat -s FirebaseMessaging NotificationManager flutter
adb shell dumpsys deviceidle force-idle
跑完这些命令再发测试消息。你可以直接在 Firebase Console 里用当前设备 token 做 send test message,也可以走自己的服务端测试接口,但至少要固定验三种场景:
- 应用在前台时,是否按产品预期显示本地通知或站内提示。
- 应用退到后台时,点击通知是否进入正确页面。
- 设备进入 Doze 后,高优先级通知是否仍能在合理时间内到达。
Doze 测完记得恢复:
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset
日志之外,还要看两类指标。第一类是客户端埋点:push_permission_status、push_token_refreshed、push_open_target。第二类是 Firebase Console 的 delivery reports,看 sent、opened、impressions 是否和客户端埋点趋势一致。如果服务端显示推送量稳定,但 impressions 持续掉,先回头查 token 新鲜度和渠道,而不是急着调大发送频率。
如果你的服务端自己维护发送接口,建议再补一个只读排查命令或页面,至少能按 user_id 看见当前绑定 token 数量、最近刷新时间和最后一次发送结果。很多“只有某个账号不响”或“换机后老手机还在响”的问题,其实五分钟就能在这里定位,不必来回重装应用和反复清缓存。
避坑边界:高优先级、重活处理和失效 Token 都不要混着猜
这里最容易踩的坑有四个。第一,高优先级消息不是万能加速键。官方文档明确说,如果一段时间内高优先级消息没有带来用户可见通知,FCM 会把它降级处理;所以别把静默数据同步也一律标成 high。第二,onMessageReceived 或前台回调里不要塞长时间网络请求。需要补图、补详情或者落复杂数据库操作时,用 WorkManager 或前台服务把生命周期延长,不要指望回调线程一直活着。
第三,不要只更新用户最后一次登录设备的 token。注销重登、多账号切换、恢复备份、重新安装,都会让 token 关系变脏,服务端要允许同一用户挂多个有效设备,并定期清理长期未刷新的 token。第四,不要把通知权限、厂商电池策略和产品静默策略揉成一个“系统拦截”。排查时必须分别记录:权限是否通过、渠道是否存在、设备是否在 Doze、消息是否真正落成用户可见通知。
复盘清单:把每次推送故障都收成同一份记录
- 记录测试日期、包版本、
targetSdkVersion、测试机型和 Android 版本。 - 记录本次 token 是否刷新、服务端是否写入、客户端日志关键字是否出现。
- 记录通知渠道 id、importance、前台展示策略和 payload 里的目标路由。
- 记录三种测试结果:前台、后台点击、Doze 模式。
- 记录 Firebase delivery reports 与客户端埋点是否一致,异常偏差出现在哪一段。
- 记录本次属于配置问题、代码问题,还是 token 数据治理问题,下次先查哪一步。
把通知链路真正修稳,靠的不是多发几条测试消息,而是把权限、token、渠道和点击路由变成一条固定验收线。等这四段都能用命令、日志、测试和指标交叉验证后,Flutter Android 的推送问题就会从“偶发玄学”变成可复盘工程问题。