Flutter中Appwrite+FCM重复通知问题排查求助
问题描述
我使用由cron job触发的Appwrite函数,通过FCM调度每日通知。通知发送时间正确,但同一设备偶尔会收到重复通知。
已排查项
- 调度逻辑:调度函数多次调用时会更新现有通知。
- 消息ID:每个消息ID针对目标(设备)和日期的组合是唯一的。
- Appwrite控制台:每个设备仅有一个通知目标,且每个目标仅被纳入一个通知调度。
- 应用端代码:应用处理后台通知的逻辑不会导致重复。
示例截图

代码片段
调度器Appwrite函数
Future<ResponseModel> setRemindersForUser( context, User user, Messaging messaging) async { context.log('processing user ${user.name}'); // Retrieve user's timezone offset final timezoneOffset = user.prefs.data['timezone']; if (timezoneOffset != null) { context.log('retrieved timezone offset: $timezoneOffset'); // Parse timezone offset context.log('Original timezone offset: $timezoneOffset'); final isOffsetNegative = timezoneOffset.startsWith('-'); final pureOffsetDuration = timezoneOffset.replaceAll('-', '').split('.').first; context.log('Pure offset duration: $pureOffsetDuration'); final offsetHour = int.parse(pureOffsetDuration.split(':')[0]); final offsetMin = int.parse(pureOffsetDuration.split(':')[1]); final offsetSec = int.parse(pureOffsetDuration.split(':')[2]); final offsetDuration = Duration( hours: offsetHour, minutes: offsetMin, seconds: offsetSec, ); context.log('Offset duration: $offsetDuration'); // Calculate next 9 PM in user's local time final now = DateTime.now().toUtc(); context.log('Current UTC time: $now'); final userTime = isOffsetNegative ? now.subtract(offsetDuration) : now.add(offsetDuration); context.log('Current user time: $userTime'); DateTime next9PM = DateTime(userTime.year, userTime.month, userTime.day, 21); if (userTime.isAfter(next9PM)) { next9PM = next9PM.add(Duration(days: 1)); } context.log('Next 9 PM user time: $next9PM'); // Convert next9PM to UTC final next9PMUtc = isOffsetNegative ? next9PM.add(offsetDuration) : next9PM.subtract(offsetDuration); final messageId = _generateMessageId(user, next9PMUtc); late Function( {required String messageId, required String title, required String body, List<String>? topics, List<String>? users, List<String>? targets, Map? data, String? action, String? image, String? icon, String? sound, String? color, String? tag, bool? draft, String? scheduledAt}) scheduler; try { context.log('check existing message'); // update if message already scheduled await messaging.getMessage(messageId: messageId); // no error thrown means message exists scheduler = messaging.updatePush; context.log('found existing message'); } catch (e) { // create if message not scheduled context.log('no existing message found'); scheduler = messaging.createPush; context.log('should create new message'); context.log(e.toString()); } // Schedule push notification context.log('scheduling push notification'); final userPushTargets = user.targets .where((element) => element.providerType == "push") .toList(); context.log('user push targets: ${jsonEncode(userPushTargets.map( (e) => e.toMap(), ).toList())}'); try { final content = dynamicNotifications.random; final result = await scheduler( messageId: messageId, title: content.$1, body: content.$2, scheduledAt: next9PMUtc.toIso8601String(), targets: userPushTargets .map( (e) => e.$id, ) .toList(), ); context .log('scheduled push notification!: $messageId - ${result.toMap()}'); return ResponseModel.success(null); } catch (e) { context.log(e.toString()); return ResponseModel.failed(message: e.toString()); } } return ResponseModel.failed(message: 'Timezone offset not found'); }
Flutter应用通知处理代码
static Future<bool> initialize() async { try { final result = await FirebaseMessaging.instance.requestPermission(); if (Platform.isAndroid) { await localNotifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() ?.createNotificationChannel(reminderChannel); } await localNotifications.initialize( const InitializationSettings( android: AndroidInitializationSettings('@mipmap/ic_launcher'), iOS: DarwinInitializationSettings(), ), ); _addOnReceiveMessageListener(); _addTokenRefreshListener(); return result.authorizationStatus == AuthorizationStatus.authorized; } catch (e) { log(e.toString()); } return false; } static void _addOnReceiveMessageListener() { FirebaseMessaging.onMessage.listen((message) { if (message.notification != null) { _showLocalNotification( title: message.notification!.title ?? '', message: message.notification!.body ?? '', data: jsonEncode(message.data)); } }); } static void _showLocalNotification( {required String title, required String message, String? data}) { FlutterLocalNotificationsPlugin() .show(generateTimeBasedId(), title, message, _details, payload: data) .then((_) {}); } static void _addTokenRefreshListener() { FirebaseMessaging.instance.onTokenRefresh.listen((token) async { // updating fcm token on appwrite }); }
可能的原因与解决建议
1. Cron任务并发竞态问题
如果cron触发间隔过短,或者函数执行时间超过间隔,会出现多个实例同时处理同一用户的情况。两个实例可能都检测到消息不存在,进而重复创建调度。
解决:
- 拉长cron间隔,确保大于函数最大执行时间,避免并发。
- 加分布式锁:用Appwrite数据库事务,在处理前创建用户ID+日期的锁记录,只有拿到锁的实例才能继续执行。
2. Appwrite消息检查/更新逻辑漏洞
getMessage调用可能因网络波动等原因误判消息不存在,导致重复创建;另外updatePush可能未完全覆盖原有调度。
解决:
- 改用查询接口:通过用户ID、目标ID、调度时间范围查询已有调度,而非仅依赖
messageId的getMessage。 - 验证更新结果:调用
updatePush后,再次查询消息状态,确认原有调度已被覆盖。 - 检查函数日志,看是否存在
getMessage报错但实际消息已存在的情况,针对性处理异常。
3. FCM重复投递
FCM可能因网络重试、设备离线重连重复投递。需确认Appwrite是否将自定义messageId传递给FCM作为幂等标识。
解决:
- 确认Appwrite的
messageId是否映射为FCM的message_id,如果没有,在data字段传递自定义唯一ID,应用端接收时先检查本地存储的已接收ID,避免重复展示。 - 把
_showLocalNotification里的generateTimeBasedId()替换为FCM消息中的自定义唯一ID,这样重复投递的通知会因ID相同被覆盖。
4.时区计算边界错误
手动解析时区偏移量可能在夏令时切换等场景出错,导致next9PMUtc偏差,生成不同messageId进而重复调度。
解决:
- 使用
timezone等专业时区库处理转换,避免手动解析偏移量。 - 生成
messageId时用用户本地日期而非UTC日期,确保同一用户同一本地日期只有一个消息ID。
5.用户目标隐性重复
设备卸载重装后旧令牌未清理,可能导致同一设备对应多个推送目标。
解决:
- 更新FCM令牌时,删除旧令牌对应的目标记录,确保每个设备只有一个有效目标。
- 调度前过滤无效/过期目标,比如清理创建时间超过30天的旧目标。
内容的提问来源于stack exchange,提问作者Morteza Mohammadi
相关产品推荐
相关产品推荐

