· engineering· 约 21 分钟精读

到期那天没有人说话:端侧离线台账的提醒通道与调度边界

核心要点速览 · TL;DR 概要

TagWorks、SlingGuard 与 QubitLab 合计 0 个通知请求、0 个后台任务、0 个角标。到期状态只在视图求值的那一刻由 Date() 算出。本文按源码核对这条通道的缺口、本地通知在离线条件下的可行性,以及"已通知"与"已履行"之间的边界。

到期那天没有人说话:端侧离线台账的提醒通道与调度边界

地下二层的配电间没有信号。检验员上一次打开 TagWorks 是十一周前,那天他补录了十二台手工具的检验记录。今天,其中三台的复检日期已经过了。

应用不会告诉他。不是通知被静音,也不是权限被拒绝——三个工程里没有任何一行代码申请过通知权限。到期这个事实一直在数据里,只是没有一条通道把它送出去。

这不是缺陷报告。TagWorks、SlingGuard 与 QubitLab 从设计上就是离线工具:没有账号、没有服务器、没有遥测。本文只核对一件事:在这样一套架构里,“到期”以什么形式存在,谁在什么时候看得见它,以及要让它主动说话,需要补上什么。

到期那天,没有一条通道被触发三个工程合计:0 个通知请求,0 个后台任务,0 个角标到期事实的产生过程lastTestDate写入记录时算一次nextTestDue计算属性,无缓存status(now: Date())视图求值时才算没有对象在等待这个日期只有打开应用的人才能看见可能承载提醒的通道调用点本地通知0远程推送0后台刷新0应用角标0小组件与日历0本地通知本来不需要网络缺的不是离线条件,是排程代码到期是求值结果,不是系统事件。

左右滑动查看完整图表

一、到期日是一个计算属性

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;后者直接比较绝对时刻加三十天。同一天到期的两台设备,在两个应用里可能显示不同的颜色。

判定TagWorksSlingGuard
过期due <= nownow > due
临近日历天数差 ≤ 30due <= now + 30 天
天数取整截断到整日不做天数取整
状态来源计算属性,无缓存计算属性,无缓存

还有一处语义分岔值得记下来。TagWorks 有两个同名重载:只接收资产与时刻的那个,在 nextTestDue 为空时返回 .due,把”从未检验”当成”过期”;多接收最近结论与不完整标记的那个,先用 .untested 把它拦下来。四个调用点最终都经过后者的包装,所以界面上看到的是 UNTESTED;但只要有人直接调前者,同一台新设备就会显示成 OVERDUE。状态没有单一入口,语义就靠调用点自觉。

三、五条通道的调用点都是零

按 API 逐条检索三个工程:

通道用途TagWorksSlingGuardQubitLab
UNUserNotificationCenter本地定时通知000
registerForRemoteNotifications远程推送000
BGTaskScheduler后台刷新000
applicationIconBadgeNumber图标角标000
WidgetKit桌面小组件000
EventKit写入系统日历000

三个工程的 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 纯函数,已有)与呈现层(打开应用时求值,已有)。中间缺的是排程层:在写入时把”什么时候该提醒”变成系统里的一条待定请求。

这一层要成立,需要四个不变量:

  • 幂等。同一台设备的同一个时点只对应一条请求,标识符由资产标识与时点拼成,重复排程不产生第二条。
  • 可重建。任何时候都能从登记册全量重建全部请求,不依赖增量事件;换机、恢复备份、权限重新打开之后走同一条路径。
  • 可清理。删除资产、修改周期、恢复备份之前,先按资产标识前缀移除旧请求,再排新的。
  • 不污染台账。排程失败只记录一条本地状态,不影响检验记录的写入。台账的完整性高于提醒的完整性。

容量要单独算。逐台两个时点,二十五台正好五十条,在六十四条以内;加到三个时点就越界,必须放弃全量——只排最近到期的二十一台(六十三条),其余在每次写入时滚动补位。另一种做法是聚合:每天最多一条摘要通知,“今天有三台到期”,把多条压成一条,代价是点进去之前不知道是哪三台。

Npending=nasset×kmilestone≤64N_{\mathrm{pending}} = n_{\mathrm{asset}} \times k_{\mathrm{milestone}} \le 64

这条不等式是排程层最硬的约束。它决定了产品只能做”最近若干台”的提醒,或者”按天聚合”的提醒,不能做”每台设备、每个节点都提醒”。

还要说清楚一点:即使全部补齐,通知仍然不是”已通知”的证据。它没有送达回执,用户可关、可清、可静音。审计上能证明的只有记录里写了什么,不是人有没有看到。

八、结语

把”提醒”这个词拆开,它由四部分组成:事实、时刻、通道、责任人。端侧应用能算准事实,也能算准时刻;通道需要显式建设,责任人永远在人这一边。

这套架构选择了不建通道。这个选择让应用保持了很小的暴露面:没有推送令牌,没有后台唤醒,没有第三方调度,锁屏上不会出现设备名。代价是所有时间判断都被推迟到”下一次打开”。对一台每天都要用的手机,这个代价很小;对一台放在配电间、一个月才被想起一次的工具,这个代价就是那三台过期设备。

工程上能做的,是把代价说清楚:在报告封面印出 Overdue,在标签上印出到期月份,在打开应用时把 Attention 计数放在第一屏。这些都已经做了。剩下那一步——让应用在到期那天主动说话——不是离线架构禁止的,只是还没有写。

参考资料

Share:
Back to Blog