mcpField
L
local-notifications-scheduler

local-notifications-scheduler

github.com/zakariaf开发63149 次安装5 周前更新
代码仓库

关于 local-notifications-scheduler

这项技能指导在 Flutter 应用中构建本地通知调度器,以数据库为唯一事实来源,并通过统一流程协调系统通知。重点涵盖纯函数调度逻辑、重复提醒、时区处理和平台限制。

主要功能

  1. 01数据库作为唯一事实来源
  2. 02统一通知协调入口
  3. 03注入时钟的纯调度逻辑
  4. 04支持夏令时的重复提醒
  5. 05平台限制与调度降级

适用场景

  1. 01设计本地提醒引擎
  2. 02在设备外测试重复与调度逻辑
  3. 03导入或重启后恢复通知

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill local-notifications-scheduler

安装后在 Claude Code 中输入/local-notifications-scheduler确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
fake_notification_gateway.dart
notification_gateway.dart
sync_notifications.dart
references/
notification-gateway-port.md
platform-boot-exact-alarms-oem.md
scheduler-and-recurrence-rules.md
scripts/
check-adhoc-schedule-calls.sh
check-manifest-permissions.sh
check-scheduler-purity.sh
check-single-fln-import.sh

详细介绍

本地通知调度器

一个由中央统一管理、API 精简的设备端提醒引擎。本地数据库是事实来源;操作系统的待处理通知集合只是可丢弃的缓存。每次调度变更都会经过统一的「先纯计算、后协调」流程,因此整个引擎都可以在设备外进行验证。凡是调度、取消或处理本地通知,均适用此规则。

请根据当前任务阅读相应参考文档:

  • references/notification-gateway-port.md — 接口契约、两个适配器、通知渠道/分组/深层链接负载规则。
  • references/scheduler-and-recurrence-rules.md — 纯函数 ReminderScheduler.compute 差异计算、重复规则/夏令时计算、确定性 ID、预算限制和完整测试矩阵。
  • references/platform-boot-exact-alarms-oem.md — 启动时重新布防、精确闹钟降级、后台隔离区安全性、Android 清单/Gradle 和 OEM 存活能力矩阵。

提交 PR 前,请运行 scripts/check-single-fln-import.sh、scripts/check-scheduler-purity.sh、scripts/check-adhoc-schedule-calls.sh 和 scripts/check-manifest-permissions.sh。

不可妥协的规则

  1. 本地数据库是唯一的事实来源。 OS 的待处理通知集只是可丢弃的缓存,需要与数据库对账,绝不能作为权威记录。进程终止、重启、Doze 或恢复后,每条提醒都必须能仅凭数据库重新构建。
  2. 所有调度变更都必须通过同一个 syncNotifications() 对账入口。 绝不要在功能或 UI 代码中临时调用 gateway.schedule()/cancel()——这条路径无法实现幂等,会导致缓存不同步。
  3. 只能在一个文件中导入 flutter_local_notifications——即 FLN 适配器。包括所有测试在内的其他代码都必须通过 NotificationGateway 端口交互。任何其他导入都会触发 grep 门禁,导致构建失败。
  4. 所有调度计算都必须放在纯粹、无副作用的类中。 其中不得调用插件、执行 IO 或使用 DateTime.now()——应从 package:clock 注入 Clock:Riverpod 代码从 clockProvider 读取,纯计算则将其作为参数(绝不使用 DateTime.now(),也绝不自定义 ClockService)。保持纯粹,才能通过 FakeNotificationGateway 在设备外进行单元测试。
  5. 重复日程应以墙上时钟时间 + 重复规则存储,仅在调度时解析为 TZDateTime。 绝不要将重复日程持久化为 UTC 时间点——每次跨越 DST 边界都会产生一小时的偏差。真正的一次性时间点应保持为 UTC epoch 毫秒。
  6. 在启动时设置 tz.local(通过 flutter_timezone)。timezone 包默认使用 UTC——如果忘记设置,每个 zonedSchedule 都会在错误的本地时间触发。
  7. 默认使用 AndroidScheduleMode.inexactAllowWhileIdle(无需权限,可穿透 Doze)。通过用户可撤销的 SCHEDULE_EXACT_ALARM 和 canScheduleExactAlarms() 控制精确触发,并在不可用时静默回退为非精确触发。绝不要声明 USE_EXACT_ALARM——Play 政策仅允许闹钟/计时器/日历应用使用此权限,否则可能导致应用被商店拒绝。
  8. 将 iOS 待处理通知数量控制在约 50 条(为静默的 64 条上限留出余量)。将未来时间点按升序排列,取最近的约 50 条,并在每次回到前台时补充。第 65 条及之后的通知会被静默丢弃,不会报错。
  9. 使用确定性 ID,由 reminderId + occurrenceIndex 以及已解析的触发时间和通知内容的哈希值生成。getPending() 只会公开 id,不会公开 when——因此,如果 ID 未包含触发时间,那么即使提醒的重复序号不变,编辑提醒时(一次性提醒从 09:00 改为 14:00,或每日规则的时间发生变化),也会继续使用相同 ID,导致取消和调度循环都跳过它,并在原时间触发。将解析后的 TZDateTime(以及内容)纳入 ID,编辑后就会生成新 ID——旧 ID 会被取消,新 ID 会被调度。输入未变时仍会映射到相同 ID,因此对账不会产生任何操作。
  10. 应用回到前台时的对账是可靠性的基石。 启动接收器、后台计时、精确闹钟开关和 OEM 兼容性都明确属于尽力而为。没有服务器推送时,发现提醒被丢弃的唯一方式就是下次应用回到前台时进行差异对账——这正是采用此架构的原因,而非需要掩盖的缺陷。

