适用场景:为什么“控制台发送成功”不等于用户真的收到

很多 Flutter Android 团队第一次接 FCM 时,都会先用 Firebase Console 发一条测试消息,手机偶尔弹了,就以为链路已经通了。真正上线后才发现问题并不集中在“能不能发”,而是散在四个点上:Android 13 权限没有拿到、token 只在首次安装时上报过一次、通知渠道和前台展示策略不一致、用户点击通知后冷启动进了首页而不是业务页。结果就是控制台显示 send 成功,日志里也能看到回调,但用户感知还是“没收到”或“点开没用”。

这篇文章只处理一个常青问题:Flutter Android 项目的通知链路怎么收口,才能把 token、渠道、点击路由和验收顺序对齐。它更适合已经有测试包或线上包、已经接入 Firebase、并且要把“收到通知”定义成可验证结果的团队。如果你目前卡在 App Links 本身不稳定,先补站内这篇 Flutter Android 深链排查:App Links 校验与 adb 验证;通知点击最终也应该落到同一套路由边界,而不是再造一条只给 push 用的特殊分支。

先把问题拆成四段:权限、Token、渠道、点击路由

我建议把通知链路固定拆成四段,而不是一上来就盯着 Firebase Console:

  1. Android 13 及以上的 POST_NOTIFICATIONS 权限是否真的通过。
  2. 当前设备的 FCM token 是否在最近一次启动后成功上报到你的服务端。
  3. Android 8.0 及以上是否已经创建正确的通知渠道,且前台消息有明确展示策略。
  4. 用户点击通知后,冷启动、后台唤回和前台点击是否都走同一个路由入口。

本文的验证环境按 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/1order_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_statuspush_token_refreshedpush_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 的推送问题就会从“偶发玄学”变成可复盘工程问题。