Flutter 基础体系 · 第 63/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 推送通知:Token、前后台、点击路由、权限和送达率

Flutter 本身不提供统一的推送服务。Flutter 应用通常通过平台推送通道接收消息:

  • Android 通常使用 Firebase Cloud Messaging(FCM);
  • iOS 使用 Apple Push Notification service(APNs),FCM 可以作为上层消息服务,把消息转发到 APNs;
  • Web 使用浏览器的 Push API、Service Worker 和 Web Push;
  • Windows、Linux 等桌面平台没有由 Flutter 统一提供的系统推送接口,需要使用平台厂商 SDK、第三方服务或自建桥接层;
  • macOS 的能力取决于所使用的原生推送 SDK、应用签名和 entitlements,不能简单套用 iOS 配置。

因此,所谓“Flutter 推送”,实际是一个跨越应用、Flutter 插件、原生推送服务、厂商系统和业务服务器的链路。Flutter 主要负责应用内的初始化、消息处理、状态同步和点击后的路由。


一、先建立完整的推送模型

一次推送涉及以下组件:

sequenceDiagram
    participant S as 业务服务器
    participant P as FCM/APNs/Web Push
    participant OS as 操作系统
    participant A as Flutter 应用
    participant U as 用户

    A->>P: 注册并获取 token
    A->>S: 上传 token 与用户绑定关系
    S->>P: 发送消息
    P->>OS: 按平台投递
    OS->>A: 前台回调或后台通知
    OS->>U: 展示系统通知
    U->>OS: 点击通知
    OS->>A: 启动或唤醒应用
    A->>A: 解析 payload 并执行路由
    A->>S: 上报 received/opened 等业务事件

这里有四个经常被混淆的对象:

  1. Token:某个应用安装实例在某个推送服务中的地址。
  2. Message:服务器提交给推送服务的消息。
  3. Notification:操作系统展示给用户的通知界面。
  4. Route:用户点击后,应用内部要打开的页面或业务上下文。

它们不是同一个概念。收到消息不代表展示了通知,展示通知也不代表用户点击,点击通知也不代表业务页面一定加载成功。


二、Token 是什么,为什么会变化

2.1 Token 不是用户 ID,也不是永久设备 ID

FCM registration token 可以理解为:

推送服务向某个应用安装实例投递消息时使用的可轮换地址。

一个用户可能有多个 token:

  • 手机和平板各有一个;
  • 同一台手机重新安装应用后可能产生新 token;
  • 应用数据清除、系统恢复、推送服务重新注册后可能变化;
  • 多个应用环境,例如开发包和生产包,通常对应不同 token;
  • iOS 的 APNs token、FCM token 和业务用户 ID 也不是同一个值。

因此,服务端不应建立:

user_id -> 一个 token

更合理的模型是:

user_id -> 多个 app_installation -> 多个 token

例如:

CREATE TABLE push_tokens (
    id              BIGINT PRIMARY KEY,
    user_id         BIGINT NULL,
    token           TEXT NOT NULL UNIQUE,
    platform        VARCHAR(16) NOT NULL,
    app_version     VARCHAR(32) NOT NULL,
    environment     VARCHAR(16) NOT NULL,
    last_seen_at    TIMESTAMP NOT NULL,
    invalid_at      TIMESTAMP NULL
);

user_id 可以为空,因为用户可能在登录前就获得 token。登录后再绑定,退出登录时解除绑定或标记为匿名,取决于业务安全模型。

2.2 Token 的生命周期

FlutterFire 中通常通过 getToken() 获取当前 token,并通过 onTokenRefresh 监听变化:

import 'dart:async';

import 'package:firebase_messaging/firebase_messaging.dart';

class PushTokenService {
  PushTokenService(this._messaging);

  final FirebaseMessaging _messaging;
  StreamSubscription<String>? _refreshSubscription;

