日期时间格式化及iOS FormatStyle Tips

cover

软件开发中有一个小问题,并不困难,但是非常繁杂,一不小心就会出错。这个问题就是“日期时间格式化”。为了说明这个问题,这篇文章会从 时间时间戳 的定义开始,提供一个方便的公式,帮助你理解其中的概念,写出准确的逻辑。同时,文章会介绍一些关于iOS 15中新增的FormatStyle API的内容。

时间和时间戳

通常,我们提到日期和时间,第一反应是“2022年9月7日10时00分”这种“时间描述”。这个日期时间的描述虽然也是绝对的,但是是不完备的。这个不完备并不是指它不够精确,而是它缺乏信息,无法将其转化成物理意义上的时间戳。具体来说,上面的描述缺乏“时区”这个关键信息。

概念

为了避免歧义,我们先明确两个词的概念:

你可能会发现下面这两个词和“官方”解释不同,但是我认为这样定义是更方便使用和符合直觉的,所以使用如下定义。(也可以使用“时间表示”和“时间”来指代,但是我觉得当前的方式更符合直觉。)

  • 时间:日期和时间的表示,如:太平洋时间2022年9月7日10时00分。这个表示是可读的,有语义的,给人看的。在不同地区可能有不同的形式(比如是否使用24小时制,月份使用数字还是英语缩写等)。
  • 时间戳:绝对时间,timestamp,如:1662570000。时间戳对于没有接触过编程的人可能会有些陌生。通常在程序中提到“时间戳(timestamp)”都是指的Unix timestamp,具体是指从 UTC时间 1970年1月1日 到现在的秒数或毫秒数(取决于具体实现,但是概念一致)。

时间戳和时间的最大区别在于,时间戳是绝对时间,在世界任何地方,同一时间的时间戳都是一致的。但是时间可能不同。所以时间戳是对物理时间的表示,而时间是人们从使用角度定义的对物理时间的表示

理解时间和时间戳的概念是理解时间日期格式化的前提,否则在时间相互转换的过程中就很容易犯错,有些时候问题并不是那么容易被发现。容易被忽视的原因是我们通常见到的时间都是时间表示,并且通常是并不显示指定时区的(时区是隐含的当前时区)。我们可以使用一个公式来理解这两个概念,时间戳 + 时区 + 格式 = 时间。格式通常是显而易见的,所以我们可以简化成:时间戳 + 时区 = 时间

带有时区的时间表示,就可以指代绝对物理时间(可以用作时间戳,这里的时间戳就是“官方”解释)。

举个例子:

apple 2022 event

苹果官网对于2022年苹果秋季发布会时间的表示是:9/7 at 10 a.m. PT,太平洋时间9月7日上午十点。这就是一个完备的时间日期表示,指一个绝对的物理时间。转换成时间戳(秒)就是:1662570000。如果我们想把这个时间戳转换成 yyyy-MM-dd HH:mm:ss 这种格式的时间,会是什么结果呢?

如果我们直接使用Swift提供的API,得到的结果是这样的:

1
2
3
4
5
6
7
8
9
10
11
let eventTimestamp = Date(timeIntervalSince1970: 1662570000)
eventTimestamp.formatted(
Date.ISO8601FormatStyle(dateSeparator: .dash,
dateTimeSeparator: .space,
timeZone: .current)
.year()
.month()
.day()
.time(includingFractionalSeconds: false)
)
// 2022-09-08 01:00:00

这段代码看起来不太美观,后面会解释。

可以看到输出的结果和原来的表示并不相同(不考虑格式),这就是时区的原因了。上面的代码指定了使用当前时区,而代码执行的时区是中国,那么得到的就是苹果发布会的中国时间。所以,在进行时间日期转换(格式化)的时候,一定要注意转换过程使用的时区是什么。并且各个语言提供的API使用的默认时区以及在不同平台上都可能出现不同,所以在使用API之前,一定要仔细进行了解。上面例子中,如果要得到等价于原来的时间:9/7 at 10 a.m. PT,应该怎么修改代码呢?当然是修改时区:

1
2
3
4
5
6
7
8
9
10
11
let eventTimestamp = Date(timeIntervalSince1970: 1662570000)
eventTimestamp.formatted(
Date.ISO8601FormatStyle(dateSeparator: .dash,
dateTimeSeparator: .space,
timeZone: .init(identifier: "PST")!)
.year()
.month()
.day()
.time(includingFractionalSeconds: false)
)
// 2022-09-07 10:00:00

One more thing…

如果你查看时区地图(from 维基百科),你会发现硅谷的时区是UTC-8,而中国的时区是UTC+8,两者相差16个时区,那么中国的9月8日凌晨1点不应该对应硅谷的9月7日上午9点吗,为什么结果是上午10点?