包/文件夹布局

此引擎属于共享基础设施,而非某个功能——纯计算位于 core/(不依赖 Flutter,可在设备外测试),副作用端口、在线实现和对账逻辑位于 services/。此目录结构由 project-structure-and-packages 负责管理。

lib/
  core/
    notifications/                   # PURE — no Flutter, off-device testable
      reminder_scheduler.dart        # compute(desired) -> reconcile diff
      recurrence_rule.dart           # wall-clock + rule -> TZDateTime
      scheduled_notification.dart    # value objects (ScheduledNotification, PendingNotification)
      deterministic_id.dart          # reminderId + occurrence + resolved instant/content -> id
  services/
    notifications/                   # the side-effect port, live adapter, and reconcile
      notification_gateway.dart      # abstract PORT: schedule/cancel/cancelAll/getPending
      fln_notification_gateway.dart  # ONLY file importing flutter_local_notifications
      fake_notification_gateway.dart # in-memory list, for tests
      sync_notifications.dart        # the single reconcile entrypoint
      boot_rearm_android.dart        # BOOT_COMPLETED post-unlock re-arm

使用多包(工作区)时: 将纯计算和网关提升为 packages/notifications 包(其中 lib/src/ + 一个公共 barrel 是认可的约定),避免向其提供数据的约 N 个功能各自实现、产生分歧。在单包应用中,拆分为 core/ + services/ 即可。参见 project-structure-and-packages。

通过 Riverpod providers 构建并注入依赖——绝不要使用全局单例或每个功能各自的实例。Clock 来自 clockProvider(在测试中使用 Clock.fixed(...));端口 provider 使用“覆盖前抛出异常”的模式,参见 service-boundary-and-native。

规范的协调(核心)

compute 是纯函数;网关差异是唯一的 IO。这是所有调度变更都会经过的唯一入口。

Future<void> syncNotifications(
  NotificationGateway gw,
  Clock clock,
  ReminderRepository repo,
) async {
  final desired = ReminderScheduler.compute(     // PURE — no IO, injected clock
    reminders: await repo.activeReminders(),      // from the local DB (source of truth)
    now: clock.now(),
    budget: 50,                                   // headroom under the silent iOS 64-cap
  );                                              // sorts future instants ascending,
                                                  // takes the nearest ~50 as a rolling window
  final current = await gw.getPending();
  final desiredIds = {for (final d in desired) d.id};
  final currentIds = {for (final c in current) c.id};

  for (final c in current) {                      // cancel stale
    if (!desiredIds.contains(c.id)) await gw.cancel(c.id);
  }
  for (final d in desired) {                       // schedule new / changed
    if (!currentIds.contains(d.id)) await gw.schedule(d);
  }
}

在以下场景调用 syncNotifications():**应用进入前台/恢复运行、提醒的增删改、备份导入/恢复、获得精确闹钟权限、Android 启动,以及每日后台定时任务。**带注释的版本(包括恢复后的处理路径)请参见 examples/sync_notifications.dart。

网关接口

该接口隔离插件,让所有调度计算和测试都能在设备之外进行。

abstract class NotificationGateway {
  Future<void> schedule(ScheduledNotification n);
  Future<void> cancel(int id);
  Future<void> cancelAll();
  Future<List<PendingNotification>> getPending();
}

接口恰好保留这四项操作——更多操作意味着逻辑应归入纯函数 ReminderScheduler。具体实现 FlnNotificationGateway 是唯一导入 flutter_local_notifications、timezone 和 flutter_timezone 的文件;FakeNotificationGateway 则是一个内存中的 List。通过 Riverpod provider 接入具体实现,并在 ProviderContainer 中让测试用 fake 覆盖它。完整契约和 ScheduledNotification 值对象请参见 references/notification-gateway-port.md 和 examples/notification_gateway.dart。

重复规则与夏令时

在安排时间时,将墙上时钟时间和规则解析为 tz.local 中的 TZDateTime——从设计上确保夏令时正确。

// Wall-clock 9:00 daily -> concrete next fire instant, DST-safe.
tz.TZDateTime nextDailyAt(int hour, int minute, {required Clock clock}) {
  final now = tz.TZDateTime.from(clock.now(), tz.local);
  var next = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
  if (!next.isAfter(now)) next = next.add(const Duration(days: 1));
  return next;
}

真正按日历重复的事项可以使用一条重复通知(matchDateTimeComponents),仅占用一个名额;其他事项则在每次协调时重新计算为一次性通知。**标记完成后重新锚定:**从实际完成时间而非预定时间计算下一次发生时间,避免重复提醒逐渐偏移。切勿将“上午 9 点”存储为 UTC 时刻。

