云端 Mac 上验收 iOS 重复日程的夏令时边界

云端 Mac 上验收 iOS 重复日程的夏令时边界

把“每天当地时间 02:30 同步一次”交给后台任务后,普通日期都能通过,切换夏令时却可能漏跑或多跑。先在云端 Mac 上把下一次执行时间抽成纯函数,用固定日期验证,再接入通知或后台调度。这里检查日历计算契约,不把操作系统实际唤醒应用的时间当成算法结果。

先区分墙上时钟与经过时长

“每隔二十四小时”和“每天 02:30”是两种规则。前者按经过的秒数推进;后者按某个时区的日历推进。一日不总是二十四小时,因此不能用同一段加秒逻辑实现两者。

为每日规则保存时、分、时区标识和策略版本。保存下一次执行的绝对时间可以方便查询,但不能用它替代原始规则。用户修改时区或策略后,需要重新计算。

Date 表示绝对时间,不自带时区。“每天几点”属于日历规则,必须和明确的 Calendar、TimeZone 一起解释。

再确定时区跟随谁:固定在创建时的时区,还是跟随用户所在地。不要让云端机器的当前时区替产品做选择,也不要只存一个固定偏移量;偏移量无法表达未来的夏令时变化。

把不存在与重复时刻写成产品规则

春季拨快时钟时,02:30 可能不存在。可以选择跳过当天,也可以顺延到当天第一个有效时间,还可以保留分钟分量后顺延。这三种结果并不相同。

Calendar 的 .strict 要求精确匹配;每日规则下,不存在的时刻会继续搜索后续日期。.nextTime 选择后续有效时间,不能假定它一定保留原来的分钟值。需要保留分钟分量时,应单独验证 .nextTimePreservingSmallerComponents。

秋季拨慢时钟时,02:30 可能出现两次。通过 .first 或 .last 明确选择。若产品要求两次都执行,必须设计两个候选时刻及各自的事件身份,不能把一次匹配调用当成完整实现。

运行固定日期的 Swift 验收脚本

将下面内容保存为 recurrence.swift。示例使用公历和指定时区,测试日期是边界夹具,不是测量日期。无需修改机器时间,也不需要启动模拟器。

import Foundation

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Berlin")!

let formatter = ISO8601DateFormatter()

func instant(_ value: String) -> Date {
    guard let date = formatter.date(from: value) else {
        fatalError("Invalid fixture date")
    }
    return date
}

func next(
    after cursor: Date,
    policy: Calendar.MatchingPolicy,
    repeated: Calendar.RepeatedTimePolicy
) -> Date {
    guard let date = calendar.nextDate(
        after: cursor,
        matching: DateComponents(hour: 2, minute: 30, second: 0),
        matchingPolicy: policy,
        repeatedTimePolicy: repeated,
        direction: .forward
    ) else {
        fatalError("No matching occurrence")
    }
    precondition(date > cursor)
    return date
}

let spring = instant("2027-03-28T00:00:00+01:00")

let skipped = next(
    after: spring,
    policy: .strict,
    repeated: .first
)
precondition(skipped == instant("2027-03-29T02:30:00+01:00"))

let shifted = next(
    after: spring,
    policy: .nextTime,
    repeated: .first
)
precondition(shifted == instant("2027-03-28T03:00:00+02:00"))

let autumn = instant("2027-10-31T00:00:00+02:00")

let first = next(
    after: autumn,
    policy: .strict,
    repeated: .first
)
let last = next(
    after: autumn,
    policy: .strict,
    repeated: .last
)
precondition(first == instant("2027-10-31T02:30:00+02:00"))
precondition(last == instant("2027-10-31T02:30:00+01:00"))
precondition(last.timeIntervalSince(first) == 3600)

用已选择的开发工具链执行:

xcrun swift recurrence.swift

成功时脚本静默退出;断言失败则退出失败。将失败接到流水线,不要只检查是否生成了日志。升级工具链或系统后重跑夹具,同时记录系统版本、工具链版本和时区标识。

脚本只验证固定的 02:30 规则。接入项目时把时分参数化,并增加普通日期、月底、年底和闰日样本;每条样本都写明预期绝对时间,避免只比较格式化字符串。

验证游标推进与补跑不会重复

计算多个候选时刻时,把上一次返回值作为下一次搜索的游标,并检查结果严格递增。不要减去一秒再查询,否则恰好命中边界时可能重复返回同一事件。批量搜索还要设次数上限,防止异常规则让任务长时间占用执行器。

补跑应使用固定窗口:窗口起点不包含、终点包含。先冻结终点,再枚举候选,不要在循环中反复读取当前时间。否则长任务会不断扩大补跑范围,验收结果也无法复现。

每日只执行一次时,用“日程标识+规则版本+规则时区内的当地日期”建立去重键。完成记录应持久化;若业务操作产生外部副作用,还需把该键传入业务层,不能仅在内存里标记成功。

测试一次处理中断:先完成业务操作,再模拟完成记录尚未落盘。重试必须被业务幂等规则挡住,不能把调度器的去重当成事务保证。

将日历正确性与系统调度分开验收

纯函数通过后,再用模拟器测试规则编辑、展示和持久化回读。后台执行机会由系统调度,不能承诺到秒触发。展示“计划执行时间”和“实际执行时间”两个字段,排障时才不会把延迟误判为日历错误。

最后检查四项:测试没有读取默认时区;每日规则没有按固定秒数推进;重复时刻策略已显式传入;补跑使用稳定窗口和持久化身份。跨夏令时的回归应比较计算结果与执行记录,而不是靠修改系统时钟碰碰运气。

常见问题

每天执行一次,可以直接给上次执行时间加二十四小时吗?

只有固定间隔任务才适合这样计算。若需求是当地每天同一时刻,应使用明确时区的 Calendar 按时分秒匹配,否则跨夏令时后墙上时钟会偏移。

夏令时结束时,同一个当地时刻应该执行两次吗?

不能由系统默认行为决定。产品应明确选择第一次、第二次或两次;只执行一次时,用日程标识、规则版本和当地日期去重,不能只靠 UTC 时间戳。

在独享物理 Mac 上运行你的工程

使用云端 Mac 完成构建、测试与远程桌面操作。按工程需要选择配置和计费周期,实际可用状态以控制台实时返回为准。

下一步,确认你的运行环境

先比较配置,再选择接入方式。SSH 命令行与 VNC 桌面可分别用于构建和交互调试。