0%

iOS 中令人头疼的时间日期处理

本文是 WWDC 2013 《Solutions to Common Date and Time Challenges》 视频的学习笔记。

我自己在工作中第一次接触与时间日期处理相关的需求时,下意识地打开搜索引擎进行搜索,结果发现自己要与 CalendarDateDateFormatterDateComponentsTimeZone 这五个家伙打交道。我人都差点傻了,由此可见:iOS 中的时间日期处理不是想象中的那么容易。

Date、DateFormatter、DateComponents 和 TimeZone

Date 是什么呢?让我们看看 Apple 官方的解释:

Date represents a single point in time.
A Date is independent of a particular calendar or time zone. To represent a Date to a user, you must interpret it in the context of a Calendar.

Date 表示一个时间点,它与某个特定的历法或时区无关。如果要把 Date 展示给用户,我们必须以某个历法为背景来解释它。
Date 包含了一个属性,这个属性存储的是相对于参照日期的秒数。至于这个参照日期是什么,其实不重要。官方 API 里有提供三个参照日期,你自己也可以自定义所需的参照日期。

1
2
3
4
5
6
7
8
9
10
11
// 此时的参照时间为:当前的时间和日期
public init(timeIntervalSinceNow: TimeInterval)

// 此时的参照时间为:世界协调时间 1970 年 1 月 1 日零时零分零秒
public init(timeIntervalSince1970: TimeInterval)

// 此时的参照时间为:用户自定义的 Date 所表示的时间日期
public init(timeInterval: TimeInterval, since date: Date)

// 此时的参照时间为:世界协调时间 2001 年 1 月 1 日零时零分零秒
public init(timeIntervalSinceReferenceDate ti: TimeInterval)

当在业务中处理时间日期时,后端往往提供的是一段描述时间日期的字符串。这时候 DateFormatter 就派上用场了。

1
2
3
4
5
// 如果不单独设置 DateFormatter 的 calendar 属性,DateFormatter 会默认使用用户当前使用的 calendar 来把 Date 解释成符合该历法的表示时间日期的字符串
func string(from date: Date) -> String

// 将一串表示时间日期的字符串(往往是 ISO 8601 格式)处理为对应的 Date
func date(from string: String) -> Date?

除了把后端返回的表示时间日期的字符串恰当地显示给用户之外,我还遇到了时间日期的比较问题。此时,用来格式化的这串字符串的 dateFormatter 需要多一步设置:

1
2
3
var dateFormatter = DateFormatter()
...
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)

这是在 UTC+0 时区里理解这个字符串代表的 Date。举例如下:

1
2
3
4
5
6
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "YYYY-MM-dd-HH-mm-ss"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
let timeString = "2020-04-20-19-00-00"
let res = dateFormatter.date(from: timeString)!
print(res) // 输出:2020-04-20 19:00:00 +0000

如果使用 dateFormatter 的 timeZone 默认值,即用户当前的时区,举个例子,东八区:

1
2
3
4
5
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "YYYY-MM-dd-HH-mm-ss"
let timeString = "2020-04-20-19-00-00"
let res = dateFormatter.date(from: timeString)!
print(res) // 输出:2020-04-20 11:00:00 +0000

在东八区里理解这个字符串代表的 Date,那么所输出的时间会少八个小时,因为 Date 的参照时区为 UTC+0。

接下来是 DateComponents

DateComponents encapsulates the components of a date in an extendable, structured manner.
It is used to specify a date by providing the temporal components that make up a date and time in a particular calendar: hour, minutes, seconds, day, month, year, and so on. It can also be used to specify a duration of time, for example, 5 hours and 16 minutes. A DateComponents is not required to define all the component fields.
When a new instance of DateComponents is created, the date components are set to nil.

DateComponents 以可扩展的结构化方式来封装日期的组成部分。日期的组成部分包括:年、月、日、时、分和秒等。它也可以用来表示一段持续时间。当创建一个 DateComponents 的实例时,该实例所有的日期组成部分都设置为 nil。

1
2
3
4
var dateComponent = DateComponents()
dateComponent.year = 2020
dateComponent.month = 04
dateComponent.day = 20

TimeZone 知道世界时的本地偏移量以及用于确定偏移量何时更改的特定规则,比如夏令时。

