一段YYYY-MM-dd和一段yyyy-MM-dd,在模拟器里跑 364 天都长得一模一样,偏偏在每年最后一周和第一周偷偷换脸。我见过最典型的一次是:某个项目的对账脚本按YYYY-MM-dd把交易按天分组,结果年末那几天的记录被塞进了下一年的桶里,第二天运营发现"跨年多出来一天的流水",追了两天才发现不是时区、不是夏令时、也不是数据库存的 UTC 转本地出错,纯粹是那颗大写的Y。这篇就把 iOS 上时间格式化里Y与y的区别、它背后那套格式化模式串的语义规则、以及一串长得很像的兄弟坑,一次讲透。适合已经能写DateFormatter但没细究过模式字符语义的 iOS 开发,也适合正在定义接口日期字段约定、需要跟后端和移动端对齐格式的同学。看完你至少能做到两件事:知道为什么大写Y在跨年时会把日期算错年,以及在自己的工程里用一套可复现的脚本把所有这类错位日期一次性揪出来。
1. 从一次跨年错位说起:大写 Y 到底错在哪
1.1 2021 年元旦的订单,被记到了 2020 年
先把现象摆出来。用系统自带的格式化器,分别用YYYY-MM-dd和yyyy-MM-dd去格式化 2021 年 1 月 1 日这一天,两次输出分别是2020-01-01和2021-01-01。年份差了整整一年,月份和日期却纹丝不动。这不是格式化器坏了,也不是时区没设对,而是这两个模式字符表达的压根不是一个概念:小写y是日历年(calendar year),也就是我们日常说话时用的那个"年份";大写Y是周历年(week-based year),它的取值不取决于这一天落在哪个公历年份里,而取决于这一天属于哪一年的第几周。
2021 年 1 月 1 日是周五。按 ISO 8601 的周历规则,一周从周一开始、周日结束,且"一年中的第一周"必须是包含该年至少四天的那一周。往前数到周一,是 2020 年 12 月 28 日;这一周一共七天,其中 12 月 28 日到 12 月 31 日这四天属于 2020 年,1 月 1 日到 1 月 3 日这三天属于 2021 年。四天对三天,四天在 2020 年,所以整周被判定为2020 年的第 53 周。于是 2021 年 1 月 1 日的周历年就是 2020,大写Y输出 2020,完全符合它自己的定义。
问题在于,绝大多数人写下YYYY-MM-dd的时候,脑子里想的是"我要一个年月日",而不是"我要周历年 + 日历月 + 日历日"。这两个参照系混在同一个模式串里,产出的字符串在跨年边界上必然是错的。更麻烦的是这个错误的时间窗口非常窄——一年里只有元旦前后和年末那几天共计十来天会出问题,平时怎么测都是对的,等它炸的时候往往已经在生产环境里跑了一整年。
1.2 错位窗口的规律:年末往前走,年初往后错
把规律摸清楚之后就好判断了。我用一段脚本遍历了 1990 到 2050 年的每一天,只挑出两种格式输出不一致的日期,结论很稳定:
| 场景类型 | 典型日期 | 小写 yyyy 输出 | 大写 YYYY 输出 | 错位方向 |
|---|---|---|---|---|
| 年末落在下一年的第 1 周 | 2019-12-30 | 2019-12-30 | 2020-12-30 | 年份被推后一年 |
| 年末落在下一年的第 1 周 | 2024-12-31 | 2024-12-31 | 2025-12-31 | 年份被推后一年 |
| 年初落在上一年的最后一周 | 2021-01-01 | 2021-01-01 | 2020-01-01 | 年份被拉回一年 |
| 年初落在上一年的最后一周 | 2023-01-01 | 2023-01-01 | 2022-01-01 | 年份被拉回一年 |
| 年初落在上一年的第 53 周 | 2027-01-02 | 2027-01-02 | 2026-01-02 | 年份被拉回一年 |
可以看出错位只会发生在两个方向:年末的几天可能被标成"明年",年初的几天可能被标成"去年"。中间那 350 多天,两种写法输出完全一致,这就是为什么这个问题能安然通过代码评审、通过 QA、通过灰度,直到某一年的 12 月 30 日才爆发。
还有一个隐藏的坑:出错的日期会被打上一个看似合法的日期字符串。2020-12-30是个真实存在的日期,它不会在解析时报错,不会触发nil,数据库也照收不误。它唯一的问题是——它指向的那一天,跟实际业务发生的那一天,差了整整一年。这种"静默失败"比崩溃难查十倍。
2. 模式串里的字母不是随便挑的:y、Y、u 的语义分工
2.1 Unicode 模式串里每个字母都有明确身份
iOS 的DateFormatter底层走的是 ICU(International Components for Unicode)这套实现,模式串遵循 Unicode TR35 里定义的日期字段字符表。这张表里同一个"年"的概念被拆成了好几个字符,各管一摊:
| 字符 | 名称 | 含义 | 典型输出 |
|---|---|---|---|
y | year | 日历年,最常用的那个"年" | 2023 |
Y | year of week of year | 周历年,取决于这一周归属哪一年 | 2022 或 2023 |
u | extended year | 扩展年,可表示公元前的负数年份,无纪元概念 | 2023 / -44 |
U | cyclic year name | 循环纪年名,用于六十甲子这类纪年 | 视日历而定 |
r | related Gregorian year | 关联公历年,用于非公历日历的年份映射 | 2023 |
y和Y差在参照系,y和u差在纪元处理。日常业务里 99% 的场景应该用y;只有在做周报、排班表、ISO 周编号这类以周为最小单位的业务时,Y才是正确的选择。这里的关键判断标准不是"哪个更常见",而是"我的数据单位是日还是周"。如果数据单位是日,用Y就是在拿周的口径去描述日的属性,必然错位。
u用得少但值得知道它的存在。如果你的场景需要处理公元前的日期(比如历史数据、天文计算),y在某些日历下会输出纪元信息或者干脆不给负号,u则始终输出带符号的扩展年。业务系统里基本碰不到,但看到别人用u不要以为是打错了。
2.2 大写 Y 必须配上 w 和 e 才是一个自洽的组合
真正理解了Y的语义之后,会发现一个更本质的问题:YYYY-MM-dd这个字符串组合本身就是不自洽的。它把周历年的年份,跟日历年的月份和日期,硬拼到一起。这就像把"第 3 季度"和"12 月 25 日"放在同一个句子里——单看每个部分都对,拼起来没有意义。
如果你确实想表达 ISO 周编号,正确的写法是把整套字段都切到周历坐标系里:
let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = Calendar(identifier: .iso8601) formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "YYYY-'W'ww" // 2024-W01这里Y是周历年,w是这一年的第几周(两位补零),中间的W用单引号包起来当字面量输出,因为不带引号的大写W是"月中第几周"的意思,语义完全不同。如果要连周几一起输出,再加e(本地化的数字周几)或E(周几的名称):
formatter.dateFormat = "YYYY-'W'ww-e" // 2024-W01-5把这些凑齐之后,你会发现输出是可逆的:拿2024-W01-5去解析,能准确还原回那一天。而2020-12-30这种由YYYY-MM-dd生成的字符串,解析回来的时候会让人抓狂——到底是 2020 年 12 月 30 日,还是某个周历年的第 30 天?解析器的行为在这种缺字段的 pattern 下并不总是符合直觉,跨版本、跨 locale 都可能变。
2.3 反过来,这些场景下大写 Y 才是对的
也不必把Y当成洪水猛兽。它存在的意义就是解决"按周归档"的问题。以下这些场景用Y配合w是正确且必要的:
- 周报系统:一份周报的归属周期是"2024 年第 1 周",跨年那周到底算去年还是今年,必须用周历口径判断,用日历年会导致 12 月 30 日和 1 月 2 日落在同一份周报里却归到两个不同年份。
- 排班表:按周排班的场景,一周必须整体归属同一年,否则一周被拆成两段跨年统计。
- ISO 周编号的对外标识:某些行业规范要求用
2024-W01这样的标识,这时候Y和w是标准的一部分,不能换。 - 按周聚合的统计报表:如果后端按周分组,前端的周选择器必须用同一套周历规则,否则用户选"2024 年第 1 周",前端算出来的日期范围和后端算出来的对不上。
判断标准可以简化成一句话:如果这个字段的语义单位是"周",用Y;如果是"日",用y。混用就是在制造 bug。
3. 长得很像的兄弟坑:DD、hh、mm、ss 各自的地雷
3.1 一个字母的大小写能改写整个含义
YYYY不是孤例。模式串里大小写敏感的组合还有好几对,每一对都有人踩过:
| 你写的 | 你以为的意思 | 实际含义 | 正确写法 |
|---|---|---|---|
YYYY | 年份 | 周历年 | yyyy |
DD | 日期 | 一年中的第几天(001-366) | dd |
hh | 24 小时制的小时 | 12 小时制的小时(01-12) | HH |
HH配a | 12 小时制带 AM/PM | 24 小时制 + 一个多余的 AM/PM | hh配a |
mm | 月份 | 分钟 | MM |
ss | 毫秒 | 秒 | SSS(毫秒是三个 S) |
DD这个坑的迷惑性极强,因为它在一年的大部分时间里输出的数字"看起来像个日期"。比如 1 月 5 日用DD输出005,1 月 15 日输出015,前缀零一去掉还真像5和15。等到 2 月 1 日,DD输出032,你才会发现不对——但这时候代码可能已经上线了。更隐蔽的是 1 月 1 日到 1 月 9 日,DD输出001到009,如果格式串是yyyy-MM-DD,你会得到2024-01-001这种既像日期又不像日期的字符串,解析侧如果做了宽松处理还可能蒙混过关。
mm和MM的混淆通常不会静默出错,因为月份位置出现00到59这种值很容易被发现,但它造成的排查成本不低——看到2024-00-15你会先怀疑数据源,最后才想到是模式串写反了。
3.2 时区不显式设置,等于把结果交给设备
DateFormatter的timeZone属性默认取当前设备的系统时区。这意味着同一段代码,在设置里把时区改过的设备上跑出来的结果可能跟你预期的差好几个小时,跨日界的时候直接差一天。做日期字符串输出(尤其是要发给服务端、写进数据库、做对账的),时区必须显式指定,不要依赖默认值。
formatter.timeZone = TimeZone(secondsFromGMT: 0) // 明确用 UTC // 或者按业务约定用固定偏移,别用 TimeZone.current这里有个容易搞混的点:TimeZone.current拿到的是设备当前时区,用户手动改过系统时区它就变;而TimeZone.autoupdatingCurrent更夸张,它会在时区变化时实时跟着变。如果你的格式化器是缓存的单例,用autoupdatingCurrent会导致同一个 formatter 在不同时刻输出不同结果,排查起来极其痛苦。涉及持久化的日期字符串,一律用固定时区。
3.3 locale 会把你的年份整个换掉
这一条比YYYY更容易被忽略,因为它的触发条件不是日期,而是用户的系统语言和地区设置。DateFormatter在格式化时会参考locale关联的日历体系。如果用户把地区设成了默认使用非公历日历的区域,那么即使你的模式串写的是yyyy,输出的年份也可能是那个历法下的年份,而不是公历年。
这类问题在测试阶段几乎不可能被发现,因为测试机和开发机都是默认设置。它只会在特定用户群体里出现,表现为"年份差了好几百年"这种一看就很离谱、但又不确定从哪查起的现象。
解法是给所有机器可读的格式化器强制指定locale:
let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = Calendar(identifier: .gregorian) formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"en_US_POSIX这个 locale 标识符是专门为"固定格式的机器可读输出"设计的,它锁定了公历、固定的数字符号、24 小时制,不受用户地区设置影响。同时把calendar显式设成公历,双保险。注意顺序:先设 locale 和 calendar,最后设 dateFormat。如果反过来先设dateFormat再设locale,某些版本上dateFormat会被 locale 的默认格式覆盖掉,你会拿到一个完全不是自己写的格式。
3.4 面向用户的展示格式,反而不该硬编码模式串
上面说的是机器可读的场景。如果这个日期是要显示给用户看的,那硬编码yyyy年MM月dd日反而是错的——不同地区的用户习惯的日期顺序、分隔符、甚至数字符号都不一样。这种场景应该用模板 API 让系统去决定:
let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("yMMMd")setLocalizedDateFormatFromTemplate接受的是"我关心哪些字段",而不是"我要什么顺序",系统会按当前 locale 的习惯重新排列。它的好处是自动适应用户习惯,代价是输出不可预测,所以绝对不能拿它的结果去做解析或者做 key。
一句话区分:要机器读的,用固定 locale 加固定模式串;要人读的,用模板 API 交给系统。
4. 把这套检查跑起来:用脚本把所有错位日期挖出来
4.1 在 Playground 里做一次跨年边界穷举
光知道原理不够,得能在自己的工程里验证。下面这段代码可以直接扔进 Playground 跑,它会遍历指定年份区间内每一天,把两种格式输出不一致的日期全部列出来:
import Foundation let calendar = Calendar(identifier: .iso8601) func makeFormatter(_ pattern: String) -> DateFormatter { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") f.calendar = calendar f.timeZone = TimeZone(secondsFromGMT: 0) f.dateFormat = pattern return f } let lower = makeFormatter("yyyy-MM-dd") let upper = makeFormatter("YYYY-MM-dd") var mismatches: [(Date, String, String)] = [] for year in 1990...2050 { guard let start = calendar.date(from: DateComponents(year: year, month: 1, day: 1)), let end = calendar.date(from: DateComponents(year: year, month: 12, day: 31)) else { continue } var cursor = start while cursor <= end { let a = lower.string(from: cursor) let b = upper.string(from: cursor) if a != b { mismatches.append((cursor, a, b)) } guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } cursor = next } } print("不一致的日期共 \(mismatches.count) 天") for (date, a, b) in mismatches.prefix(10) { print("\(a) vs \(b)") }跑完你会看到 61 年里大概有 200 多天会错位,平均每年 3 到 4 天,全部集中在 12 月 28 日之后和 1 月 4 日之前。这个数量级很有意思——它既少到日常测试碰不到,又多到在一年周期的生产系统里几乎必然会被触发一次。
4.2 把检查固化成单元测试
Playground 验证完就完了,下次有人改代码还是可能写回YYYY。真正有效的做法是把它变成一条能进 CI 的断言。测试的目标不是"格式化结果等于某个固定字符串",而是"两种模式串在全年范围内必须完全一致"——这个断言天然覆盖了所有跨年边界,不需要你手写测试用例:
func testWeekBasedYearMustNotBeUsedForCalendarDates() { let calendar = Calendar(identifier: .iso8601) let lower = makeFormatter("yyyy-MM-dd") let upper = makeFormatter("YYYY-MM-dd") for year in 2020...2030 { guard let start = calendar.date(from: DateComponents(year: year, month: 1, day: 1)), let end = calendar.date(from: DateComponents(year: year, month: 12, day: 31)) else { XCTFail("日期构造失败") return } var cursor = start while cursor <= end { XCTAssertEqual(lower.string(from: cursor), upper.string(from: cursor), "\(lower.string(from: cursor)) 处两种模式串结果不一致") guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } cursor = next } } }这条测试一定会失败,失败本身就是结论——它在用最直白的方式告诉你"这两种写法不等价"。反过来说,如果你把它改成断言"两种写法应当一致",那就成了一条反向的护栏:任何人在代码里引入YYYY做日历日期输出,只要对应的用例覆盖到跨年区间,就会红。
4.3 用静态扫描拦住这类回归
单元测试依赖路径覆盖,更稳的是在代码扫描层面直接禁掉。你可以在构建流程里加一条简单的文本检查,扫描源码中所有dateFormat赋值语句,把包含YYYY、DD、hh但缺a、单S这类高危模式串的位置打印出来人工过一遍。这类检查用脚本几十行就能写完,收益却很高,因为模式串一旦写错,出错时机是每年固定的那几天,人工评审几乎兜不住。
我自己的习惯是维护一张"禁止模式串"清单:YYYY-MM-dd、yyyy-MM-DD、yyyy-mm-dd、dd/MM/yyyy混用大小写这几种,一旦在代码里出现就要求作者说明理由。真需要用周历的,必须同时在同一个 formatter 上出现w,这条规则简单、可自动化,比我记住所有语义细节可靠得多。
5. 封装一层更稳的格式化入口
5.1 缓存 formatter,但别缓存"半成品"
DateFormatter的初始化成本不低,内部要解析 locale、构建 ICU 对象,量级在几十微秒到毫秒之间。在列表滚动、表格批量渲染这类场景里,每行let f = DateFormatter()是实打实的掉帧来源。常见做法是按模式串缓存:
final class DateFormatters { static let shared = DateFormatters() private var cache: [String: DateFormatter] = [:] private let lock = NSLock() func fixed(_ pattern: String) -> DateFormatter { lock.lock() defer { lock.unlock() } if let cached = cache[pattern] { return cached } let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") f.calendar = Calendar(identifier: .gregorian) f.timeZone = TimeZone(secondsFromGMT: 0) f.dateFormat = pattern cache[pattern] = f return f } }这里几个细节值得说一下。第一,加锁保护的是缓存字典的读写,不是 formatter 的使用。系统的DateFormatter在格式化本身是线程安全的,但修改属性不是,所以只要构造完成后不再改属性,多线程并发调用string(from:)没问题。第二,格式化器构造完成后就冻结,不给外部任何修改dateFormat的机会,否则缓存会变成共享可变状态,一个问题变成十个。第三,locale、calendar、timeZone 三个全写死,不要留可配置的口子,需要不同配置就换一个 key。
5.2 优先用系统提供的专用类型
能不用自定义模式串的场景,尽量不用。系统提供了几个语义明确的类型,比手写 pattern 安全得多:
ISO8601DateFormatter专门处理 ISO 8601 格式,默认输出带时区偏移的时间戳,通过options控制精度:
let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] iso.timeZone = TimeZone(secondsFromGMT: 0) iso.string(from: Date()) // 2024-01-05T08:30:00Z它的优点是格式固定、不会写错;缺点是不能自定义成yyyy-MM-dd这种只有日期的形式,所以只适合接口传输用的完整时间戳。
如果最低支持版本够高,Date.ISO8601FormatStyle更现代一些,可以链式组合:
let s = Date().formatted( .iso8601 .year().month().day() .dateSeparator(.dash) )它同样不会出现Y和y混淆的问题,因为年份只有一种语义。这类 API 值得在新代码里优先考虑,把模式串这层手写风险彻底去掉。
5.3 格式化和解析必须共用同一套 pattern
一个容易被忽略的对称性问题:很多项目里,把日期转成字符串和把字符串转回日期,是两段不同的人、在不同的时间、用不同的 pattern 写的。格式化用yyyy-MM-dd,解析那边抄成了YYYY-MM-dd,单看都"能跑",但两边对同一天的理解可能不一致。
稳妥的做法是把 pattern 定义成常量,格式化和解析共用:
enum DatePattern { static let dayOnly = "yyyy-MM-dd" static let dayTime = "yyyy-MM-dd HH:mm:ss" }更进一步,如果某个字段既可能被格式化也可能被解析,直接封成一个类型,对外只暴露string(from:)和date(from:)两个方法,pattern 锁在内部。这样即使有人后面想改格式,也只能改一处,不会出现"写进去是对的、读出来是错的"这种最难查的问题。
另外,解析侧一定要检查返回值。DateFormatter.date(from:)在解析失败时返回nil,很多人直接强解包或者用?? Date()兜底。后者尤其危险——它会把一个格式错误的字符串静默变成一个"当前时间",然后在业务逻辑里以完全错误的面目出现。解析失败就该走失败分支,宁可报错。
6. 和协作方对齐:格式约定比代码技巧更重要
6.1 跨端对齐时,大写 Y 是"传染性"的
同一个问题在别的技术栈里也存在。服务端很多日期格式化库同样区分日历年和周历年,模式字符也用小写y和大写Y。真正麻烦的是这种错位一旦出现在某一端,会被当成"约定"复制到其他端:后端用YYYY-MM-dd输出,前端照着文档也用YYYY-MM-dd解析,两端都错得一致,测试时因为都是同一批数据所以看不出问题,直到有一天某一端升级了库、行为变了,才炸出一堆跨年数据对不上。
所以接口文档里写日期格式的时候,最好把语义写清楚,而不是只给一个模式串。比如写成"日期字段使用公历年月日,格式yyyy-MM-dd,时区固定为 UTC,四位年份、两位月份、两位日期",比单纯贴一个 pattern 有信息量得多。有条件的话再加一条正向用例:"2024 年 1 月 1 日应输出2024-01-01",这个样例能直接把周历年的写法挡在门外——因为用YYYY写的话,2024 年 1 月 1 日会输出2024-01-01(这一年恰好没错位),样例失效。更狠的样例是 2021 年 1 月 1 日,正确输出2021-01-01,一旦有人用YYYY就会变成2020-01-01,一测就露馅。
我在实际配合里会挑三个日期作为对齐样例:1 月 1 日、12 月 31 日、以及一个普通日子。前两个卡住跨年边界,后一个验证常规格式。三个样例的成本极低,能挡掉绝大部分年份相关的格式争议。
6.2 提交前的自查清单
把前面这些收成一张可以贴在工位上的清单,每次碰到日期格式化就过一遍:
| 检查项 | 为什么 | 怎么改 |
|---|---|---|
模式串里有大写Y吗 | 周历年,跨年错位 | 换成yyyy,或补齐w和e |
有DD吗 | 这是一年第几天,不是几号 | 换成dd |
有hh但没配a吗 | 12 小时制会丢掉上下午信息 | 加a,或换HH |
有单独的S吗 | 单个S是十分之一秒,毫秒要三个 | 需要毫秒用SSS |
locale设了吗 | 不设会跟随用户地区,可能换历法 | 机器可读场景固定en_US_POSIX |
timeZone设了吗 | 不设跟随设备,跨日界会差一天 | 明确指定 UTC 或业务约定时区 |
calendar设了吗 | 防止 locale 带来的历法切换 | 显式设成公历 |
| 赋值顺序对吗 | 先locale后dateFormat | 调整赋值顺序 |
| 格式化与解析用同一个 pattern 吗 | 两边不一致会导致数据错位 | 抽成常量共用 |
这张表里最容易被跳过的是"赋值顺序"和"calendar是否显式设置",因为它们不像YYYY那样一眼能看出对错,而是取决于运行环境和系统版本。我个人的处理方式是:所有格式化器都从同一个工厂方法产出,配置全写在工厂里,业务代码只传 pattern,这样上面这些项永远不会漏。
最后再分享一个我在实际项目里养成的小习惯:凡是写日期格式化,先在 Playground 里把2021-01-01、2021-01-03、2020-12-31这三个日期各跑一遍,看输出跟预期是否一致。这三个日期分别覆盖了"年初落在上一年的周"、"年初最后一天落在上一年"、"年末落在下一年的周"三种错位形态,几十秒的验证成本,能省掉一次跨年通宵。踩过几次坑之后我基本不再相信"这个格式化写得没问题"的直觉了——日期这种东西,验证一次比想一百次靠谱。