PAYATHON 2026

Flutter FCM 前台通知的 onTap callback 为何不触发,后台却正常?

排障赵师傅

前台手动显示的本地通知,点击后由 flutter_local_notifications 的 onDidReceiveNotificationResponse 处理。后台由 FCM 显示的通知,点击后通过 FirebaseMessaging.onMessageOpenedApp 或 getInitialMessage() 处理。两条点击链路相互独立,后台通知点击正常,不能说明本地通知的点击配置也正常。

你注册的本地通知回调是正确的。现有代码需要调整初始化顺序,修正路由数据,并处理导航尚未就绪的情况。不过,仅凭这些代码,还无法确定“回调没有触发”的具体原因。

先区分回调未进入和路由未执行

当前回调只在 payload != null 时处理数据。进入路由后,下面两处也可能让处理结束而不跳转:

if (context == null) return;

以及没有匹配分支的 switch:

switch (type) {
  case 'POS_ACCESS_REQUEST':
    // ...
  case 'TASK':
    // ...
}

示例 payload 中的 type 是 "chat",当前路由没有对应分支,所以即使点击回调执行了,也不会跳转。

先在回调入口添加日志,放在空值检查、JSON 解析和路由调用之前:

static void _onNotificationResponse(NotificationResponse response) {
  debugPrint(
    'Local notification tapped: '
    'id=${response.id}, payload=${response.payload}',
  );

  final payload = response.payload;
  if (payload == null || payload.isEmpty) {
    debugPrint('Local notification payload is empty');
    return;
  }

  try {
    final decoded = jsonDecode(payload);

    if (decoded is! Map<String, dynamic>) {
      debugPrint('Local notification payload must be a JSON object');
      return;
    }

    NotificationRouter.handle(decoded);
  } catch (error, stackTrace) {
    debugPrint('Failed to handle local notification: $error');
    debugPrintStack(stackTrace: stackTrace);
  }
}

需要补充导入:

import 'package:flutter/foundation.dart';

入口日志有输出,就继续检查 payload 和路由;没有输出,再集中检查插件初始化和原生点击事件的传递。

按依赖顺序初始化通知服务

原代码把这两个操作放进了 Future.wait:

LocalNotificationHelper.init(),
NotificationService.init()

Future.wait 不保证前者完成后才执行后者,因此注册 onMessage 时,本地通知插件可能尚未初始化完成。这个时序风险需要消除,但还不能据此认定它就是点击失败的原因。

通知服务应按以下顺序初始化:

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

  if (Platform.isIOS || Platform.isAndroid) {
    await SystemChrome.setPreferredOrientations([
      DeviceOrientation.portraitUp,
      DeviceOrientation.portraitDown,
    ]);
  }

  // 这里假设该方法会完成 Firebase 初始化。
  await FirebaseIntegration().invoke();

  // 先注册本地通知点击回调,再监听 FCM 消息。
  await LocalNotificationHelper.init();
  await NotificationService.init();

  runApp(AppRoot());

  WidgetsBinding.instance.addPostFrameCallback((_) {
    NotificationRouter.markReady();
  });
}

Crashlytics、版本检查、定位权限等操作可以按各自的依赖安排,不必与通知初始化放在同一个 Future.wait 中。

在首帧之后调用 markReady(),前提是此时已经创建了用于导航的 Navigator。如果应用还要等待登录恢复、启动页结束或路由加载,就应等这些步骤完成后再调用。

保留本地点击回调,并补齐本地通知冷启动处理

本地通知初始化可以这样调整,_createChannels() 和 showNotification() 暂时保留原实现。

示例沿用题目中的 initialize(settings, ...) 调用形式。如果项目锁定版本的 API 签名不同,需要按该版本调整参数写法。

static Future<void> init() async {
  const settings = InitializationSettings(
    android: AndroidInitializationSettings('@mipmap/retiq'),
    iOS: DarwinInitializationSettings(),
  );

  await _plugin.initialize(
    settings,
    onDidReceiveNotificationResponse: _onNotificationResponse,
  );

  await _createChannels();

  // 用户点击此前显示的本地通知,启动了已经终止的应用。
  final launchDetails = await _plugin.getNotificationAppLaunchDetails();

  if (launchDetails?.didNotificationLaunchApp ?? false) {
    final response = launchDetails?.notificationResponse;
    if (response != null) {
      _onNotificationResponse(response);
    }
  }
}

不同通知来源和应用状态,对应的点击处理入口如下:

通知来源与点击情形处理入口
FCM 通知被点击,应用从后台恢复FirebaseMessaging.onMessageOpenedApp
FCM 通知被点击,应用从终止状态启动FirebaseMessaging.instance.getInitialMessage()
本地通知被点击,应用进程仍在运行onDidReceiveNotificationResponse
本地通知被点击,应用从终止状态启动getNotificationAppLaunchDetails()

