「元旦那天的日报数据和昨天一模一样,但昨天的日报和前天的又对不上。」这是我在做一款数据看板类 App 时遇到的一个典型问题:两个统计模块在跨年前后各错了一天,查了半天接口、缓存、时区,最后定位到的差异只是格式化字符串里一个小小的字母——有人写成了大写YYYY-MM-dd。iOS 上的日期格式化看起来只是拼一串字母,实际上每个字母都对应 Unicode 日期模式规范里一个明确且互不等价的语义,大小写不同,含义就完全不同。这篇内容围绕yyyy-MM-dd和YYYY-MM-dd的取舍展开,把 Calendar、Locale、TimeZone 这几个隐藏变量一起拆开讲,给出可以直接复用的封装代码和排查清单。不管你是刚接触 iOS 开发、只会照抄格式串的新手,还是已经写过几年业务、想把日期处理彻底理顺的老手,下面这些内容应该都能省下你几个晚上的排查时间。
1. 从一个“元旦当天数据错乱”的线上问题说起
1.1 大写 Y 到底代表什么
先把结论摆出来:yyyy是calendar year(日历年),也就是我们日常理解的那个年份;YYYY是week-based year(周历年,也叫周所属年)。这两个概念在一年中绝大部分时间是重合的,所以你在三月份随手写一个YYYY-MM-dd,输出看起来完全正确,测试也过,代码就这么合进去了。
周历年的定义追溯于 ISO 8601 的周编号体系:一个“周”不是从 1 月 1 日算起,而是从某个固定的星期几算起,跨越了自然年的边界。也就是说,12 月 31 日所在的那一周,如果大部分天数落在下一年,那么这一整周(包括 12 月 31 日)在周历体系里属于下一年。反过来,1 月 1 日所在的那一周,如果大部分天数落在上一年,那 1 月 1 日的周历年就是上一年。
用一个生活化的类比:假设公司按“周”给销售算业绩,年末最后一周横跨了 12 月 31 日和 1 月 1 日。财务不会把这一周劈成两半算进两个年度,而是整周归到某一年。周历年就是这套记账逻辑在日期表示上的投影。所以YYYY更接近“这个日子归属于哪个业绩年度”,而yyyy是“这个日子在日历上挂着哪一年的牌子”。
两种语义都真实存在、都有用武之地,问题在于绝大多数场景我们需要的是后者,而YYYY的存在感又足够强,强到能让代码看起来毫无破绽地跑上大半年。
1.2 差异只在跨年周:几年数据摆在一起看
要理解这个坑有多隐蔽,我们得知道它的触发窗口到底有多窄。下面这组数据是我在项目里用en_US_POSIX加东八区实际跑出来的,格式串分别是yyyy-MM-dd和YYYY-MM-dd:
| 日期 | 星期 | yyyy-MM-dd 输出 | YYYY-MM-dd 输出 | 是否一致 |
|---|---|---|---|---|
| 2024-01-01 | 周一 | 2024-01-01 | 2024-01-01 | 一致 |
| 2023-01-01 | 周日 | 2023-01-01 | 2022-01-01 | 不一致 |
| 2022-01-01 | 周六 | 2022-01-01 | 2021-01-01 | 不一致 |
| 2021-01-01 | 周五 | 2021-01-01 | 2020-01-01 | 不一致 |
| 2020-12-31 | 周四 | 2020-12-31 | 2020-12-31 | 一致 |
| 2018-12-31 | 周一 | 2018-12-31 | 2019-12-31 | 不一致 |
| 2017-01-01 | 周日 | 2017-01-01 | 2016-01-01 | 不一致 |
规律非常清楚:每年的 1 月 1 日到 1 月 3 日、以及 12 月 29 日到 12 月 31 日这几天,是唯一可能出问题的时间窗口。如果你的代码在 3 月写完,11 月上线,一路平安无事,直到元旦凌晨定时任务跑起来,才发现落库的分区键全错位了——这就是为什么这个 bug 总能精确地在假期里爆炸。
注意 2021-01-01 那一行,它是往“回”错的:用YYYY输出成 2020,比实际年份还小。这个方向的反直觉程度更高,因为大家潜意识里觉得“格式化最多是精度问题,怎么会往前跳”。实际上 2020 年有 53 个 ISO 周,2021 年的 1 月 1 日到 1 月 3 日属于 2020 年的第 53 周,整周归 2020,所以周历年是 2020。
顺便说明,上面表格里的结果依赖于 Calendar 的周起始日设定。如果你把 Calendar 换成en_US(周日为一周第一天)而不是 ISO 体系,跨年那几天的归属会发生变化。这意味着YYYY的输出不仅取决于日期本身,还取决于 Calendar 配置——这一点我在第 2 章会展开。
1.3 整张大小写对照表,别只盯着 Y
把注意力只放在Y和y上是不够的。Unicode 日期模式串里,几乎所有字母都有大小写配对,而且每一对的语义都不同。我整理了一份日常最常踩的:
| 模式字符 | 含义 | 常见误用与后果 |
|---|---|---|
y/Y | 日历年 / 周历年 | 跨年周错位,最经典的一个 |
M/m | 月份 / 分钟 | 把分钟写成MM,输出会变成月份数字,时间全错 |
d/D | 月内日 / 年内第几天 | DD输出如365,拼进日期串直接不可读 |
H/h | 24 小时制 / 12 小时制 | hh在下午会输出03而不是15,且不带 AM/PM 就无法还原 |
W/w | 月内第几周 / 年内第几周 | 做周报统计时用错,维度直接跑偏 |
S | 毫秒(小数秒) | 写SS只有两位,高位会被吞掉 |
E/e | 星期名称 / 本地化的星期序号 | 需要数字星期却拿到“周一”“Monday” |
其中MM和mm的混用,在 iOS 上是第二高频的错误。HH:mm:ss是标准写法,但很多人手快写成HH:MM:ss,输出会变成类似14:03:27的诡异结果——因为中间的MM被解析成了月份。这种错误最讨厌的地方在于它不一定马上暴露:在某些时刻(比如分钟数刚好和月份数接近)看起来还挺正常,等你在别的时刻再测就完全对不上。
我记得有个团队排查过一个问题:某条日志的时间戳在上午看起来正常,下午就不对。查到最后发现格式串是h:mm:ss,压根没带a(AM/PM)标记,下午 3 点被格式化成3:20:11,和其他系统的 24 小时制数据混在一起排序就全乱了。
提示:不要试图用记忆去区分这些字母。把所有用到的格式串集中定义在一处常量文件里,用有语义的命名(比如
fullDateOnly、timestampWithMillis),每次新增格式串都要经过一次 code review,这个成本远低于线上排查。
2. 决定格式化结果的四个变量:格式串只是其中之一
2.1 Locale 与 Calendar:佛历、和历与 12 小时制
很多人以为dateFormat一旦设定,输出就确定了。实际上最终结果由格式串、Locale、Calendar、TimeZone四者共同决定,格式串只是其中之一,另外三个默认为当前用户环境——而用户环境是不可控的。
先说 Locale。DateFormatter如果不显式设置locale,默认取Locale.current。这带来两类问题。第一类是 12/24 小时制:如果系统设置里开了 24 小时制,某些风格的格式化输出会跟着变,你在测试机上看着是14:30,用户机上变成2:30 PM。第二类更隐蔽:某些地区的默认 Calendar 不是公历。用户在设置里把区域切到使用佛历的地方,yyyy可能输出 2567 这种数字;切到使用和历的区域,年份可能变成“令和六年”。这些场景在真实用户中占比很小,但只要存在,就会出现你完全无从复现的“日期显示错误”反馈。
解决方案是统一使用en_US_POSIX这个特殊的 Locale。它的作用不是“变成英文”,而是把 Locale 相关的可变因素全部锁死:公历、固定 24 小时制、固定数字符号、固定周起始日。它专门为“格式化字符串必须稳定可解析”的场景设计,苹果的开发者文档里专门用一节讲过为什么必须这么做,凡是格式串固定写死的场合都应该用它。
let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") // 关键:锁定区域行为 formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"注意:
en_US_POSIX只适用于格式串写死的情况。如果你的界面要按用户语言显示“2024年3月5日”这类本地化日期,应该用dateStyle/timeStyle,让系统去决定格式,这时候不要碰dateFormat。
再补一个容易忽略的点:dateStyle和dateFormat是互斥的,后设置的会覆盖先设置的。有人写了formatter.dateStyle = .medium然后又设dateFormat,结果dateStyle被悄悄清空,界面在英文环境下显示成纯数字日期,看起来像是“本地化失效了”,其实是自己把配置冲掉了。
2.2 TimeZone:比大小写更沉默的破坏者
如果排序一下 iOS 日期问题的破坏力,我会把 TimeZone 排第一,YYYY排第二——只是因为YYYY更隐蔽所以更有名。DateFormatter的timeZone默认是TimeZone.current,这意味着一份 UTC 时间戳,在不同时区的用户设备上会被格式化成不同的本地日期串。
举个具体场景:服务端返回1704038400这个时间戳,换算成东八区是 2024-01-01 00:00:00。你在东八区的设备上格式化成2024-01-01,完美。但这个用户飞到西五区,同一份数据就被格式化成2023-12-31,落到本地的“每日打卡”记录里,新年第一天的记录消失了,跑到上一年去了。
这类问题没有一劳永逸的方案,只有明确约定。我的做法是把时区分成两类:
| 数据类型 | 时区策略 | 理由 |
|---|---|---|
| 展示给用户的“本地时间” | 显式TimeZone.current | 用户要看的是自己所在时区的钟表时间 |
| 业务日期(生日、账单日、统计日) | 固定业务时区 | 这些日期有唯一业务含义,不能随设备漂移 |
| 日志、文件名、接口报文 | 固定 UTC 或固定业务时区 | 必须全局唯一,便于跨系统比对 |
| 时间戳存储 | 无所谓,存 Unix 时间戳 | 时间戳本身是绝对的,格式化时才注入时区 |
关键点是显式。不要依赖默认值,每一处格式化都问自己一句:这个时间代表的是“此刻的绝对时刻”还是“某个业务意义上的日子”?如果是后者,就必须固定时区。
2.3 解析方向的宽容度陷阱
上面讲的都是格式化方向(Date 转 String),解析方向(String 转 Date)有另一套坑。DateFormatter在解析时默认是宽松模式:格式串写yyyy-MM-dd,给它2024-02-31这种不存在的日期,它不会返回 nil,而是自动滚动到 3 月 2 日。
更麻烦的是,YYYY在解析方向上基本是个宣传单。你写formatter.dateFormat = "YYYY-MM-dd"去解析"2024-01-01",结果取决于这一天属于哪个周历年;如果解析失败,date(from:)返回 nil,你的代码如果没做判空,就会走进if let的 else 分支,用一个默认日期兜底,然后整条业务链静默错下去。
我的处理方式是在封装层里强制开启严格模式:
formatter.isLenient = false开启之后,2024-02-31会直接返回 nil 而不是滚动。这是我在做一份历史数据导入功能时被迫加上的——那批数据里有大量 2 月 30 日的手工录入错误,宽松解析把它们全变成 3 月初,错误一直到对账阶段才被发现,返工代价极大。
解析方向还有一条经验:永远不要用带YYYY的格式串去解析用户输入或接口数据。解析的场景几乎都是“我知道这个字符串是标准日期”,这种情况下yyyy是唯一合理的选择。
3. 落地实操:一套可直接复用的日期工具层
3.1 设计原则:单一出口、显式时区、格式常量集中管理
聊完原理,进入实际能抄的部分。先说我在多次重构之后固定下来的三条原则,它们是这套封装的基础:
第一,所有格式串必须有名字,不许内联。你在业务代码里搜dateFormat = "应该搜不到东西。所有格式串定义在一个 enum 里,命名体现语义而不是格式,比如.businessDateOnly而不是.yearMonthDay。这样将来要调整格式(比如业务需要加毫秒),只改一处。
第二,每个格式器实例在创建时就固定 Locale 和 TimeZone,之后不再改。这是从性能和线程安全两个角度同时考虑的,下面 3.3 会详细说。
第三,对外只暴露“带明确语义的方法”,不暴露 DateFormatter 实例。业务层调用应该是DateTool.string(from: date, format: .businessDateOnly),而不是自己 new 一个 formatter。前者你能保证所有细节一致,后者你保证不了。
3.2 完整代码实现
下面这套代码是我目前项目里在用的精简版,可以直接拿走:
enum DateFormatStyle: String { /// 业务日期,例如 2024-03-05 case businessDate = "yyyy-MM-dd" /// 带时间的完整时间戳,例如 2024-03-05 14:30:00 case timestamp = "yyyy-MM-dd HH:mm:ss" /// 带毫秒,用于日志 case timestampMillis = "yyyy-MM-dd HH:mm:ss.SSS" /// 周维度标识,例如 2024-W10 case weekBasedYear = "YYYY-'W'ww" /// 文件名安全格式,不含冒号 case fileNameSafe = "yyyyMMdd_HHmmss" } enum DateTool { /// 业务固定时区,所有跟"业务日期"相关的格式化都走这里 static let businessTimeZone: TimeZone = TimeZone(secondsFromGMT: 8 * 3600)! private static var cache: [String: DateFormatter] = [:] private static let lock = NSLock() private static func formatter(for style: DateFormatStyle, timeZone: TimeZone) -> DateFormatter { let key = style.rawValue + "|" + timeZone.identifier lock.lock() defer { lock.unlock() } if let cached = cache[key] { return cached } let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = timeZone f.dateFormat = style.rawValue f.isLenient = false cache[key] = f return f } /// Date -> String,时区必须显式传入 static func string(from date: Date, format style: DateFormatStyle, timeZone: TimeZone = businessTimeZone) -> String { formatter(for: style, timeZone: timeZone).string(from: date) } /// String -> Date,失败返回 nil,绝不返回兜底日期 static func date(from text: String, format style: DateFormatStyle, timeZone: TimeZone = businessTimeZone) -> Date? { formatter(for: style, timeZone: timeZone).date(from: text) } /// 判断两个时刻是否落在同一个业务日 static func isSameBusinessDay(_ a: Date, _ b: Date) -> Bool { let calendar = Calendar(identifier: .gregorian) var cal = calendar cal.timeZone = businessTimeZone return cal.isDate(a, inSameDayAs: b) } /// 业务日的起止时刻,常用于查询区间 static func businessDayRange(of date: Date) -> (start: Date, end: Date)? { var cal = Calendar(identifier: .gregorian) cal.timeZone = businessTimeZone guard let start = cal.startOfDay(for: date).date(byAdding: .day, value: 1, to: date) else { return nil } return (cal.startOfDay(for: date), start) } }几个设计点值得说明。
weekBasedYear这个 case 用的是YYYY-'W'ww,注意中间的单引号——在 Unicode 模式串里,单引号包裹的内容会被当作字面量原样输出,不会被当作模式字符。如果不加引号直接写W,它会被解释成“月内第几周”,输出的就不是你想要的东西了。字面量转义是一个高频细节,格式化人名、单位、分隔符时都会用到。
isLenient = false放在统一构造里,避免某个调用点忘记设置。
DateTool的缓存用NSLock保护。这里其实可以用串行队列或者os_unfair_lock,但NSLock的可读性最好,锁竞争在这个粒度上几乎不可能成为瓶颈。
注意:如果你的 App 支持多语言,
businessDate这种纯数字日期在界面上展示是没问题的,但如果要给用户看“3月5日 星期二”这类文案,请走DateFormatter的dateStyle+timeStyle,不要用自定义格式串硬拼,否则本地化一定会出问题。
3.3 性能与线程安全
DateFormatter的创建成本比大多数人想象的高。它内部要初始化 ICU 的格式化引擎,首次创建可能耗时到毫秒级,后续复用则非常廉价。我做过一个粗略对比:在一个 200 行的列表里,每行 cell 配置时新建一次DateFormatter,滚动时的掉帧肉眼可见;换成缓存复用之后完全平滑。
不过缓存也要注意粒度。如果按“格式串 + 时区”做 key,组合数量是可控的(一般不超过十几种);如果按“用户 ID + 格式串”做 key 那就失控了。上面代码里 key 的设计就是基于这个考虑。
线程安全方面,DateFormatter从较早的系统版本起就被官方标记为线程安全的,但有一个前提:创建配置完成之后不再修改。如果你的代码里出现“取出缓存实例,改一下 dateFormat,再用”的写法,那就等于把线程安全丢掉了。所以我在封装里把所有配置项都封在私有构造方法里,外部拿不到可变实例,从设计上杜绝这种误用。
还有一个常被忽略的开销来源:Calendar的计算。isDate(_:inSameDayAs:)、startOfDay(for:)这些方法内部要做时区和日历换算,比字符串格式化便宜,但也不适合放在滚动列表的高频路径里。如果列表里要显示“今天/昨天”这种相对文案,建议在数据层一次性算好,把结果放进 ViewModel,别在 cell 配置里算。
3.4 存量代码的排查与迁移
老项目里已有的YYYY-MM-dd怎么写,是更现实的问题。我的做法分三步。
第一步,全局搜索"YYYY这个字面量。搜索范围包括 Swift 文件、Objective-C 文件、JSON 配置文件、本地化字符串表。注意别漏掉那些从服务端下发的格式串——有些业务灵活配置的系统会把日期格式写在配置中心,这种要单独排查。
第二步,对每一个搜到的位置,判断它到底是“周维度场景”还是“日维度场景”。90% 的情况是日维度,直接改成小写。剩下 10% 才是需要仔细确认的,比如周报统计、按周分组的销售看板、ISO 周编号展示。
第三步,写一个校验用例固化下来。比如:
func testCrossYearWeekBoundary() { var comps = DateComponents() comps.year = 2021; comps.month = 1; comps.day = 1 var cal = Calendar(identifier: .gregorian) cal.timeZone = DateTool.businessTimeZone let date = cal.date(from: comps)! XCTAssertEqual(DateTool.string(from: date, format: .businessDate), "2021-01-01") // 周历年场景下,1 月 1 日属于上一年最后一周 XCTAssertEqual(DateTool.string(from: date, format: .weekBasedYear), "2020-W53") }把跨年那几天固定成测试用例,是防止这个坑复发的唯一有效手段。光靠 code review 靠不住,因为YYYY在 90% 的日期上表现正确,reviewer 的注意力根本不会落在那里。
4. 场景决定选型:哪些地方该用 YYYY,哪些绝对不行
4.1 按周聚合:周历年才是正确答案
前面花了大量篇幅讲YYYY怎么坑人,但它本身并不是错的。在真正的周维度场景里,YYYY反而是唯一正确的选择。
设想一个销售看板,统计口径是“每周一到周日的销售额”。2020 年 12 月 28 日(周一)到 2021 年 1 月 3 日(周日)这一周,横跨两个自然年。如果按yyyy分组,这一周会被劈成两段:12 月 28 到 31 号算 2020 年,1 月 1 到 3 号算 2021 年,你会得到两个不完整周的数据,周环比、周同比全部失真。
用YYYY-ww分组就完全没问题:整周都归到2021-W01,数据完整。这时候如果你“为了安全”改成小写yyyy-ww,反而会得到2020-W01和2021-W01两个组——因为同一个周编号挂在了两个年份下,看起来更离谱。
所以这个场景的判断标准很清晰:只要分组单位是“周”,就用YYYY;只要分组单位是“天”,就用yyyy。这两者之间没有模糊地带,模糊的是你对业务口径的理解。
补充一个实操细节:做周维度统计时,除了年份,ww本身也受 Calendar 的周起始日影响。如果你要严格对齐 ISO 8601(周一开始、包含 1 月 4 日的周是第 1 周),需要在 Calendar 上设置firstWeekday = 2和minimumDaysInFirstWeek = 4,光靠格式串是不够的。这一点很多人不知道,用默认的en_US_POSIX时周编号可能和预期的 ISO 编号差一位。
var cal = Calendar(identifier: .iso8601) // 直接用 ISO 日历,避免手动配 cal.timeZone = DateTool.businessTimeZone4.2 文件名、日志、接口报文:绝对用小写
跟周维度场景相反,下面这几类场景没有任何讨论空间,一律用yyyy。
文件名。用yyyyMMdd_HHmmss生成的导出文件名、截图名、备份名,本质上是给“这个文件产生的那个日子”打标签,不涉及任何周归属。用YYYY的后果是每年跨年那几天,文件名里的日期和实际日期对不上,用户排查问题时会被误导。另外文件名格式要避开冒号,HH:mm:ss里的冒号在部分文件系统里是非法字符,这也是为什么我单独加了一个fileNameSafe的 case。
日志。日志的时间戳必须全局唯一可排序,用YYYY会导致跨年那几天的日志时间倒退——2021 年 1 月 1 日的日志被标记成 2020 年,日志分析工具按时间排序时就乱了。日志场景还建议带上毫秒和明确时区标注,比如2024-03-05 14:30:00.123+0800,避免不同机器日志混在一起时无法区分。
接口报文。这条是给服务端打交道的硬规矩。跟服务端约定日期格式时,永远只约定yyyy-MM-dd HH:mm:ss,并且约定时区(我一般建议直接约定 UTC,或者干脆用时间戳)。接口字段里出现大写Y是灾难性的,因为解析端的实现可能各不相同,有的严格按周历年解析(然后报错),有的宽松模式直接当成小写处理(然后静默不一致),排查成本极高。
4.3 跨端一致性:和安卓、服务端对齐
同一份数据在 iOS、安卓、服务端、Web 后台同时被格式化,是最容易出隐性不一致的场景。这三端用的日期库不一样,对 Unicode 模式串的支持程度也不完全一样。
有的库对模式串大小写不敏感——你把yyyy写成YYYY它也能正确解析成日历年。这会让问题更糟:iOS 上错、别的端对,数据对不上时你会先怀疑 iOS,但如果反过来(某端写错 iOS 对),你排查时就会一头雾水。
我的处理办法是定一份“日期格式契约”放进技术规范,明确规定三件事:能用的格式串白名单、日期字段的默认时区、解析失败的统一行为(报错还是丢弃,绝不兜底)。白名单里只放yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd'T'HH:mm:ssZZZZZ这几个,任何新的格式串要加进白名单都需要评审。这个规定听起来有点重,但实施之后跨端日期问题基本归零了。
顺带说一个跨端常吵的点:ZZZZZ和Z的区别。Z输出+0800,ZZZZZ输出+08:00(带冒号)。ISO 8601 标准要求带冒号,所以跟 Web 端对接时用ZZZZZ更稳妥。如果对方按严格 ISO 解析,收到+0800可能直接失败。
5. 常见问题排查实录
5.1 问题速查表
把日常遇到的情况整理成一张表,方便对照定位:
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 元旦前后日期整体偏移一年 | 格式串用了YYYY | 检索全部YYYY字面量 |
| 月份显示正确,分钟显示成月份数字 | HH:MM:ss写错大小写 | 检查格式串中MM的位置 |
| 下午时间显示成 3 点而不是 15 点 | 用了hh且没带a | 改成HH |
日期串里出现365这种三位数 | 误用DD(年内第几天) | 改成dd |
| 部分用户看到 2567 年 | 用户 Locale 为佛历 | 格式化时锁定en_US_POSIX |
| 同一条数据在不同设备显示不同日期 | 未固定 TimeZone | 显式指定业务时区 |
| 解析不报错但结果偏了一天 | 宽松解析把非法日期滚动了 | 设置isLenient = false |
| 界面日期没按语言本地化 | 设置了dateFormat覆盖掉dateStyle | 二选一,别混用 |
| 列表滚动卡顿 | 高频路径新建 DateFormatter | 加缓存,按格式串复用 |
5.2 三次印象深刻的排查过程
第一次是关于“周报重复”的。某次迭代后,运营反馈周报里有一周出现了两条记录,日期分别是 12 月 30 日和 1 月 1 日。我们一开始怀疑是任务调度重复触发,排查了定时器配置、分布式锁、幂等键,全都没问题。后来把两条记录的原始时间戳拉出来对比,发现是同一个时间点被格式化成了两个不同的周标识。原因是统计代码里有两处格式化,一处写的是YYYY,另一处写的是yyyy,而且当时ww的周起始日配置也不一致,双重错位。修复方案是统一到封装层,并把周起始日显式设成 ISO 标准。
第二次是关于“文件名重复覆盖”的。有个导出功能,文件名格式是导出_YYYYMMdd.xlsx,用户在同一天多次导出会覆盖上一次的文件。这个问题大半年的时间都没人发现,因为覆盖后内容其实差不多。直到某年元旦,用户反馈“导出的文件日期是去年”,我们才顺着查到YYYY。修复后顺便把文件名改成了带时分秒的格式,彻底解决覆盖问题。这件事给我的教训是:能用时间戳做文件唯一标识就别用日期串,日期串的粒度根本不够。
第三次是关于“打卡跨天”的。一个习惯打卡功能,记录用户每天是否打卡,用业务日期做唯一键。有用户反馈自己在 23 点 50 分打卡,结果第二天的记录变成了已打卡状态。排查发现是业务日期的计算用了Calendar.current,而该用户设备时区设成了另一个时区,导致startOfDay算出来的边界和服务器不一致。最后的方案是所有打卡相关的时间计算都强制走固定业务时区,客户端只负责展示,判定逻辑放到服务端统一处理。凡是涉及“哪一天”的业务判定,判定权应该收归单一位置。
5.3 上线前自检清单
最后给一份我每次涉及日期模块改动时都会过一遍的清单,条目不多但每条都救过我:
- 全局搜索
YYYY,确认每一处都是周维度场景,其余全部改小写 - 全局搜索
dateFormat,确认每一处都在设置后紧跟locale = en_US_POSIX - 确认所有
TimeZone都是显式的,没有依赖默认值 - 确认所有解析路径都设置了
isLenient = false且有 nil 分支处理 - 确认没有在同一处同时设置
dateStyle和dateFormat - 确认高频路径(列表、循环)里没有新建
DateFormatter - 跑一遍跨年测试用例,覆盖 12 月 29 日到 1 月 3 日
- 如果涉及周维度,确认 Calendar 的
firstWeekday和minimumDaysInFirstWeek符合业务口径
提示:这套检查里最容易被跳过的是最后一条。周起始日不同导致的偏差通常只有一两天,肉眼极难发现,但在跨年那一周会放大成整整一周的错位,属于典型的“平时没事,出事就是大事”。
我个人在实际操作中的体会是,日期处理这件事的难度从来不在“怎么写格式化串”,而在于你愿不愿意在写之前停下来想两秒:这个时间在我业务里到底代表什么?是绝对时刻、是本地钟表时间、还是某个业务意义上的日子?这三个问题的答案不同,Locale、TimeZone、格式串的选择就完全不同。我见过太多项目的日期工具层是东拼西凑长出来的,十几种格式串散落在各个文件里,没人说得清哪个用在哪。等到真出问题时,排查成本是重构成本的五倍以上。所以如果现在让我给一个建议,我会说:如果你手上项目的日期格式化还是一盘散沙,抽半天时间把它收敛到一处,比你现在修任何一个具体 bug 都值。