  Future<void> start() async {
    final token = await _messaging.getToken();
    if (token != null) {
      await _uploadToken(token);
    }

    _refreshSubscription = _messaging.onTokenRefresh.listen(
      (newToken) async {
        await _uploadToken(newToken);
      },
      onError: (Object error, StackTrace stack) {
        // 记录错误,但不要因为 token 刷新失败终止应用启动。
      },
    );
  }

  Future<void> _uploadToken(String token) async {
    // 真实项目中应通过 HTTPS 上传,并携带:
    // user_id、平台、应用版本、环境、设备安装实例 ID 等信息。
    print('upload token: $token');
  }

  Future<void> dispose() async {
    await _refreshSubscription?.cancel();
  }
}

这个流程的因果关系是:

  1. 应用启动时读取当前 token;
  2. 把 token 上传到业务服务器;
  3. token 变化时再次上传;
  4. 服务端将新 token 标记为有效;
  5. 旧 token 在推送失败后失效或被清理。

只在首次安装时上传一次 token 是常见错误。它在测试设备上可能看起来正常,但应用重装、系统迁移或 token 轮换后会导致推送突然失效。

2.3 Token 上传必须幂等

网络重试可能导致同一个 token 多次上传,因此服务端应使用唯一约束或幂等更新:

第一次上传 token T:INSERT
第二次上传 token T:UPDATE last_seen_at

服务端发送失败时也不能立即删除用户。推送服务返回的错误需要分类:

  • token 无效或注册失效:可以标记 invalid_at
  • 限流:延迟重试;
  • 鉴权错误:修复服务端凭据;
  • 网络超时:根据请求 ID 重试;
  • 临时不可用:指数退避。

删除 token 前,应确认错误确实表示“永久无效”,否则可能把暂时性故障误判为设备失效。


三、权限决定“能否展示”,不是决定“消息是否存在”

3.1 iOS 权限

iOS 通常需要向用户请求通知权限:

final settings = await FirebaseMessaging.instance.requestPermission(
  alert: true,
  badge: true,
  sound: true,
  provisional: false,
);

switch (settings.authorizationStatus) {
  case AuthorizationStatus.authorized:
    print('用户已授权通知');
  case AuthorizationStatus.provisional:
    print('临时授权,通知可能以较低干扰方式展示');
  case AuthorizationStatus.denied:
    print('用户拒绝通知');
  case AuthorizationStatus.notDetermined:
    print('尚未决定');
  case AuthorizationStatus.ephemeral:
    print('临时应用授权状态');
}

iOS 还需要:

  • 在 Xcode 中启用 Push Notifications capability;
  • 正确配置 APNs authentication key 或 certificate;
  • 应用的 bundle identifier、签名环境和推送环境必须匹配;
  • 如果需要后台静默处理,还涉及 Background Modes 中的 Remote notifications;
  • APNs 的生产环境和沙盒环境不能混用。

权限被拒绝时,仍然可能存在 token 或服务端发送记录,但系统通常不会向用户展示可见通知。不能把“拿到了 token”解释成“用户一定允许通知”。

provisional 是 iOS 的临时授权状态。它允许应用在较低打扰的方式下尝试展示通知,后续用户仍可能升级或拒绝权限。它适合需要降低首次弹窗打扰的场景,但不是所有业务都适合。

3.2 Android 权限

Android 13(API 33)及以上需要运行时通知权限 POST_NOTIFICATIONS。使用 FlutterFire 时,可以调用:

final settings = await FirebaseMessaging.instance.requestPermission(
  alert: true,
  badge: true,
  sound: true,
);

print(settings.authorizationStatus);

但 Android 的最终行为还受以下因素影响:

  • Android 版本;
  • 应用 target SDK;
  • 系统通知总开关;
  • 单个通知渠道的开关;
  • 渠道重要性;
  • 用户是否手动关闭该渠道;
  • 厂商系统的后台限制;
  • 应用是否被强制停止。

Android 的通知渠道一旦创建,重要性等部分属性通常不能由应用静默修改。开发阶段把渠道创建成低重要性,之后即使代码改成高重要性,系统也可能继续保留用户原来的渠道设置。验证通知优先级时,往往需要删除渠道或卸载重装测试。

