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 范围要求的通知编号。这个问题涉及通知标识,不能单凭它认定回调没有触发。
备注:内容仅供参考。