有些应用会根据非时间触发条件推算提醒时间(例如“完成 N 次后提醒”)。请将此类推算放在一个注入 Clock 的纯函数辅助程序中,在交给调度器之前解析为明确的未来时刻,这样调度器仍只需处理同一种时刻列表。对重新推算进行防抖,避免连续输入引发频繁重新调度。

后台隔离环境与点击处理

后台点击/操作处理程序需要 @pragma('vm:entry-point'),并在没有主隔离环境状态的独立隔离环境中运行。不要在其中写入数据库——记录轻量级的“待处理操作”意图(或不做任何处理),让下一次前台协调完成实际工作,从而避免两个隔离环境并发访问数据库。点击操作应将可序列化负载映射为从数据库重建的 go_router 路径——切勿使用不可序列化的 extra。关于 await 后的 mounted/上下文检查,请参见 async-safety 和 references/platform-boot-exact-alarms-oem.md。

本地化与备份

通知标题和正文通过 gen-l10n 本地化,使用 ICU 复数规则和数字规范化;无论显示日历如何,调度时刻都保持为公历 TZDateTime(参见 i18n-rtl-l10n)。备份不包含操作系统通知状态或精确闹钟授权——导入后运行 cancelAll(),再进行完整协调;切勿假设恢复的待处理 ID 仍然有效。

反模式

  • 从功能模块/ViewModel 调用 gateway.schedule()。 这会导致缓存不同步,且无法实现幂等。应通过 syncNotifications() 调用。
  • 将循环计划保存为 UTC 时刻。 每次跨越夏令时边界,触发时间都会偏移一小时。应保存墙上时间和规则。
  • 在调度器中使用 DateTime.now()。 这会导致无法在设备外测试。应注入 Clock。
  • 声明 USE_EXACT_ALARM,以“确保准时触发”。这有被 Play 政策拒绝的风险。应使用选择启用的 SCHEDULE_EXACT_ALARM,并在不可用时回退到非精确闹钟。
  • 调度无上限的一组计划。 iOS 会静默丢弃超过 64 个的计划。应做好数量预算并补充计划。
  • 从点击处理隔离中写入数据库。 并发访问可能导致数据损坏。应记录意图;在应用回到前台时进行协调。
  • 相信模拟器运行成功就能保证重启/Doze/OEM 环境下正常运行。 模拟器不可靠。只有通过真实设备测试矩阵,才能获得这种信心。
  • 把操作系统的待处理计划集当作权威存储。 它只是缓存。数据库才是事实来源。

完成定义

  • 仅在一个文件中导入 flutter_local_notifications(check-single-fln-import.sh 通过)。
  • 调度/重复计划计算是纯函数,并注入 Clock(check-scheduler-purity.sh 通过)。
  • 协调逻辑之外没有临时调用 gateway.schedule/cancel(check-adhoc-schedule-calls.sh 通过)。
  • 在启动时、任何 zonedSchedule 调用之前设置 tz.local。
  • 重复计划保存为墙上时间和规则;单次计划保存为 UTC 毫秒数。
  • 根据 canScheduleExactAlarms() 选择 AndroidScheduleMode;不包含 USE_EXACT_ALARM(check-manifest-permissions.sh 通过)。
  • iOS 数量预算 ≤ 约 50;按最近时间升序排列;在应用回到前台时补充计划。
  • 确定性 ID 包含解析后的触发时刻;已测试唯一性;输入未变时协调具备幂等性,且在编辑触发时间后会重新调度(取消旧 ID,并在新时间触发新 ID)。
  • 点击处理器使用 @pragma('vm:entry-point');不在隔离中写入数据库;可序列化的负载 → 路由。
  • 恢复后流程:执行 cancelAll(),然后进行完整协调。
  • 纯 Dart 测试涵盖跨夏令时的重复计划、数量预算边界、幂等协调、恢复状态等于期望状态。

相关技能

  • service-boundary-and-native — 网关所遵循的可注入、覆盖前会抛出异常的端口提供程序,以及 MethodChannel 隔离机制。
  • naming-conventions — [Concern]Gateway 后缀(对特定插件的轻量封装,此处为 flutter_local_notifications)与 [Concern]Service 的区别;本技能遵循的角色后缀表由该技能维护。
  • persistence-drift — 作为事实来源的本地数据库;触发协调的作用域 .watch 流。
  • async-safety — 点击处理器中 await 之后的 BuildContext/mounted 检查,以及订阅释放。
  • state-management-riverpod — 将 provider 用作网关和协调逻辑的 DI。
  • i18n-rtl-l10n — 本地化正文、ICU 复数形式、数字规范化。
  • value-objects-money-and-units — 此处共用的 Clock 注入规范。
  • app-startup-and-bootstrap — 在 runApp 之前设置 tz.local 和创建通道的位置。
  • testing-strategy — 本引擎依赖的优先使用伪造对象而非模拟对象,以及注入时钟的纯核心测试。

参考资料

评论

支持 Markdown