3.3 Web 权限

Web Push 依赖浏览器通知权限和 Service Worker。权限属于浏览器和站点,不等同于移动端权限。用户可以:

  • 拒绝站点通知;
  • 只允许某个浏览器;
  • 清理站点数据导致订阅失效;
  • 使用不支持 Web Push 的浏览器;
  • 处于隐私模式或受企业策略限制。

Web 还需要 VAPID 公钥、Service Worker 注册和 HTTPS(本地开发通常是 localhost 例外)。Flutter Web 的应用代码不能替代 Service Worker,因为浏览器在页面不活跃甚至页面关闭后,需要由 Service Worker 接收和处理推送。


四、消息类型:通知消息、数据消息和混合消息

4.1 通知消息

通知消息包含可展示内容,例如标题和正文。典型结构类似:

{
  "message": {
    "token": "DEVICE_TOKEN",
    "notification": {
      "title": "订单状态更新",
      "body": "订单已发货"
    },
    "data": {
      "route": "/orders/detail",
      "order_id": "A1001"
    }
  }
}

常见行为是:

  • 应用在后台时,操作系统或 FCM SDK 可能直接展示通知;
  • 应用在前台时,消息通常交给 Flutter 的 onMessage 回调,系统不一定自动弹出通知;
  • 用户点击后台通知后,应用收到打开事件;
  • data 用于携带业务上下文,但不能假定所有字段在所有平台和状态下都以完全相同的方式到达。

4.2 数据消息

数据消息只携带业务数据,例如:

{
  "message": {
    "token": "DEVICE_TOKEN",
    "data": {
      "type": "sync",
      "resource_id": "R1001"
    }
  }
}

它更适合:

  • 通知应用刷新数据;
  • 后台执行轻量同步;
  • 由应用决定是否展示本地通知。

但数据消息不是可靠的后台任务队列。iOS 可能限制后台执行时间,Android 可能因 Doze、厂商策略或应用状态限制而延迟或丢弃后台执行机会。需要可靠处理的任务仍应由服务端保存状态,应用恢复后主动拉取。

4.3 混合消息的前后台差异

混合消息同时包含 notificationdata。它的优点是后台可以由系统展示,缺点是行为更依赖平台。

例如同一条消息可能产生:

应用状态 常见表现
前台 Flutter 收到 onMessage,通常需要应用自己决定是否展示
后台 系统展示通知,点击后 Flutter 收到打开事件
被系统终止 用户点击后应用冷启动,并从初始消息读取 payload
iOS 后台 展示、后台唤醒和数据处理受 APNs headers、权限及系统策略影响

如果业务要求完全控制展示内容、去重和点击行为,通常会使用数据消息加本地通知;如果业务更重视后台由系统直接展示,则使用通知消息或混合消息,但必须接受平台差异。


五、Flutter 的前台、后台和终止状态

“后台”不是一个单一状态,至少要区分:

  1. 前台:Flutter isolate 正常运行,用户正在使用应用。
  2. 后台但进程仍在:应用不可见,但进程可能还在。
  3. 进程被系统回收:应用进程不存在,系统未来可能重新启动它。
  4. 用户强制停止:Android 用户在系统设置中执行 Force Stop,系统通常不会主动为应用启动后台接收逻辑。
  5. 用户点击通知冷启动:应用进程原本不存在,点击动作启动应用。

对应 FlutterFire 的主要入口如下:

FirebaseMessaging.onMessage

前台消息回调。它发生在 Flutter isolate 中,可以更新状态或显示应用内 UI。

FirebaseMessaging.onBackgroundMessage(handler)

后台消息回调。处理函数必须是顶层函数,不能依赖当前页面的 BuildContext,也不能直接导航。

FirebaseMessaging.onMessageOpenedApp

应用已经在后台,用户点击通知后触发。

FirebaseMessaging.instance.getInitialMessage()

应用被通知点击冷启动时读取初始消息。