在夏令时(Daylight Saving Time)这一点上,中国一开始的做法跟美国相似,是拨快(慢)时钟。夏令时到来时,把钟表拨快一小时。夏令时结束时,把钟表回拨一小时。但根据中国的国情,这样做并不能达到节能的目的。我上初中的时候,是通过改作息时间来应对夏令时的,比如,夏令时到来时,上下学时间为:06:00 - 18:00;夏令时结束时,上下学时间为:06:30 - 18:30。

TimeZone 也有一系列夏令时相关的 API 供开发者使用。

时间日期处理中的常见计算

午夜时刻

为什么想要午夜这个时刻呢?
一个普遍的原因是:想把这个时间点当作一个默认时间。比如当想表示一个人的生日时,我们只在乎这个人是某年某月某日出生的,而不会去想知道是几时几分几秒出生的,除非要去绘制占星学中的 出生图。面对这种情况,我们可以使用正午时间作为这个默认时间。
另外一个普遍原因是:想对「新的一天开始」这个事件作出反应。比如,在新的一天的开始,app 的一些界面会因此作出改变。对此,Cocoa 提供 NSCalendarDayChangeNotification。只要注册并监听这个通知,我们就可以及时对此事件作出相应。

午夜时刻可能会很棘手

午夜时刻可能不存在,也可能存在两个。
这里以巴西为例:
2018 年 11 月 4 日,巴西在这一天进入夏令时,具体表现为:当 11 月 3 日 23 时 59 分 59 秒时,再过一秒,时钟的时间会被人为地拨快一小时,变为:11 月 4 日 01:00:00。在这种情况下,午夜时刻就不存在了。

2019 年 2 月 17 日,巴西在这一天结束夏令时,具体表现为:当 00:00 分 00 秒时,再过一秒,时钟的时间会被人为地拨慢一小时,变为:2 月 16 日 23:00。在这种情况下,存在两个午夜时刻。

以上的例子是该 WWDC 视频中讲者所举的例子。但在我写这篇文章的时候,发现:巴西最后一次观测夏令时是在 2019 年。从 2020 年开始,巴西弃用夏令时。

换个美国的例子:
2020 年 3 月 8 日,美国在这一天进入夏令时,具体表现为:当 01:59 分 59 秒时,再过一秒,时钟的时间会被人为地拨快一小时,变为:03:00。
2020 年 11 月 1 号,美国会在这一天退出夏令时,具体表现为:当 01:59 分 59 秒时,再过一秒,时间会变为 1:00。所以在这一天,其实会有两个 1:30 分。

所以,建议用每一天的开始时刻来取代午夜时刻。
计算今天的开始时刻:

1
2
3
var cal = Calendar.current
let todayStart = cal.startOfDay(for: Date())
print(todayStart) // 输出:2020-04-20 16:00:00 +0000

计算明天的开始时刻:

1
2
3
let sometimeTommorrow = cal.date(byAdding: .day, value: 1, to: todayStart) ?? Date()
let tommorrowStart = cal.startOfDay(for: sometimeTommorrow)
print(tommorrowStart) // 输出:2020-04-21 16:00:00 +0000

把 Date 设置成一个具体时间

比如,我想设置成今天的 11 点 30 分:

1
2
3
var date = Date()
var resDate = cal.date(bySettingHour: 11, minute: 30, second: 0, of: date) ?? Date()
print(resDate) // 输出:2020-04-21 03:30:00 +0000

这个 Date 是在今天吗?

Calendar 提供如下的 API,方便开发者使用。

1
2
3
4
5
6
7
8
9
10
11
// 判断 date 是否在今天
public func isDateInToday(_ date: Date) -> Bool

// 判断 date 是否在昨天
public func isDateInYesterday(_ date: Date) -> Bool

// 判断 date 是否在明天
public func isDateInTomorrow(_ date: Date) -> Bool

// 判断 date 是否在周末
public func isDateInWeekend(_ date: Date) -> Bool

需要记住的是:以上函数返回的 Bool 值是会随着时间推移发生变化的。

Date 的比较

Date 本身提供一些供比较时使用的 API:

1
2
3
4
5
public static func == (lhs: Date, rhs: Date) -> Bool

public static func < (lhs: Date, rhs: Date) -> Bool

public static func > (lhs: Date, rhs: Date) -> Bool

但实际上,这些比较方法的比较粒度太粗,我们还需要更细粒度的比较方法。
Calendar 正好提供这个比较方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 输出: 2020-04-21 02:59:52 +0000
let currentDate = Date()