这不得不提另一个容易被忽略的点,夏令时/冬令时。硅谷所在的地区是实行夏令时制度的,而9月7日仍是夏令时的范围,所以硅谷虽然在UTC-8的时区,但是因为夏令时提前了一个小时,所以当地时间和UTC-7相同。和北京时间相差15个小时。而这也是上面代码使用PST而不是UTC-8的原因。PST指的是太平洋标准时间,作为时区的标识时,会被解析成“太平洋时区”,软件在执行时会根据时区和日期自动应用夏令时,所以就可以得到正确的结果了。

苹果在发布会时间的文案上也是非常细节的,使用了PT而不是PST。以下是两者的区别:

  • PT : Pacific Time / Pacific Time Zone,太平洋时间,UTC-8 或者 UTC-7。该时区使用夏令时制度,在夏令时和冬令时期间,时区缩写分别为:PDT (Pacific Daylight Time) 和 PST (Pacific Standard Time)。根据日期时间不同,将分别表示PDT和PST。
  • PST : Pacific Standard Time,太平洋标准时间,UTC-8。在软件开发中,我们会使用PST作为标识符来创建时区实例,PST就可以用来指定太平洋标准时间。而PT并不是支持的标识符。(也就是在软件开发领域,PST就是PT,并且没有PDT)

需要注意的是,时区的缩写并不是ISO标准,而是一种习惯用法。虽然在软件开发时我们会使用缩写指定时区,但是使用时也需要经过测试,避免歧义。

IOS8601

ISO8601 是一个关于日期和时间表示的国际标准。本文提到这个标准仅用来说明下一小节的FormatStyle API,不会涵盖太多标准的内容,详细内容可以查看维基百科页面。

ISO8601涵盖了日期时间的不同表示方法,以及针对重复、时间间隔等场景的表示方法。是一种国际通用的标准。不同的编程语言在实现时间日期相关的接口和工具时,都(没有验证)会提供符合该标准的格式化和解析方法。命名通常会包含iSO、iSO8601等。这个标准中,最常用的就是日期时间表示法,以下是一个例子(苹果2022秋季发布会时间):

1
2022-09-07T17:00:00Z

ISO8601默认使用UTC时区描述时间,并在结尾使用Z表示。如果需要指定其他时区,可以使用以下格式:

1
2
3
2022-09-08T01:00:00+08:00
2022-09-08T01:00:00+0800
2022-09-08T01:00:00+08

如果在上下文中不指定时区,提及ISO8601格式时,都指的是UTC时区。

iOS 15 FormatStyle API

FormatStyle 是 iOS 15 新增的时间日期API,用来格式化时间戳和解析时间。FormatStyle 默认会根据设备的地区不同,使用符合当地规则的表示方法。

FormatStyle 也提供了对 ISO8601的支持,以下代码就可以非常轻松的实现 ISO8601 格式化:

1
2
3
4
5
let date = Date(timeIntervalSince1970: 1662570000)
let formated = date.formatted(.iso8601) // 2022-09-07T17:00:00Z
let formatedDate = date.formatted(.iso8601.year().month().day()) // 2022-09-07
let formatedDate = date.formatted(.iso8601.year().month().day().time(includingFractionalSeconds: false).dateTimeSeparator(.space)) // 2022-09-07 17:00:00
let formatedDate = date.formatted(.iso8601.time(includingFractionalSeconds: false)) // 17:00:00

但是这里有一个不太方便的地方,如果我们使用 .iso8601 这个属性获取对应 ISO8601 的 FormatStyle 实例,时区将会被默认指定为 UTC 时区,并且没有提供链式的修改时区的方法。如果我们只是想格式化日期,那么得到的永远都是 UTC 时区的日期,可能会忽略这个问题导致错误。这个策略似乎是普遍存在的做法,JavaScript也提供了 ISO8601 格式化方法,并且也只支持 UTC 时区(好在 JS 的文档写明了这一点,而 iOS 的没有)。

所以,如果我们想得到本地时区的 IOS8601 格式的时间,就需要本文开头那个比较丑陋的写法,先创建 ISO8601FormatStyle 实例,再修改时区。

1
2
3
4
5
6
7
8
9
10
11
let eventTimestamp = Date(timeIntervalSince1970: 1662570000)
eventTimestamp.formatted(
Date.ISO8601FormatStyle(dateSeparator: .dash,
dateTimeSeparator: .space,
timeZone: .current)
.year()
.month()
.day()
.time(includingFractionalSeconds: false)
)
// 2022-09-08 01:00:00

总结

本文从介绍时间和时间戳的概念开始,解释日期时间格式化中时区的重要性以及容易被忽略的特点。结合实际例子介绍了在格式化的过程中时区参数的影响,以及应该如何使用时区。其中最重要的就是在进行转换(格式化/解析)时需要注意时区这个参数,希望这个公式能帮助你理解它: 时间戳 + 时区 = 时间

参考