后台处理函数示例:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 这里只做短小、可重复执行的工作:
  // - 解析消息
  // - 写入本地数据库
  // - 记录日志
  // - 触发有限的本地处理
  //
  // 不要在这里访问页面、BuildContext 或 Navigator。
  print('background message: ${message.messageId}');
}

@pragma('vm:entry-point') 用于避免发布构建中的树摇优化移除入口函数。后台处理函数还必须注册:

FirebaseMessaging.onBackgroundMessage(
  firebaseMessagingBackgroundHandler,
);

注册应尽可能早,通常放在 main() 中初始化 Firebase 后、runApp() 前。

后台处理有几个重要限制:

  • 不能保证每条消息都触发;
  • 不能假定有无限执行时间;
  • 不能把它当作持续运行的后台服务;
  • 不能直接操作 UI;
  • 如果要写本地数据库,必须考虑并发、重复执行和数据库初始化;
  • 处理失败后,不能依赖推送系统自动帮你补偿业务状态。

六、点击通知后的路由:消息到页面不是自动完成的

推送 payload 中可以携带路由信息:

{
  "data": {
    "route": "/orders/detail",
    "order_id": "A1001"
  }
}

但是推送 SDK 不会自动把 route 变成 Flutter 页面。应用必须自己完成:

  1. 读取消息;
  2. 校验字段;
  3. 将外部输入转换为内部允许的路由;
  4. 等待 MaterialApp 和导航器完成初始化;
  5. 执行导航;
  6. 页面打开后重新从服务端校验业务状态。

不要直接这样做:

navigatorKey.currentState!.pushNamed(message.data['route']);

问题包括:

  • currentState 可能还是 null
  • route 是外部输入,可能不是合法路由;
  • 重复回调可能导致重复入栈;
  • 用户尚未登录时,页面可能不应直接打开;
  • 订单、消息等资源可能已删除或无权限访问。

6.1 一个可运行的核心结构

下面示例展示初始化、前台消息、后台点击、冷启动和安全路由转换。它使用 firebase_corefirebase_messaging 以及 flutterfire configure 生成的 firebase_options.dart

import 'dart:async';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 这里不能导航,只能做后台数据处理。
  debugPrint('background: ${message.messageId}');
}

class PushCoordinator {
  String? _pendingRoute;
  final Set<String> _handledMessageIds = <String>{};

  void receive(RemoteMessage message) {
    final messageId = message.messageId;

    // messageId 可能为空。为空时不以它作为唯一去重依据。
    if (messageId != null && !_handledMessageIds.add(messageId)) {
      return;
    }

    final route = _routeFrom(message.data);
    if (route == null) {
      return;
    }

    final state = navigatorKey.currentState;
    if (state == null) {
      _pendingRoute = route;
      return;
    }

    state.pushNamed(route);
  }

  void flushPendingRoute() {
    final route = _pendingRoute;
    _pendingRoute = null;

    if (route != null && navigatorKey.currentState != null) {
      navigatorKey.currentState!.pushNamed(route);
    }
  }

  String? _routeFrom(Map<String, dynamic> data) {
    final type = data['type'];
    final id = data['id'];

    if (type == 'order' && id is String && id.isNotEmpty) {
      // 不允许 payload 任意指定页面,只根据受控字段生成路由。
      return '/orders/$id';
    }

    if (type == 'message' && id is String && id.isNotEmpty) {
      return '/messages/$id';
    }

    return null;
  }
}

final PushCoordinator pushCoordinator = PushCoordinator();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  FirebaseMessaging.onBackgroundMessage(_backgroundHandler);

  final messaging = FirebaseMessaging.instance;

  await messaging.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );

  final token = await messaging.getToken();
  debugPrint('FCM token: $token');

  messaging.onTokenRefresh.listen((newToken) {
    debugPrint('new FCM token: $newToken');
    // 上传到业务服务器。
  });

  FirebaseMessaging.onMessage.listen((message) {
    // 前台收到消息。
    // 此处可以更新应用内状态,或者调用本地通知插件展示系统通知。
    debugPrint('foreground: ${message.messageId}');
  });

  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    pushCoordinator.receive(message);
  });

  final initialMessage = await messaging.getInitialMessage();

  runApp(const PushExampleApp());

  // 等待首帧完成后处理冷启动点击。
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (initialMessage != null) {
      pushCoordinator.receive(initialMessage);
    }
    pushCoordinator.flushPendingRoute();
  });
}

