把「每天當地時間 02:30 同步一次」交給背景工作後,一般日期都能正常執行,遇到日光節約時間切換卻可能漏跑或多跑。先在雲端 Mac 上將計算下一次執行時間的邏輯抽成純函式,以固定日期驗證,再串接通知或背景排程。這裡驗收的是日曆計算是否符合約定,不把作業系統實際喚醒 App 的時間當成演算法的計算結果。
先區分牆上時鐘與經過時間
「每隔二十四小時」和「每天 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 時間戳記。