let sometimeTommorrow = cal.date(byAdding: .day, value: 1, to: currentDate) ?? Date()

// 比较粒度为:天
let res = cal.compare(date, to: sometimeTommorrow, toGranularity: .day)

// 输出:orderedAscending
printCompareResult(res)

// 辅助函数
func printCompareResult(_ result: ComparisonResult) {
switch result {
case .orderedAscending:
print("orderedAscending")
case .orderedSame:
print("orderedSame")
case .orderedDescending:
print("orderedDescending")
}
}

更多的比较粒度可以参考文档

Timeless Date 和 Dateless Time

什么是 “Timeless Date” 呢?Date 是包含时间(例:11:30)和日期(2020 年 04 月 21 日)。正如上文所提到的,如果要表示生日的话,我们只需要日期而已。
在这种情况下,建议使用 DateComponents 或者自定义一个 model。

1
2
3
4
5
// 使用 DateComponents
var birthDayComp = DateComponents()
birthDayComp.year = 2000
birthDayComp.month = 08
birthDayComp.day = 05

如果自定义一个记录生日的 model 的话,我们需要给这个 model 添加一个属性来记录这个日期对应的历法。如果脱离了历法,2000、08 和 05 就真的只是数字而已。
在这种情况下,不要使用用户当前使用的历法。
举个例子,用户现在使用的历法是公历,我记录下用户的生日为公历 2000 年 08 月 05 日。但如果用户把使用的历法改为民国纪年,时间就会出现一些偏差:

1
2
3
4
5
6
7
8
9
// 采用民国纪年
var cal = Calendar(identifier: .republicOfChina)

var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "YYYY年MM月dd日"

let res = cal.date(from: birthDayComp) ?? Date()
let timeString = dateFormatter.string(from: res)
print(timeString) // 输出:3911年08月05日

注:民国纪年法与公历纪年法的换算公式
设 x 为公历纪年法下的年份数字,y 为民国纪年法下的年份数字
则转换公式为:y = x - 1911 (1911 为民国 0 年,1912 为民国元年)

由公式可知:公历 2000 年 08 月 05 日对应的民国年份是 89 年。

接下来,Dateless Time 也就十分容易理解了。有些情况下,我们不关心日期是什么,只关心时间。同样地,建议使用 DateComponents 或者自定义一个 model。

如何找到下一个符合匹配条件的 Date?

很常见的一个例子是:我想让闹钟 App 在下一个 16:30 提醒我下楼扔垃圾。此时,闹钟 App 就想找到下一个符合匹配条件(下一个 16:30)的 Date

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 此时的公历时间为: 2020 年 4 月 21 日 14:21:05
var matchingComponents = DateComponents()
matchingComponents.hour = 16
matchingComponents.minute = 30

let nextDate = cal.nextDate(after: Date(),
matching: matchingComponents,
matchingPolicy: .nextTime,
repeatedTimePolicy: .first,
direction: .forward) ?? Date()
let timeString = dateFormatter.string(from: nextDate)

// 输出:2020 年 04 月 21 日 16 时 30 分 00 秒
print(timeString)

Bingo! 用 Calendar 提供的这个方法就能达到目标啦!但第一次看到这个方法的函数签名,我是这样的:🤯。
先放出 Apple 官方的解释:

Computes the next date which matches (or most closely matches) a given set of components.

  • parameter date: The starting date.
  • parameter components: The components to search for.
  • parameter matchingPolicy: Specifies the technique the search algorithm uses to find results. Default value is .nextTime.
  • parameter repeatedTimePolicy: Specifies the behavior when multiple matches are found. Default value is .first.
  • parameter direction: Specifies the direction in time to search. Default is .forward.
  • returns: A Date representing the result of the search, or nil if a result could not be found.

可能让人比较困惑的是 matchingPolicy 这个参数。总共有四种 matchingPolicy,分别是:nextTimenextTimePreservingSmallerComponentspreviousTimePreservingSmallerComponentsstrict
让我们来举个具体的例子来理解这四种 matchingPolicy,但在讲这个例子之前,先回顾一下之前提到的美国夏令时:

2020 年 3 月 8 日,美国在这一天进入夏令时,具体表现为:当 01:59 分 59 秒时,再过一秒,时钟的时间会被人为地拨快一小时,变为:03:00。

我有一个朋友,在美国一机场上班。他在今年 3 月 8 日的凌晨 03:30 有排班。他跟往常一样,打算提前一小时醒来做作准备。他把一个简单闹钟 App 的提醒时间设定为 3 月 8 号 02:30。