class PushExampleApp extends StatelessWidget {
  const PushExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey,
      initialRoute: '/',
      onGenerateRoute: (settings) {
        final name = settings.name ?? '/';

        if (name == '/') {
          return MaterialPageRoute<void>(
            builder: (_) => const HomePage(),
          );
        }

        if (name.startsWith('/orders/')) {
          final id = name.substring('/orders/'.length);
          return MaterialPageRoute<void>(
            builder: (_) => OrderPage(orderId: id),
          );
        }

        if (name.startsWith('/messages/')) {
          final id = name.substring('/messages/'.length);
          return MaterialPageRoute<void>(
            builder: (_) => MessagePage(messageId: id),
          );
        }

        return MaterialPageRoute<void>(
          builder: (_) => const UnknownPage(),
        );
      },
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('首页')),
    );
  }
}

class OrderPage extends StatelessWidget {
  const OrderPage({required this.orderId, super.key});

  final String orderId;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('订单')),
      body: Center(child: Text('订单号:$orderId')),
    );
  }
}

class MessagePage extends StatelessWidget {
  const MessagePage({required this.messageId, super.key});

  final String messageId;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('消息:$messageId')),
    );
  }
}

class UnknownPage extends StatelessWidget {
  const UnknownPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('无法打开该页面')),
    );
  }
}

这个示例的关键点不是 pushNamed 本身,而是区分两个时间点:

  • onMessageOpenedApp:应用已有进程,用户从后台点击通知;
  • getInitialMessage():应用因点击通知而冷启动。

如果只监听 onMessageOpenedApp,冷启动场景会丢失路由。如果只读取 getInitialMessage(),后台恢复场景又会丢失路由。

6.2 路由数据必须经过业务校验

通知 payload 来自服务器,但服务器数据也可能过期、错误或被篡改。因此:

payload 中的 order_id
    -> 只作为候选 ID
    -> 页面请求服务端订单详情
    -> 服务端检查当前用户权限
    -> 成功后展示

不要把订单标题、金额、权限状态等关键数据完全信任于通知 payload。通知内容可能延迟到达,用户点击时业务状态已经改变。

6.3 去重和幂等

同一个业务通知可能因为以下原因出现多次处理:

  • 应用收到了前台消息,同时服务端又触发了本地刷新;
  • 用户重复点击;
  • 系统恢复过程再次传递 intent;
  • 网络重试产生重复发送;
  • 应用使用通知消息和本地通知时生成了两条视觉通知。

客户端可以按 messageId 做短期去重,但不能把它作为永久业务幂等依据。服务端应为业务事件设计稳定的 event_id,客户端打开详情页时也应允许重复请求而不产生错误副作用。


七、前台通知为什么经常“不弹”

当应用在前台时,系统通常认为用户已经能看到应用内容,因此不会像后台那样自动在通知栏展示通知。FlutterFire 的 onMessage 只是把消息交给应用。

前台场景可以选择:

  1. 只更新当前页面;
  2. 显示应用内 Banner、SnackBar 或对话框;
  3. 使用 flutter_local_notifications 等插件生成本地通知;
  4. 在 iOS 配置前台通知展示选项。

例如 iOS 可以设置:

await FirebaseMessaging.instance
    .setForegroundNotificationPresentationOptions(
  alert: true,
  badge: true,
  sound: true,
);

这只影响 iOS 前台展示策略,不会替代 Android 通知渠道,也不会自动解决点击路由。