原有 NotificationService 中的 FCM 点击监听仍需保留。本地通知的冷启动处理覆盖的是另一种情况:前台收到消息并显示本地通知,随后应用被终止,用户再点击这条通知。

点击普通通知主体并跳转页面,通常不需要添加 onDidReceiveBackgroundNotificationResponse。该回调主要处理需要在后台执行的通知操作,不能作为修复前台点击的通用开关,也不应在后台 isolate 中直接执行页面导航。

等待 Navigator 就绪,避免静默丢弃点击

原代码在 currentContext == null 时直接返回,会丢弃这次通知点击。启动阶段尤其容易遇到这种情况,因为通知数据可能先于页面导航准备好。

可以统一使用绑定到 MaterialApp 的 navigatorKey.currentState,并在导航就绪前暂存点击数据:

class NotificationRouter {
  static final navigatorKey = GlobalKey<NavigatorState>();

  static final List<Map<String, dynamic>> _pending = [];
  static bool _ready = false;

  static void handle(Map<String, dynamic> data) {
    _pending.add(Map<String, dynamic>.from(data));
    _flush();
  }

  static void markReady() {
    _ready = true;
    _flush();
  }

  static void _flush() {
    if (!_ready) return;

    final navigator = navigatorKey.currentState;
    if (navigator == null) {
      debugPrint('Navigator is not ready; notification retained');
      return;
    }

    while (_pending.isNotEmpty) {
      final data = _pending.removeAt(0);

      switch (data['type']) {
        case 'POS_ACCESS_REQUEST':
          navigator.pushNamed(RoutePage.accessRequest);
          break;

        case 'TASK':
          final taskId = data['task_id'];
          final taskableId = data['taskable_id'];

          if (taskId == null || taskableId == null) {
            debugPrint('Missing task_id or taskable_id');
            break;
          }

          navigator.push(
            MaterialPageRoute<void>(
              builder: (_) => OpnFormScreen(
                taskableId: taskableId,
                taskId: taskId,
              ),
            ),
          );
          break;

        default:
          debugPrint(
            'Unsupported notification type: ${data['type']}',
          );
      }
    }
  }
}

在 AppRoot 中,将同一个 key 绑定到实际承载这些页面的 MaterialApp,保留项目已有的路由配置:

MaterialApp(
  navigatorKey: NotificationRouter.navigatorKey,
  // 保留已有的 home、routes、onGenerateRoute 等配置。
)

如果项目已有 GlobalContext.instance.navigatorKey,可以继续使用,但路由处理器与 MaterialApp 必须共用同一个 key,不能分别创建。

首次调用 markReady() 时,如果 Navigator 尚未创建,数据会留在队列中,需要等导航真正就绪后再次调用。业务页面要求登录时,也应将登录状态作为就绪条件。

修正 payload 中的类型和字段名

题目中的 JSON 缺少引号,字段 taks_id 也与代码读取的 task_id 不一致。要打开 TASK 页面,可以使用:

{
  "message": {
    "token": "DEVICE_TOKEN",
    "notification": {
      "title": "Test Title",
      "body": "Test Body"
    },
    "data": {
      "type": "TASK",
      "task_id": "123",
      "taskable_id": "123"
    }
  }
}

如果业务发送的确实是 "type": "chat",就需要实现对应的 case 'chat'。字段类型也应与 OpnFormScreen 的构造参数一致;页面需要整数时,先校验再转换。

这些数据问题会导致路由不执行或参数异常,但无法解释回调入口日志完全没有输出的情况。

入口日志仍未输出时,继续检查什么

完成上述调整后,先绕开 FCM,直接调用 LocalNotificationHelper.showNotification() 显示一条测试通知,再点击通知主体,单独验证本地通知的点击链路。

如果仍然没有入口日志,继续检查:

  • LocalNotificationHelper.init() 是否成功完成,初始化过程中是否出现异常。
  • 是否有其他代码再次调用 initialize(),替换了已注册的回调。
  • Android 项目自定义的 MainActivity、onNewIntent 或启动配置,是否影响插件接收通知点击。
  • iOS 项目自定义的 UNUserNotificationCenterDelegate,是否与通知插件的代理处理冲突。

题目未提供包版本和原生配置,还无法判断是否需要修改某个 launchMode 或添加原生代码。

iOS 还需要确认:是否既启用了 FCM 的前台系统展示,又在 onMessage 中显示本地通知。两种机制同时展示时,可能出现重复通知,难以判断实际点击的是哪一条。

另外,DateTime.now().microsecond 只取时间中的微秒分量,可能重复并覆盖已有通知。应使用符合插件 ID 范围要求的通知编号。这个问题涉及通知标识,不能单凭它认定回调没有触发。

备注:内容仅供参考。