RelativeDateTimeFormatter 是 iOS 和 macOS 中的 Foundation 类,它将绝对日期转换为人类可读的相对表述:“5 分钟前”、“昨天”、“3 天后”。根据 Apple Developer Documentation, 2024,RelativeDateTimeFormatter 会自动选择合适的单位(秒、分钟、小时、天),并以设备当前区域设置的语言本地化输出。与通过 Calendar 手动计算日期之间的差异不同,此类会考虑每种语言的语言特点:对于某些语言,数词会变形,对于其他语言,“昨天”一词会使用特殊形式。此类自 iOS 13 和 macOS 10.15 起可用。
要点
RelativeDateTimeFormatter 是 Foundation 中 Formatter 的子类,它接收 Date(或以秒为单位的差异)并返回带有相对时间的本地化字符串。例如,对于比当前日期早 5 分钟的日期,它将为 ru_RU 返回 “5 分钟前”,或为 en_US 返回 “5 minutes ago”。此类支持三种时间上下文:过去(past)、未来(future)和现在(present)。
RelativeDateTimeFormatter 的内部逻辑使用 Calendar 和 Locale 来计算日期之间的差异并选择正确的语法形式。对于中文,此类可以正确处理数词:“1 分钟前”、“2 分钟前”、“5 分钟前”。对于英语——在 “minute ago” 和 “minutes ago” 之间选择。此功能基于 ICU(International Components for Unicode)数据,不需要开发人员进行额外配置。
根据 Apple WWDC 2019,RelativeDateTimeFormatter 成为框架的一部分以简化本地化——在它出现之前,开发人员必须手动计算日期差异并通过 String.localizedStringWithFormat 插入本地化字符串。这导致了变形错误(尤其是斯拉夫语和阿拉伯语)以及测量单位选择不正确。
工作算法 RelativeDateTimeFormatter 由三个步骤组成:计算传入日期与当前时刻之间的差异,选择合适的单位(不会产生零的最大单位),并考虑区域设置进行格式化。例如,对于 3720 秒(1 小时 2 分钟)的差异,将选择“小时”单位,结果是“1 小时前”,而不是“62 分钟前”。
单位按照“最大非零”原则选择:如果差异大于 86400 秒(1 天),则使用天;如果大于 604800(1 周)——使用周,依此类推。此算法保证结果始终自然可读:用户看到的不是“518400 秒前”,而是“6 天前”。单位的确切边界由当前区域设置的日历确定。
| 差异范围 | 单位 | ru_RU 的示例 |
|---|---|---|
| 0–59 秒 | Seconds | 30 秒前 |
| 1–59 分钟 | Minutes | 5 分钟前 |
| 1–23 小时 | Hours | 3 小时前 |
| 1–6 天 | Days | 2 天前 |
| 7–27 天 | Weeks | 1 周前 |
| 28 天–11 个月 | Months | 3 个月前 |
| 12+ 个月 | Years | 1 年前 |
格式化上下文决定短语的结尾。对于过去:“前”(中文)、“ago”(英语)。对于未来:“3 天后”(中文)、“in 3 days”(英语)。对于现在:“现在”(中文)、“now”(英语)。上下文通过 localizeString(fromTimeInterval:) 方法或直接通过 string(from: Date) 设置。
RelativeDateTimeFormatter 提供多种设置来控制输出:unitsStyle 属性确定格式化样式(numeric、abbreviated、full、spellOut),maximumUnitCount 限制显示的单位数量。例如,使用 maximumUnitCount = 1 时,1 小时 30 分钟的差异将显示为“1 小时前”,而不是“1 小时 30 分钟前”。
单位限制:默认情况下,RelativeDateTimeFormatter 只显示一个(最大的)单位。设置 maximumUnitCount = 2 会激活下一个单位以获得更精确的描述:“1 小时 30 分钟前”。然而,这可能会使短消息(推送、通知)的字符串过长。对于 UI,建议保持 maximumUnitCount = 1。
import Foundation
let formatter = RelativeDateTimeFormatter()
// 配置样式
formatter.unitsStyle = .numeric
formatter.maximumUnitCount = 1
// 不同日期的示例
let fiveMinAgo = Date().addingTimeInterval(-300)
print("5 分钟前:\(formatter.localizedString(for: fiveMinAgo, relativeTo: Date()))")
let twoDaysLater = Date().addingTimeInterval(172800)
print("2 天后:\(formatter.localizedString(for: twoDaysLater, relativeTo: Date()))")
// 缩写样式
formatter.unitsStyle = .abbreviated
let oneWeekAgo = Date().addingTimeInterval(-604800)
print("缩写:\(formatter.localizedString(for: oneWeekAgo, relativeTo: Date()))")
// 完整样式(文字)
formatter.unitsStyle = .full
let threeHours = Date().addingTimeInterval(10800)
print("完整:\(formatter.localizedString(for: threeHours, relativeTo: Date()))")
为不同上下文选择样式:对于新闻信息流,请使用 maximumUnitCount = 1 的 .numeric — 这是 Twitter、Instagram 和 Facebook 的标准。对于无障碍(VoiceOver),请使用 .full — 文字数字读起来更自然。对于紧凑元素(通知角标、状态栏),请使用 .abbreviated 以节省空间。
基本用法 RelativeDateTimeFormatter 归结为创建实例、配置属性和调用其中一种格式化方法。主要方法:localizedString(for:relativeTo:) — 用于一对日期,localizedString(fromTimeInterval:) — 用于以秒为单位的差异,以及 string(for:) — 用于带自动上下文(过去/未来)的 Date。
import Foundation
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .numeric
formatter.maximumUnitCount = 1
// 社交媒体 UI 示例
let postDates: [(title: String, date: Date)] = [
("Just now", Date().addingTimeInterval(-30)),
("5 min ago", Date().addingTimeInterval(-300)),
("Yesterday", Date().addingTimeInterval(-90000)),
("Last week", Date().addingTimeInterval(-700000)),
("Last year", Date().addingTimeInterval(-32000000))
]
for (title, postDate) in postDates {
let relative = formatter.localizedString(
for: postDate,
relativeTo: Date()
)
print("\(title): \(relative)")
}
// 未来日期
let reminderFormatter = RelativeDateTimeFormatter()
reminderFormatter.unitsStyle = .abbreviated
let inOneHour = Date().addingTimeInterval(3600)
let reminderText = reminderFormatter.localizedString(
for: inOneHour,
relativeTo: Date()
)
print("提醒:\(reminderText)")
处理“刚刚”场景 — RelativeDateTimeFormatter 对于非常小的间隔没有内置的“刚刚”短语支持。对于小于 5 秒的差异,它将返回“0 秒前”,这在 UI 中看起来不美观。建议将 formatter 调用包装在条件逻辑中:如果差异小于设定的阈值(例如 5 秒)——手动显示“刚刚”,否则将日期传递给 formatter。
import Foundation
func relativeTimeString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
// “刚刚”阈值
if interval < 5 {
return "just now"
}
// “今天”阈值
if interval < 60 {
return "just now"
}
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .numeric
formatter.maximumUnitCount = 1
// 不带“前”后缀显示
return formatter.localizedString(
for: date,
relativeTo: Date()
)
}
print(relativeTimeString(from: Date().addingTimeInterval(-3)))
print(relativeTimeString(from: Date().addingTimeInterval(-120)))
print(relativeTimeString(from: Date().addingTimeInterval(-3600)))
string(fromTimeInterval:) 方法接收以秒为单位的差异并自动确定上下文(正值 — 未来,负值 — 过去)。当差异已知时(例如,从服务器接收为 unix timestamp),这很方便。在这种情况下,无需创建 Date — 差异直接传递。
RelativeDateTimeFormatter 会根据 Locale.current 自动本地化输出。要更改格式化语言,请设置 locale 属性 — 与 DateFormatter 不同,RelativeDateTimeFormatter 的 locale 不是固定的,可以针对每次调用进行更改。这允许以不同于界面语言的语言显示相对日期(例如,以原始语言显示内容)。
相对日期本地化的复杂性在于不同语言的语法特点。俄语需要不同形式的数词:“1 分钟”、“2 分钟”、“5 分钟”。阿拉伯语 — 对 3 到 10 的数字使用复数形式,对 11+ 使用特殊形式。中文 — 完全没有变形,这简化了任务。RelativeDateTimeFormatter 通过 ICU 规则覆盖所有这些情况,无需额外代码。
import Foundation
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .numeric
formatter.maximumUnitCount = 1
let targetDate = Date().addingTimeInterval(-7200) // 2 小时前
// 不同的区域设置
let locales: [String] = ["ru_RU", "en_US", "de_DE", "fr_FR", "ja_JP", "ar_SA"]
for identifier in locales {
formatter.locale = Locale(identifier: identifier)
let result = formatter.localizedString(
for: targetDate,
relativeTo: Date()
)
print("\(identifier): \(result)")
}
// 检查俄语变形
formatter.locale = Locale(identifier: "ru_RU")
let intervals: [TimeInterval] = [-60, -120, -180, -300]
for interval in intervals {
let date = Date().addingTimeInterval(interval)
print("\(-Int(interval / 60)) 分钟:\(formatter.localizedString(for: date, relativeTo: Date()))")
}
重要细节:RelativeDateTimeFormatter 在计算 .numeric 设置的差异时会忽略 TimeZone — 它使用以秒为单位的绝对差异。但是,对于 .full 样式(带文字数字)和特殊情况(昨天、今天),TimeZone 会被考虑在内。请始终显式设置 TimeZone 以保持一致性,尤其是当应用程序处理 UTC 中的服务器日期时。
计算相对日期时忽略 TimeZone — 处理服务器日期时的常见错误。如果服务器以 UTC 发送 Date,而 RelativeDateTimeFormatter 使用 TimeZone.current,则对于接近当前时刻的日期,差异可能计算不正确。建议始终为服务器数据设置 formatter.timeZone = TimeZone(secondsFromGMT: 0)。
短间隔的单位选择不正确 — RelativeDateTimeFormatter 将差异四舍五入到最大单位。对于 25 小时,结果将是“1 天前”,这可能会误导用户。如果需要高精度(例如,对于倒计时计时器),请使用 DateComponentsFormatter 而不是 RelativeDateTimeFormatter — 它允许同时显示多个单位。
缺少对负 TimeInterval 的检查 — 如果未来的日期被作为过去的日期传递(string(fromTimeInterval:) 中的负值),formatter 可能返回不正确的字符串。在传递给 formatter 之前,请始终检查间隔的符号,尤其是在处理服务器数据时,时区可能会扭曲计算。
根据 Hacker News (2024),RelativeDateTimeFormatter 最常讨论的问题之一是英语中缺乏对“昨天”和“今天”的内置支持。formatter 对于 90000 秒的差异将返回“1 天前”,而不是“昨天”。对于俄语,不存在这样的问题——“1 天前”听起来很自然,但对于英语 UI,“yesterday”更可取。此功能不受支持,需要通过 Calendar.isDateInToday/Yesterday 进行手动检查。
常见问题
RelativeDateTimeFormatter — 用于以相对格式显示日期的 Foundation 类:“5 分钟前”、“2 天后”。自 iOS 13 和 macOS 10.15 起可用。
根据最大非零单位原则 — 秒、分钟、小时、天、周、月或年。例如,对于 3720 秒(1 小时 2 分钟)的差异,将选择“小时”单位,而不是“分钟”。
将 locale 属性设置为所需的 Locale 实例。默认使用 Locale.current。示例:formatter.locale = Locale(identifier: "de_DE") 用于德语。
.numeric — 完整形式(“3 天前”),.abbreviated — 缩写形式(“3 天前”)。选择取决于上下文:numeric 用于主要 UI,abbreviated 用于紧凑元素。
添加手动检查,用于小于 5–10 秒的间隔。RelativeDateTimeFormatter 不支持“刚刚”——对于小间隔,它返回“0 秒前”。使用带阈值的条件逻辑。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。