如果使用本地通知插件,需要额外处理:

  • Android 通知渠道创建;
  • Android 13 通知权限;
  • iOS 本地通知权限;
  • 本地通知点击回调;
  • FCM 通知点击回调和本地通知点击回调的统一去重;
  • 前台消息和后台系统通知的重复展示。

因此,应用应把两种点击入口统一成一个业务函数:

FCM onMessageOpenedApp
本地通知 onDidReceiveNotificationResponse
冷启动初始消息
        ↓
解析并校验业务事件
        ↓
统一导航

八、发送端的基本结构

服务端应使用 FCM HTTP v1 或官方服务端 SDK。服务账号凭据只能保存在服务端,不能放入 Flutter 应用。

HTTP v1 消息的核心 JSON 可以是:

{
  "message": {
    "token": "注册到服务端的 FCM token",
    "notification": {
      "title": "订单更新",
      "body": "订单 A1001 已发货"
    },
    "data": {
      "type": "order",
      "id": "A1001",
      "event_id": "evt_20250101_0001"
    },
    "android": {
      "notification": {
        "channel_id": "orders",
        "click_action": "FLUTTER_NOTIFICATION_CLICK"
      },
      "ttl": "3600s"
    },
    "apns": {
      "payload": {
        "aps": {
          "sound": "default"
        }
      }
    }
  }
}

其中:

  • token 指目标安装实例;
  • notification 提供系统展示内容;
  • data 提供应用路由所需的业务字段;
  • ttl 是消息的存活时间,超过后消息可以被丢弃;
  • Android 的 channel_id 必须对应应用已经创建的通知渠道;
  • iOS 的 aps 字段由 APNs 识别,不能随意改成业务字段;
  • click_action 是否生效取决于平台配置和通知生成方式,不能只靠 Flutter 代码保证。

消息发送成功通常只代表 FCM 接受了请求,不代表设备已经收到,更不代表用户看到了通知。


九、送达率到底测量什么

“送达率”必须先定义分母和事件,否则这个指标没有明确含义。

可以定义四个数量:

  • SS:服务端提交并通过基本校验的消息数;
  • AA:推送服务接受的消息数;
  • RR:设备或客户端确认收到的消息数;
  • DD:操作系统展示通知的数量;
  • OO:用户点击打开的数量。

对应指标可能是:

接受率=AS\text{接受率} = \frac{A}{S}

客户端接收率=RA\text{客户端接收率} = \frac{R}{A}

展示率=DA\text{展示率} = \frac{D}{A}

点击率=OD\text{点击率} = \frac{O}{D}

例如某天:

提交 S = 100000
FCM 接受 A = 99000
客户端收到 R = 90000
系统展示 D = 72000
点击 O = 10800

则:

接受率       = 99000 / 100000 = 99%
客户端接收率 = 90000 / 99000  ≈ 90.91%
展示率       = 72000 / 99000  ≈ 72.73%
点击率       = 10800 / 72000  = 15%

这四个指标反映不同问题:

  • 接受率低:服务端鉴权、token、请求格式或限流有问题;
  • 接收率低:设备离线、token 失效、系统策略、TTL 或平台投递问题;
  • 展示率低:权限、渠道、前台策略、通知被折叠或应用自行过滤;
  • 点击率低:内容、时机、用户意图或展示位置问题。

9.1 “客户端收到”不能简单等于“后台回调触发”

Android、iOS 和 Web 的可观测性不同:

  • 系统在后台直接展示通知时,Flutter 代码可能没有立即运行;
  • iOS 后台执行可能被系统延迟或跳过;
  • 应用被强制停止时,后台行为会受到更严格限制;
  • Web 的 Service Worker 可能接收了消息,但页面没有打开;
  • 用户点击通知后,才有机会由应用上报打开事件。

因此不要只用 Flutter 日志中的 onMessage 数量计算总送达率。生产系统通常需要:

  1. 服务端发送日志;
  2. 推送服务响应日志;
  3. 客户端收到后上报 received
  4. 应用展示或点击后上报 displayed / opened
  5. 通过 event_id 去重;
  6. 按平台、版本、地区、权限状态和网络环境分组。

