· engineering· 约 21 分钟精读
到期那天没有人说话:端侧离线台账的提醒通道与调度边界
TagWorks、SlingGuard 与 QubitLab 合计 0 个通知请求、0 个后台任务、0 个角标。到期状态只在视图求值的那一刻由 Date() 算出。本文按源码核对这条通道的缺口、本地通知在离线条件下的可行性,以及"已通知"与"已履行"之间的边界。
地下二层的配电间没有信号。检验员上一次打开 TagWorks 是十一周前,那天他补录了十二台手工具的检验记录。今天,其中三台的复检日期已经过了。
应用不会告诉他。不是通知被静音,也不是权限被拒绝——三个工程里没有任何一行代码申请过通知权限。到期这个事实一直在数据里,只是没有一条通道把它送出去。
这不是缺陷报告。TagWorks、SlingGuard 与 QubitLab 从设计上就是离线工具:没有账号、没有服务器、没有遥测。本文只核对一件事:在这样一套架构里,“到期”以什么形式存在,谁在什么时候看得见它,以及要让它主动说话,需要补上什么。
左右滑动查看完整图表
一、到期日是一个计算属性
TagWorks 的资产模型里没有”到期日”这个存储字段,只有一个 nextTestDue 计算属性:取最近检验日期,按类别、环境、风险三格查出间隔月数,再用 Calendar.current 加上去。没有检验日期时返回空。
SlingGuard 的 nextInspectionDue 是同样的形状,基准日取 lastInspectionDate,为空则退回 commissionDate,间隔取资产上的 inspectionIntervalMonths。它另有一个存在检验记录上的 nextDueDate 字段,用于报告明细里的 “Scheduled next date”;资产状态判定不读那个字段。两个到期日的分工已在另文逐行核对,这里只取一个结论:判定链读的是计算属性。
计算属性本身没有问题。问题是把它当成事件。一个计算属性只在被读取时存在;两次读取之间,它不占内存,也不对应任何系统对象。要让”到期”在某个时刻主动发生,系统里必须有一个东西在等那个时刻——定时器、后台任务、预约通知,或一个正在运行的前台进程。这三样,一个都没有。
二、求值发生在五个地方
TagWorks 的登记册每一行在构造 AssetRow 时调用 ReportExporter.status(of:records:),now 用默认值 Date()。仪表盘的 summary 是计算属性,每次 body 求值都重算一遍:对全部资产做一次状态映射,再在结果数组上做六次计数过滤(失败、不完整、合格、过期、临近、未检验)。CSV 导出逐行求值,PDF 生成时再算一遍,并把它自己的 now 传下去。物理标签渲染时读的是 nextTestDue。
| 求值点 | 触发条件 | 用的时刻 | 结果去哪 |
|---|---|---|---|
| 登记册行 | 列表滚动到该行 | Date() | 胶囊文字 OVERDUE / SOON / OK |
| 仪表盘汇总 | 每次 body 重算 | Date() | 四个计数与进度条 |
| CSV 导出 | 点击导出 | Date() | status 列 |
| PDF 报告 | 点击生成 | 调用方传入的 now | 封面 Overdue 计数、Due 列 |
| 物理标签 | 点击打印 | Date() | NEXT TEST DUE 区块 |
这张表说明:状态不是被保存的,而是被重新算出来的。同一台设备,今天上午在仪表盘上是 SOON,明天早上打开就是 OVERDUE;中间没有任何写入,也没有任何通知。变化的不是数据,是读取时的时钟。
SlingGuard 的报告里有一句现成的注脚:“No cloud transmission. Statuses and dates are calculated from locally stored records.” 这本来是隐私声明,顺带把架构说清楚了:状态与日期都是”从本地记录算出来的”,算的动作发生在生成报告的那一刻。
两个应用对”过期”的边界还差一个比较符。TagWorks 写的是 due <= now,到期瞬间即判过期;SlingGuard 写的是 now > due,同一瞬间仍算在用。前者的”临近”窗口用日历天数差,且只取整,三十天零二十三小时会被截成三十天,落进 SOON;后者直接比较绝对时刻加三十天。同一天到期的两台设备,在两个应用里可能显示不同的颜色。
| 判定 | TagWorks | SlingGuard |
|---|---|---|
| 过期 | due <= now | now > due |
| 临近 | 日历天数差 ≤ 30 | due <= now + 30 天 |
| 天数取整 | 截断到整日 | 不做天数取整 |
| 状态来源 | 计算属性,无缓存 | 计算属性,无缓存 |
还有一处语义分岔值得记下来。TagWorks 有两个同名重载:只接收资产与时刻的那个,在 nextTestDue 为空时返回 .due,把”从未检验”当成”过期”;多接收最近结论与不完整标记的那个,先用 .untested 把它拦下来。四个调用点最终都经过后者的包装,所以界面上看到的是 UNTESTED;但只要有人直接调前者,同一台新设备就会显示成 OVERDUE。状态没有单一入口,语义就靠调用点自觉。
三、五条通道的调用点都是零
按 API 逐条检索三个工程:
| 通道 | 用途 | TagWorks | SlingGuard | QubitLab |
|---|---|---|---|---|
UNUserNotificationCenter | 本地定时通知 | 0 | 0 | 0 |
registerForRemoteNotifications | 远程推送 | 0 | 0 | 0 |
BGTaskScheduler | 后台刷新 | 0 | 0 | 0 |
applicationIconBadgeNumber | 图标角标 | 0 | 0 | 0 |
WidgetKit | 桌面小组件 | 0 | 0 | 0 |
EventKit | 写入系统日历 | 0 | 0 | 0 |
三个工程的 project.yml 与 Info.plist 里也没有 UIBackgroundModes。TagWorks 的二十六个 Swift 文件里,唯一与生命周期有关的是 TagWorksApp 对 scenePhase 的观察:进入 inactive 或 background 时调用 store.flush(),把四百毫秒防抖窗口里还没落盘的编辑同步写掉。SlingGuard 的二十七个文件里连这个都没有,只有三百毫秒的防抖保存。
也就是说,生命周期感知被用来保证写入完整,从未被用来安排一次提醒。
QubitLab 这一列要单独看。它有六十二个 Swift 文件、十处 TimelineView、四处 Timer,全部用在关卡内的动画与倒计时;通知调用同样是零。对教育应用这是可接受的:每日挑战按本机日序取模,打开才算,不打开就没有”错过”这回事。合规工具不同——到期是外部世界的事实,不因应用是否被打开而改变。
四、离线不是理由
常见的解释是”离线应用不能发通知”。这句话只对了一半。
远程推送需要 APNs、需要服务器、需要设备令牌,离线架构确实给不了。本地通知不需要。UNUserNotificationCenter 的请求在设备上排队,UNCalendarNotificationTrigger 按日历计算触发点,整个过程不经过网络,也不经过任何第三方服务。一个从不联网的应用,完全可以做到”某年某月某日上午九点弹一条提醒”。
所以”完全离线”与”到期提醒”并不冲突。缺的是排程代码,不是网络条件。
但这条通道有它自己的边界,每一条都会改变产品行为:
| 边界 | 具体表现 | 对产品的影响 |
|---|---|---|
| 授权只弹一次 | requestAuthorization 被拒后无法再次弹窗 | 只能引导去设置页,无法强制 |
| 待定请求上限 | 系统只保留最早触发的 64 条 | 全量排程会静默丢请求 |
| 日历语义 | 触发点按 Calendar.current 解释 | 改时区或日历,触发时刻漂移 |
| 重装与迁移 | 待定请求不在沙盒备份里 | 恢复备份后通知队列为空 |
| 重排触发点多 | 新增、修改、删除、恢复各一次 | 漏一个就留下幽灵提醒 |
| 没有回执 | 系统不告知送达,用户可清除 | 只能当提示,不能当证据 |
第一条最容易被低估。授权弹窗在首次调用时出现,用户点”不允许”之后,应用再次调用 requestAuthorization 不会弹出任何界面,只会静默返回 false。此后唯一的通道是打开系统设置页,而应用无法感知用户是否真的改了。
第二条决定了产品形态。六十四不是很大的数字。TagWorks 免费层上限二十五台,每台两个时点(到期前三十天、到期当天)是五十条,勉强放得下;加一个”到期前七天”就是七十五条,超出的部分由系统丢弃,保留哪一条不做承诺。SlingGuard 的免费层是二十台,两个时点四十条,三个时点六十条,刚好卡在边上。这意味着提醒功能一旦上线,容量模型就必须和额度模型一起算。
第三条与已有的时间口径相连。跨时区飞行之后,“到期日早上九点”可能是另一个绝对时刻;用户把日历从公历改成其他历法,DateComponents 的解释也会变。端侧应用没有服务器来统一时区,只能接受这个不精确。
第四条常被忽略。待定通知存在系统数据库里,不在 Documents 目录里,不随备份文件走。换机、恢复备份、删除重装之后,登记册回来了,通知队列是空的。要恢复,必须从登记册全量重建排程——这也意味着重建逻辑必须存在,且必须幂等。
还有一层与隐私立场相关的取舍。到期通知的正文如果写成”角磨机 TG-4821 已过期”,这条内容会出现在锁屏上;而锁屏是否显示正文,由用户的系统设置决定,应用无法覆盖。一个以”数据不出设备”为原则的产品,需要决定是否让设备名出现在锁屏上。可以退到”您有三台设备需要复检”这种不含标识的文案,代价是失去逐台可操作性。
五、后台刷新不是解法
另一条常被想到的路径是后台刷新。BGAppRefreshTask 能让应用在后台获得一小段执行时间,但它有三个性质决定了它承担不了到期提醒:
- 机会式调度。系统按使用习惯、电量与网络状况决定何时给窗口,不承诺时刻,也不承诺当天一定执行。
- 需要声明。要开 Background Modes、要申请权限,审核时会追问用途。
- 无事可做。离线应用在后台拿到窗口,能做的只是重算本地已有的日期——而重算的结果,用户打开应用时同样能算出来。
所以后台任务对这套架构的唯一价值是”提前把通知排好”,而这件事在写入记录时做更可靠:那时数据是新的,到期日是确定的,不需要等待系统分配一个窗口。
iOS 也没有”在某个日期唤醒应用执行代码”的公开能力。除了本地通知,端侧无法在指定时刻主动运行逻辑。这一点决定了提醒只能是预约通知,不能是定时计算。
六、今天真正在提醒的四样东西
| 载体 | 由谁生成 | 何时可见 | 依赖打开应用 |
|---|---|---|---|
| TagWorks 标签 800×420 | 检验后打印 | 贴在设备上,随时 | 否 |
| SlingGuard 标签 360×220 | 登记或检验后打印 | 挂在索具上,随时 | 否 |
| 合规报告 PDF | 手动生成 | 导出与传阅时 | 是 |
| 人的复检计划 | 设备所有者 | 制度层面 | 否 |
TagWorks 的标签上有一个 NEXT TEST DUE 区块,用大号字印出到期的月份与年份,下面再附一行完整日期;二维码载荷里也带 DUE: 段,扫码可以读回。这是整套系统里唯一不依赖应用被打开的提醒:纸贴在设备上,谁走到设备前谁看见。
SlingGuard 的标签是另一套取舍。三百六十乘二百二十的卡面上印的是标识、类型、额定载荷、颜色代码与检验周期,二维码只编码标识。它提醒的是”这台索具每六个月要检一次”,不是”这次到期是哪天”。周期恒定,日期滚动,标签印了恒定的那一半。
报告是第三种。TagWorks 封面有 Overdue 计数,逐站点表格有 Due 列;SlingGuard 封面有 “Scheduled due within 30 days” 与 “Scheduled due date passed” 两行,表格有 Status 与 Scheduled due 两列。但报告要人主动生成,时效取决于生成频率,不取决于到期日。
剩下的就是人的流程。AS/NZS 3760 要求设备所有者确保在用设备按规定间隔检验;LOLER 1998 第九条要求雇主确保吊具在规定间隔内接受彻底检验。义务主体是企业与责任人,不是记录工具。软件能把日期算对、把台账记全、把报告印清楚;它不能替责任人履行义务,也不该被当成义务已经履行的证据。
七、补这条通道需要什么
如果要把到期提醒做进这套架构,要补的不是一个 API 调用,而是一层排程。
现状是两层:事实层(nextTestDue 纯函数,已有)与呈现层(打开应用时求值,已有)。中间缺的是排程层:在写入时把”什么时候该提醒”变成系统里的一条待定请求。
这一层要成立,需要四个不变量:
- 幂等。同一台设备的同一个时点只对应一条请求,标识符由资产标识与时点拼成,重复排程不产生第二条。
- 可重建。任何时候都能从登记册全量重建全部请求,不依赖增量事件;换机、恢复备份、权限重新打开之后走同一条路径。
- 可清理。删除资产、修改周期、恢复备份之前,先按资产标识前缀移除旧请求,再排新的。
- 不污染台账。排程失败只记录一条本地状态,不影响检验记录的写入。台账的完整性高于提醒的完整性。
容量要单独算。逐台两个时点,二十五台正好五十条,在六十四条以内;加到三个时点就越界,必须放弃全量——只排最近到期的二十一台(六十三条),其余在每次写入时滚动补位。另一种做法是聚合:每天最多一条摘要通知,“今天有三台到期”,把多条压成一条,代价是点进去之前不知道是哪三台。
这条不等式是排程层最硬的约束。它决定了产品只能做”最近若干台”的提醒,或者”按天聚合”的提醒,不能做”每台设备、每个节点都提醒”。
还要说清楚一点:即使全部补齐,通知仍然不是”已通知”的证据。它没有送达回执,用户可关、可清、可静音。审计上能证明的只有记录里写了什么,不是人有没有看到。
八、结语
把”提醒”这个词拆开,它由四部分组成:事实、时刻、通道、责任人。端侧应用能算准事实,也能算准时刻;通道需要显式建设,责任人永远在人这一边。
这套架构选择了不建通道。这个选择让应用保持了很小的暴露面:没有推送令牌,没有后台唤醒,没有第三方调度,锁屏上不会出现设备名。代价是所有时间判断都被推迟到”下一次打开”。对一台每天都要用的手机,这个代价很小;对一台放在配电间、一个月才被想起一次的工具,这个代价就是那三台过期设备。
工程上能做的,是把代价说清楚:在报告封面印出 Overdue,在标签上印出到期月份,在打开应用时把 Attention 计数放在第一屏。这些都已经做了。剩下那一步——让应用在到期那天主动说话——不是离线架构禁止的,只是还没有写。
参考资料
- [1] Apple. UNUserNotificationCenter. unusernotificationcenter
- [2] Apple. UNCalendarNotificationTrigger. uncalendartrigger
- [3] Apple. Scheduling and handling local notifications. local-notifications
- [4] Apple. BGAppRefreshTask. bgapprefreshtask
- [5] Apple. scenePhase. scenephase
- [6] Standards Australia & Standards New Zealand. AS/NZS 3760:2022 在用电气设备检验与测试. as-nzs-3760
- [7] Health and Safety Executive. LOLER 1998 起重作业与起重设备条例. loler
- [8] Health and Safety Executive. INDG236 便携电气设备维护. indg236