Appearance
第十二章:日常事项与提醒系统
12.1 背景:一次性待办之外的「日常」
最初 TodoApp 只有一种数据:一次性待办——写下来、做完、勾掉、消失。但真实生活里还有一类事项不是"做完就没了",而是周而复始:每天早晚吃药、工作时段每小时起身喝水、每个工作日晨会打卡。
用一次性待办硬套这类需求很别扭:做完今天的还得手动再建明天的,还得自己惦记时间。于是引入了第二种待办类型 routine(日常),配合一套双平台提醒系统,让"到点自动提醒 + 每日可勾选"成为一等能力。
普通待办与 routine 共用同一张 todos 表、同一套 E2EE 加密与同步链路(详见 第十章、第十一章),routine 只是多了一组描述"怎么重复、几点提醒"的明文元数据字段。
12.2 两种待办类型
todoType 字段区分两种类型(models/todo.dart):
dart
enum TodoType { normal, routine }| 类型 | 语义 | 截止/重复 | 完成语义 |
|---|---|---|---|
normal | 一次性待办 | 可选 dueDate 截止时间 | isCompleted 勾掉即结束 |
routine | 日常重复事项 | 按重复规则每天/按周触发 | 可选「每日打勾」,次日自动重置 |
todoType == normal 时,下面所有 routine 字段全部被忽略,行为与旧版待办完全一致——这保证了历史数据零迁移成本。
12.3 重复方式:两种 RecurrenceKind
routine 支持两种重复模型(recurrenceKind 字段):
dart
enum RecurrenceKind { dailyAt, interval }dailyAt — 每天若干固定时刻
适合"每天在确定的几个时间点"提醒,例如吃药 09:00 / 21:00。触发时刻存在 dailyTimes 字段,格式是当天分钟数列表:
09:00 → 540,21:00 → 1260
dailyTimes = [540, 1260]为什么用「当天分钟数」而非
DateTime:routine 不绑定具体日期,只关心"每天的第几分钟"。用 int 分钟数存储既紧凑,又天然规避了时区/日期的干扰。数据库列与同步 payload 用逗号串("540,1260")存储,模型内用List<int>方便计算,两者通过RoutineTimes.parse/format互转。
interval — 时间窗内每隔 N 分钟
适合"某个时段内周期性"提醒,例如工作时间 08:00–18:00 每 60 分钟喝一次水。相关字段:
| 字段 | 含义 | 默认值 |
|---|---|---|
intervalMinutes | 每隔多少分钟触发一次 | 60 |
windowStart | 时间窗起点(当天分钟数) | 480(08:00) |
windowEnd | 时间窗终点(当天分钟数) | 1080(18:00) |
调度时由 RoutineScheduler.slotsFor() 把时间窗按步长展开成一串触发时刻。
防刷屏铁闸
maxSlots = 64:如果用户配了"每 1 分钟",一天会展开出上千个提醒。展开逻辑设了 64 个槽位的硬上限,超出即截断,防止生成海量通知拖垮系统。
daysOfWeek — 按星期过滤
无论哪种重复方式,都可以再叠加"只在某些星期触发"。daysOfWeek 是一个 7 位掩码:
bit0=周一 bit1=周二 … bit6=周日
127 (0b1111111) = 每天(默认)
31 (0b0011111) = 仅周一~周五(跳过周末)RoutineScheduler.firesOnWeekday(t, weekday) 用它判断"今天要不要触发",采用 ISO 星期约定(周一=1…周日=7),与 Dart 的 DateTime.weekday 一致。频率文案(describe())会在仅工作日时追加「· 仅工作日」,让用户在卡片上一眼看懂,例如:
每天 09:00、21:00
每 60 分钟 · 08:00–18:00 · 仅工作日12.4 提醒元数据:铃声与震动
每条待办(普通 + routine 都适用)带两个明文提醒偏好:
| 字段 | 含义 | 取值 |
|---|---|---|
soundKey | 通知铃声 | default(系统默认音)/ silent(静音) |
vibrate | 是否震动 | 仅 Android 生效 |
为什么这两个字段不加密:它们是"怎么提醒"的技术元数据,不含任何用户隐私内容,服务端读到也无意义。E2EE 只加密
title/description两个隐私字段(见 第十章 10.3),其余结构化字段一律明文,以便同步与调度逻辑直接使用。
soundKey 刻意用字符串而非布尔,是为将来扩展内置铃声(如 bell / chime)时无需再做一次数据库迁移——预留式设计。
12.5 通知投递:双平台策略
通知能力抽象为统一接口 NotificationService,按平台切换实现:
dart
abstract class NotificationService {
Future<void> setup();
Future<bool> isAllowed();
Future<bool> show({...}); // 立即弹一条
Future<void> scheduleDaily({...}); // 系统级:每天/每周定时重复
Future<void> scheduleOnce({...}); // 系统级:未来某一时刻一次性
Future<void> cancelAllScheduled();
}| 平台 | 实现 | 底层库 | 调度方式 |
|---|---|---|---|
| Android | AndroidNotificationService | awesome_notifications | OS 级定时,App 被杀也能响 |
| Windows | DesktopNotificationService | local_notifier | 前台轮询(托盘常驻),无系统级调度 |
这是整个提醒系统最关键的架构分歧:Android 把"何时提醒"交给操作系统排期,桌面端靠 App 自己每分钟轮询兜底。 两端因此走了两条不同的代码路径。
12.5.1 普通待办的截止提醒
有截止时间的普通待办排两条提醒:临期(提前 15 分钟)和逾期(到点)。
Android:_scheduleDue() 用 scheduleOnce 排两条一次性系统通知,只排未来时刻(已过去的不排,OS 不会补发)。即使 App 被系统杀掉,到点仍会响。
Windows:local_notifier 没有系统级排程,改由 _check() 每分钟轮询:
- 临期:距截止 14~16 分钟窗口内弹一次(错过窗口不补,交给逾期提醒)
- 逾期:到点后弹;并设 120 分钟补偿窗口——App 关闭期间错过的逾期,重开后若仍在窗口内且没提醒过,补弹一次;超出则不再打扰(列表里本就显示为逾期)
12.5.2 routine 的每日/间隔提醒
Android:_scheduleRoutine() 把 routine 的每个触发槽用 scheduleDaily 交给 OS。每天(daysOfWeek==127)走单条"每天重复"最省;否则按选中的星期逐条"每周重复"。
Windows:仍走每分钟轮询(_checkRoutineDesktop),但有一个精心设计的**「只补最近一条」策略**:
在今天"已到时刻、且尚未触发过"的槽位中,只补弹最近一条,其余积压的(睡眠/关机期间错过的)标记为已处理但不弹。正常实时场景下每分钟至多一个新槽到点,行为与"到点即弹"一致;而睡醒/时钟漂移导致一次积压十几个槽时,也不会瞬间弹一屏通知。
此外桌面端 routine 触发前还会检查:今天是否是该 routine 的激活星期(firesOnWeekday)、以及"可打勾且今天已勾选"(completedForDate == 今天)——已勾选的当天不再打扰。
12.6 去重与幂等
提醒系统最怕两件事:重复弹和漏弹。为此设计了三道机制。
签名 diff 避免重复重排
待办数据每次变动都会触发 watchAll 流。如果每次都全量重排系统通知,开销大且易抖动。ReminderService 为所有"影响排程"的待办算一个签名字符串(_schedSig,涵盖时刻、间隔、星期、铃声、震动等),签名不变就直接跳过,只有真正影响提醒的改动才触发 _reschedule。
全清 + 重建的幂等重排
一旦需要重排,_reschedule 先 cancelAllScheduled() 清空所有已排通知,再按当前待办全量重建。这种"全清+重建"是幂等的,天然处理编辑、完成、删除三种情况,无需为每种变更写单独的增量逻辑。
跨重启去重(桌面端)
桌面端轮询需要"跨 App 重启也只弹一次",两组去重记录持久化到 prefs(按 userId 隔离):
| 记录 | prefs key | 内容 |
|---|---|---|
| 一次性提醒已弹集合 | notified_reminders_$userId | {todoId}_soon / {todoId}_overdue |
| routine 当天已触发 | routine_fired_$userId | {day, keys},跨天自动清空 |
去重记录弹成功才写入(弹失败留到下个 tick 重试),并会随待办完成/删除而清理,避免无限增长。
Android 的截止提醒走 OS 级排程、不用
_notified,故这些持久化去重仅在桌面端生效。
12.7 UI 呈现
普通待办与 routine 在同一个列表里分区展示(todo_list_panel.dart):普通待办在上,日常在下,两者都存在时用「日常」分区标题分隔。排序(优先级/截止/创建时间)只作用于普通待办区;日常区按时间窗/首个触发时刻自然排序。
routine 卡片(RoutineItemCard)与普通待办卡片不同:它显示由 RoutineScheduler.describe() 生成的频率文案,若开启了 routineCheckable 则显示每日打勾框。勾选后写入 completedForDate = YYYYMMDD,当天不再提醒,次日日期变化后自动重置为"未完成"。
12.8 Android 通知权限与 channel 矩阵
channel 矩阵
awesome_notifications 的铃声与震动绑定在 channel 上,且 channel 创建后不可修改。因此不能在发通知时临时改铃声,只能预建一组固定的 channel 矩阵(铃声 × 震动开关),发通知时按事项的 soundKey / vibrate 选对应 channel:
channelKey 命名:reminder_<铃声>_<v|n> (v=震动开,n=震动关)
阶段 A 两种铃声 × 两种震动 = 4 个 channel权限引导
Android 13+ 需要运行时申请通知权限,部分国产 ROM 还需额外授予"精确闹钟/后台弹出"权限。isNotificationAllowed() 暴露当前授权状态,供应用内的权限体检引导(permission_guide.dart)检测并引导用户去系统设置开启,确保 OS 级定时提醒能正常送达。
12.9 小结
| 关注点 | 设计要点 |
|---|---|
| 数据模型 | 复用 todos 表,todoType 区分;routine 字段全为明文元数据 |
| 重复模型 | dailyAt(固定时刻)/ interval(时间窗步进),叠加 daysOfWeek 星期过滤 |
| 防爆炸 | maxSlots=64 硬上限 |
| 投递策略 | Android OS 级排程(可被杀后仍响)/ Windows 前台轮询(托盘常驻兜底) |
| 幂等 | 签名 diff + 全清重建 + 持久化去重 |
| 隐私 | 提醒元数据明文,仅 title/description 走 E2EE |