9.2 影响送达率的系统因素

离线和 TTL

设备离线时,推送服务可能暂存消息。TTL 到期后,过期消息不会继续投递。

例如订单状态通知的 TTL 可以较短,因为两小时后“已发货”可能仍有意义,但“正在支付”可能已经失效。聊天消息则通常不应只依赖短 TTL,而应以服务端消息列表为准。

Android Doze 和厂商后台策略

低优先级消息可能延迟,后台限制严格的厂商系统可能冻结应用或限制网络。高优先级不是“必达”开关,滥用还可能影响系统调度和平台配额。

iOS 后台限制

静默推送适合触发轻量数据刷新,不适合作为可靠任务执行器。系统会综合电量、用户行为、应用活跃度和后台资源决定是否及时唤醒应用。

通知权限和渠道

设备收到消息不等于系统展示通知。用户关闭通知权限、关闭某个 Android channel、关闭声音或开启专注模式,都可能造成“服务端成功、设备可能收到、用户没有看见”的结果。

折叠和覆盖

对于状态刷新类通知,服务端可能配置折叠键,使多条旧消息只保留最新状态。这会减少通知噪音,但也意味着不能把每一条发送记录都期待为一条独立的用户可见通知。


十、常见失败路径和诊断顺序

10.1 完全收不到

按以下顺序定位:

  1. Flutter 是否成功初始化 Firebase;
  2. getToken() 是否返回 token;
  3. token 是否正确上传到服务端;
  4. 服务端发送时使用的项目是否与应用配置一致;
  5. FCM HTTP v1 凭据是否属于正确项目;
  6. APNs key、bundle ID、签名环境是否匹配;
  7. Android 包名和 Firebase 注册应用是否匹配;
  8. 用户是否拒绝通知权限;
  9. Android 目标 channel 是否存在且未被关闭;
  10. 设备是否处于离线、Doze、强制停止或厂商限制状态。

要记录的最小日志包括:

installation_id
platform
app_version
permission_status
token_hash
message_id
event_id
send_request_id
send_result
received_at
opened_at

生产环境不应把完整 token 明文写入普通业务日志。可以保存受控字段或哈希,用于关联和排障。

10.2 前台有回调,但通知栏没有通知

这通常不是“推送失败”,而是前台展示策略未实现。检查:

  • 是否在 onMessage 中只打印了日志;
  • 是否使用本地通知插件;
  • Android channel 是否创建;
  • iOS 是否设置前台展示选项;
  • 是否因为业务去重逻辑主动过滤。

10.3 后台能看到通知,但点击不跳页面

检查:

  • 是否监听 onMessageOpenedApp
  • 是否调用 getInitialMessage()
  • payload 是否真的包含 data
  • 路由数据类型是否正确,特别是所有值通常都应按字符串处理;
  • NavigatorState 是否已建立;
  • 是否在登录、初始化或权限检查完成前错误地丢弃了路由;
  • 系统通知和本地通知是否使用了不同的点击回调。

10.4 Android 有通知,iOS 没有

优先检查 APNs,而不是 Flutter 路由代码:

  • Apple Developer 中是否启用 Push Notifications;
  • Xcode capability 是否启用;
  • provisioning profile 是否包含推送能力;
  • FCM 项目是否配置 APNs authentication key;
  • bundle identifier 是否完全一致;
  • 使用的是开发还是生产环境;
  • iOS 通知权限是否为 authorizedprovisional
  • 设备是否支持当前推送配置。

10.5 token 上传正常,但一段时间后失效

这通常是正常生命周期的一部分,不应假定 token 永久有效。服务端应:

  • 每次启动重新确认 token;
  • 监听刷新事件;
  • 对永久无效错误停用 token;
  • 允许同一用户有多台设备;
  • 处理用户退出登录和重新登录;
  • 发送前过滤已失效 token。

十一、不同平台的边界

Android

Android 与 FlutterFire 集成通常较直接,但通知行为强依赖:

  • Android API 级别;
  • POST_NOTIFICATIONS
  • 通知 channel;
  • Doze;
  • 厂商后台策略;
  • 应用是否被强制停止。

