关于 local-notifications-scheduler
这项技能指导在 Flutter 应用中构建本地通知调度器,以数据库为唯一事实来源,并通过统一流程协调系统通知。重点涵盖纯函数调度逻辑、重复提醒、时区处理和平台限制。
主要功能
- 01数据库作为唯一事实来源
- 02统一通知协调入口
- 03注入时钟的纯调度逻辑
- 04支持夏令时的重复提醒
- 05平台限制与调度降级
适用场景
- 01设计本地提醒引擎
- 02在设备外测试重复与调度逻辑
- 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.dartnotification_gateway.dartsync_notifications.dartreferences/notification-gateway-port.mdplatform-boot-exact-alarms-oem.mdscheduler-and-recurrence-rules.mdscripts/check-adhoc-schedule-calls.shcheck-manifest-permissions.shcheck-scheduler-purity.shcheck-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。
不可妥协的规则
- 本地数据库是唯一的事实来源。 OS 的待处理通知集只是可丢弃的缓存,需要与数据库对账,绝不能作为权威记录。进程终止、重启、Doze 或恢复后,每条提醒都必须能仅凭数据库重新构建。
- 所有调度变更都必须通过同一个
syncNotifications()对账入口。 绝不要在功能或 UI 代码中临时调用gateway.schedule()/cancel()——这条路径无法实现幂等,会导致缓存不同步。 - 只能在一个文件中导入
flutter_local_notifications——即 FLN 适配器。包括所有测试在内的其他代码都必须通过NotificationGateway端口交互。任何其他导入都会触发 grep 门禁,导致构建失败。 - 所有调度计算都必须放在纯粹、无副作用的类中。 其中不得调用插件、执行 IO 或使用
DateTime.now()——应从package:clock注入Clock:Riverpod 代码从clockProvider读取,纯计算则将其作为参数(绝不使用DateTime.now(),也绝不自定义ClockService)。保持纯粹,才能通过FakeNotificationGateway在设备外进行单元测试。 - 重复日程应以墙上时钟时间 + 重复规则存储,仅在调度时解析为
TZDateTime。 绝不要将重复日程持久化为 UTC 时间点——每次跨越 DST 边界都会产生一小时的偏差。真正的一次性时间点应保持为 UTC epoch 毫秒。 - 在启动时设置
tz.local(通过flutter_timezone)。timezone包默认使用 UTC——如果忘记设置,每个zonedSchedule都会在错误的本地时间触发。 - 默认使用
AndroidScheduleMode.inexactAllowWhileIdle(无需权限,可穿透 Doze)。通过用户可撤销的SCHEDULE_EXACT_ALARM和canScheduleExactAlarms()控制精确触发,并在不可用时静默回退为非精确触发。绝不要声明USE_EXACT_ALARM——Play 政策仅允许闹钟/计时器/日历应用使用此权限,否则可能导致应用被商店拒绝。 - 将 iOS 待处理通知数量控制在约 50 条(为静默的 64 条上限留出余量)。将未来时间点按升序排列,取最近的约 50 条,并在每次回到前台时补充。第 65 条及之后的通知会被静默丢弃,不会报错。
- 使用确定性 ID,由
reminderId + occurrenceIndex以及已解析的触发时间和通知内容的哈希值生成。getPending()只会公开id,不会公开when——因此,如果 ID 未包含触发时间,那么即使提醒的重复序号不变,编辑提醒时(一次性提醒从 09:00 改为 14:00,或每日规则的时间发生变化),也会继续使用相同 ID,导致取消和调度循环都跳过它,并在原时间触发。将解析后的TZDateTime(以及内容)纳入 ID,编辑后就会生成新 ID——旧 ID 会被取消,新 ID 会被调度。输入未变时仍会映射到相同 ID,因此对账不会产生任何操作。 - 应用回到前台时的对账是可靠性的基石。 启动接收器、后台计时、精确闹钟开关和 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— 本引擎依赖的优先使用伪造对象而非模拟对象,以及注入时钟的纯核心测试。
参考资料
- flutter_local_notifications:https://pub.dev/packages/flutter_local_notifications
- 时区调度与时区:https://pub.dev/packages/flutter_local_notifications#scheduling-a-notification
- timezone 包:https://pub.dev/packages/timezone
- flutter_timezone:https://pub.dev/packages/flutter_timezone
- Android 精确闹钟:https://developer.android.com/develop/background-work/services/alarms/schedule
- Android USE_EXACT_ALARM 政策:https://support.google.com/googleplay/android-developer/answer/13161072
- iOS UNUserNotificationCenter 的 64 条通知限制:https://developer.apple.com/documentation/usernotifications/unusernotificationcenter
- package:clock:https://pub.dev/packages/clock