本文是 WWDC 2013 《Solutions to Common Date and Time Challenges》 视频的学习笔记。
我自己在工作中第一次接触与时间日期处理相关的需求时,下意识地打开搜索引擎进行搜索,结果发现自己要与 Calendar
、Date
、DateFormatter
、DateComponents
和 TimeZone
这五个家伙打交道。我人都差点傻了,由此可见:iOS 中的时间日期处理不是想象中的那么容易。
Date、DateFormatter、DateComponents 和 TimeZone
Date
是什么呢?让我们看看 Apple 官方的解释:
Date
represents a single point in time.
ADate
is independent of a particular calendar or time zone. To represent aDate
to a user, you must interpret it in the context of aCalendar
.
Date
表示一个时间点,它与某个特定的历法或时区无关。如果要把 Date
展示给用户,我们必须以某个历法为背景来解释它。Date
包含了一个属性,这个属性存储的是相对于参照日期的秒数。至于这个参照日期是什么,其实不重要。官方 API 里有提供三个参照日期,你自己也可以自定义所需的参照日期。
1 | // 此时的参照时间为:当前的时间和日期 |
当在业务中处理时间日期时,后端往往提供的是一段描述时间日期的字符串。这时候 DateFormatter
就派上用场了。
1 | // 如果不单独设置 DateFormatter 的 calendar 属性,DateFormatter 会默认使用用户当前使用的 calendar 来把 Date 解释成符合该历法的表示时间日期的字符串 |
除了把后端返回的表示时间日期的字符串恰当地显示给用户之外,我还遇到了时间日期的比较问题。此时,用来格式化的这串字符串的 dateFormatter 需要多一步设置:
1 | var dateFormatter = DateFormatter() |
这是在 UTC+0 时区里理解这个字符串代表的 Date
。举例如下:
1 | var dateFormatter = DateFormatter() |
如果使用 dateFormatter 的 timeZone 默认值,即用户当前的时区,举个例子,东八区:
1 | var dateFormatter = DateFormatter() |
在东八区里理解这个字符串代表的 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. ADateComponents
is not required to define all the component fields.
When a new instance ofDateComponents
is created, the date components are set tonil
.
DateComponents
以可扩展的结构化方式来封装日期的组成部分。日期的组成部分包括:年、月、日、时、分和秒等。它也可以用来表示一段持续时间。当创建一个 DateComponents
的实例时,该实例所有的日期组成部分都设置为 nil。
1 | var dateComponent = DateComponents() |
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 | var cal = Calendar.current |
计算明天的开始时刻:
1 | let sometimeTommorrow = cal.date(byAdding: .day, value: 1, to: todayStart) ?? Date() |
把 Date 设置成一个具体时间
比如,我想设置成今天的 11 点 30 分:
1 | var date = Date() |
这个 Date 是在今天吗?
Calendar
提供如下的 API,方便开发者使用。
1 | // 判断 date 是否在今天 |
需要记住的是:以上函数返回的 Bool 值是会随着时间推移发生变化的。
Date 的比较
Date
本身提供一些供比较时使用的 API:
1 | public static func == (lhs: Date, rhs: Date) -> Bool |
但实际上,这些比较方法的比较粒度太粗,我们还需要更细粒度的比较方法。Calendar
正好提供这个比较方法:
1 | // 输出: 2020-04-21 02:59:52 +0000 |
更多的比较粒度可以参考文档。
Timeless Date 和 Dateless Time
什么是 “Timeless Date” 呢?Date
是包含时间(例:11:30)和日期(2020 年 04 月 21 日)。正如上文所提到的,如果要表示生日的话,我们只需要日期而已。
在这种情况下,建议使用 DateComponents 或者自定义一个 model。
1 | // 使用 DateComponents |
如果自定义一个记录生日的 model 的话,我们需要给这个 model 添加一个属性来记录这个日期对应的历法。如果脱离了历法,2000、08 和 05 就真的只是数字而已。
在这种情况下,不要使用用户当前使用的历法。
举个例子,用户现在使用的历法是公历,我记录下用户的生日为公历 2000 年 08 月 05 日。但如果用户把使用的历法改为民国纪年,时间就会出现一些偏差:
1 | // 采用民国纪年 |
注:民国纪年法与公历纪年法的换算公式
设 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 | // 此时的公历时间为: 2020 年 4 月 21 日 14:21:05 |
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, ornil
if a result could not be found.
可能让人比较困惑的是 matchingPolicy
这个参数。总共有四种 matchingPolicy
,分别是:nextTime
、nextTimePreservingSmallerComponents
、previousTimePreservingSmallerComponents
和 strict
。
让我们来举个具体的例子来理解这四种 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 | // 辅助函数 |
matchingPolicy: strict
如果要匹配的时间在这一天并不存在,则这种 matchingPolicy 会尽可能从未来的时间/过去的时间寻找一个精准匹配,即 3 月 9 日 02:30。这就糟糕了🤦♂️,可能会导致他旷工!
1 | let next = calendar.nextDate(after: dstDate!, |
matchingPolicy: nextTimePreservingSmallerComponents
如果要匹配的时间在这一天并不存在,则这种 matchingPolicy 会选择下一个存在的时间,并保留 matchingComponents 中更小的 component:minute = 30
。这样匹配的结果是:3 月 8 日 03:30。噢😯,亲爱的朋友,你已经迟到了!
1 | let next = calendar.nextDate(after: dstDate!, |
matchingPolicy: previousTimePreservingSmallerComponents
如果要匹配的时间在这一天并不存在,则这种 matchingPolicy 会选择上一个存在的时间,并保留 matchingComponents 中更小的 component:minute = 30
。这样的匹配的结果是:3 月 8 日 01:30。这也许是个不错的结果🧐,他离上班的时间正好还有一个小时,刚刚好!
1 | let next = calendar.nextDate(after: dstDate!, |
可能,我这位朋友真正想要的是并不是凌晨两点半,而是开始工作前的一小时。
结语
我是听完 iOS 開發生平大坑之 DateFormatter 这期播客,按图索骥找到这个 13 年的老视频。觉得「好记性不如烂笔头」,就打算记录下来了。虽然是 WWDC 2013 的老视频了,但是我还是有学到很多。
推荐播客 弱弱的我。
感谢阅读。