Android 前台通知通常需要应用自行展示;后台通知可能由系统展示。

iOS

iOS 推送依赖 APNs。FCM 只是消息编排和 token 管理的一层。需要同时正确配置:

  • Apple Developer;
  • Xcode capability;
  • 签名和 entitlements;
  • APNs 凭据;
  • Firebase iOS 应用;
  • 用户授权。

iOS 对后台静默执行的限制比“收到一个数据包就运行任意 Dart 代码”严格得多。

Web

Web 依赖:

  • HTTPS;
  • 浏览器 Push API;
  • Service Worker;
  • 通知权限;
  • VAPID;
  • 浏览器实现差异。

应用页面关闭后,Flutter 页面代码不存在,Service Worker 才是接收后台推送的执行入口。点击通知时需要通过 Service Worker 的 notificationclick 与页面通信或打开 URL。

Windows、Linux 和 macOS

Flutter 没有一个对所有桌面系统统一的内建推送抽象。Windows 常见方案是 Windows Toast、WNS 或第三方服务;Linux 可能需要桌面环境通知机制和自定义常驻进程;macOS 可以使用 Apple 的推送能力,但需要核对当前 Flutter 插件、原生 SDK、签名和 entitlements 的支持范围。

如果产品必须同时覆盖移动端、Web 和桌面端,应先建立平台能力矩阵,而不是假定 firebase_messaging 在所有平台上提供相同 API 和语义。


十二、生产设计中必须区分通知和业务状态

推送适合做“提醒”和“唤醒”,不适合作为唯一数据源。

例如订单状态变更时:

服务端修改订单状态
    ↓
服务端持久化订单状态和 event_id
    ↓
异步发送推送
    ↓
客户端收到或用户点击
    ↓
客户端按 order_id 拉取最新订单
    ↓
页面展示服务端当前状态

如果客户端没有收到推送,用户打开订单列表时仍应通过普通 API 看到最新状态。这样即使推送延迟、丢失、过期或权限被拒绝,业务仍然正确。

通知 payload 可以携带:

{
  "type": "order",
  "id": "A1001",
  "event_id": "evt_001"
}

但不应把它当成完整业务事实。event_id 用于关联和去重,id 用于查询资源,最终权限和内容由服务端决定。


十三、一个可靠的心智模型

可以用下面的条件表达“用户最终看到并打开通知”:

P(opened)=P(accepted)×P(deliveredaccepted)×P(displayeddelivered)×P(openeddisplayed)P(\text{opened}) = P(\text{accepted}) \times P(\text{delivered} \mid \text{accepted}) \times P(\text{displayed} \mid \text{delivered}) \times P(\text{opened} \mid \text{displayed})

其中:

  • accepted 受服务端请求、凭据和 token 影响;
  • delivered 受网络、TTL、设备状态和平台调度影响;
  • displayed 受权限、通知渠道、前后台状态和系统策略影响;
  • opened 受内容和用户行为影响。

这个分解说明了一个常见误区:

不能通过增加发送次数,简单地把“送达率”提升为可靠性。

重复发送可能造成:

  • 重复通知;
  • 用户关闭通知;
  • 服务端限流;
  • 业务状态过期;
  • 点击归因混乱。

更可靠的设计是:

  • token 可刷新、可失效;
  • 消息可重复、业务处理必须幂等;
  • 通知可丢失、业务状态必须可重新拉取;
  • 前台、后台和冷启动分别处理;
  • 点击路由经过校验;
  • 权限、展示、接收和点击分别埋点;
  • 按平台差异验证,而不是只在一台 Android 测试机上验证。

当这些边界被明确后,Flutter 推送就不再是“调用一个 API 弹出通知”,而是一个可观测的分布式投递流程:Token 负责寻址,平台服务负责尽力投递,操作系统负责展示策略,Flutter 负责应用内处理和路由,业务服务器负责最终事实与补偿。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。