先从上帝视角看这条时间线:

这一天其实是不存在凌晨两点半这个时间点的。这就是 matchingPolicy 大显身手的时候了。四种不同的 matchingPolicy 会导致闹钟 App 在四个不同的时间点响铃🔔:

matchingPolicy: nextTime

如果要匹配的时间在这一天并不存在,则这种 matchingPolicy 会选择下一个存在的时间,即 3 月 8 日 03:00。我这个朋友会在这一天的凌晨三点被闹钟喊起,而距离他上班只有半个小时了🤨。他可能要抓紧点时间了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 辅助函数
func dateLog(_ date: Date?, hint: String) {
guard let date = date else { return }
let formatter = DateFormatter()
formatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "EST")!
print(hint)
print("Formatted: " + formatter.string(from: date))
print()
}

var calendar = Calendar.init(identifier: .gregorian)
calendar.timeZone = TimeZone(abbreviation: "EST")!

// 2020 US DST Time
var dstComp = DateComponents()
dstComp.year = 2020
dstComp.month = 03
dstComp.day = 08
dstComp.hour = 01
dstComp.minute = 00
dstComp.second = 0
dstComp.nanosecond = 0


let dstDate = calendar.date(from: dstComp)

// 输出:
// 2020 US DST Time
// Formatted: 2020/03/08 01:00:00
dateLog(dstDate, hint: "2020 US DST Time")

var matchingComponents = DateComponents()
matchingComponents.hour = 02
matchingComponents.minute = 30

let next = calendar.nextDate(after: dstDate!,
matching: matchingComponents,
matchingPolicy: .nextTime,
repeatedTimePolicy: .first,
direction: .forward)

// 输出:
// Matching Result
// Formatted: 2020/03/08 03:00:00
dateLog(next, hint: "Matching Result")

matchingPolicy: strict

如果要匹配的时间在这一天并不存在,则这种 matchingPolicy 会尽可能从未来的时间/过去的时间寻找一个精准匹配,即 3 月 9 日 02:30。这就糟糕了🤦‍♂️,可能会导致他旷工!

1
2
3
4
5
6
7
8
9
10
let next = calendar.nextDate(after: dstDate!,
matching: matchingComponents,
matchingPolicy: .strict,
repeatedTimePolicy: .first,
direction: .forward)

// 输出:
// Matching Result
// Formatted: 2020/03/09 02:30:00
dateLog(next, hint: "Matching Result")

matchingPolicy: nextTimePreservingSmallerComponents

如果要匹配的时间在这一天并不存在,则这种 matchingPolicy 会选择下一个存在的时间,并保留 matchingComponents 中更小的 component:minute = 30。这样匹配的结果是:3 月 8 日 03:30。噢😯,亲爱的朋友,你已经迟到了!

1
2
3
4
5
6
7
8
9
10
let next = calendar.nextDate(after: dstDate!,
matching: matchingComponents,
matchingPolicy: .nextTimePreservingSmallerComponents,
repeatedTimePolicy: .first,
direction: .forward)

// 输出:
// Matching Result
// Formatted: 2020/03/08 03:30:00
dateLog(next, hint: "Matching Result")

matchingPolicy: previousTimePreservingSmallerComponents

如果要匹配的时间在这一天并不存在,则这种 matchingPolicy 会选择上一个存在的时间,并保留 matchingComponents 中更小的 component:minute = 30。这样的匹配的结果是:3 月 8 日 01:30。这也许是个不错的结果🧐,他离上班的时间正好还有一个小时,刚刚好!

1
2
3
4
5
6
7
8
9
10
let next = calendar.nextDate(after: dstDate!,
matching: matchingComponents,
matchingPolicy: .previousTimePreservingSmallerComponents,
repeatedTimePolicy: .first,
direction: .forward)

// 输出:
// Matching Result
// Formatted: 2020/03/08 01:30:00
dateLog(next, hint: "Matching Result")

可能,我这位朋友真正想要的是并不是凌晨两点半,而是开始工作前的一小时

结语

我是听完 iOS 開發生平大坑之 DateFormatter 这期播客,按图索骥找到这个 13 年的老视频。觉得「好记性不如烂笔头」,就打算记录下来了。虽然是 WWDC 2013 的老视频了,但是我还是有学到很多。

推荐播客 弱弱的我

感谢阅读。