ICU Calendar 实际工作问题排查手册
基于
supplementalData.txt(weekData)、Calendar.java(ICU4J)、calendar.cpp(ICU4C)源码讨论整理,面向 Android 实际开发中可能遇到的问题。
1. 每周第一天 / 周末判断不符合预期
现象:App 显示周日历周一打头,测试在海外(如哥伦比亚、沙特)报"周从周日开始,是 bug"。
根因 :firstDayOfWeek 不是写死的,来自 weekData 的地区数据(CN=2 周一,CO=1 周日),见 setWeekData() 的 ures_getByKey(rb, region.data(), ...)。
排查要点:
- 确认 locale 的 region 部分 :只传语言
new Locale("zh")时,代码会先maximizeSubtags补 region(可能补成 CN),也可能补不成 → 回退"001"(周日开头)。 - 检查是否用了
u扩展"fw":@fw=mon会强制覆盖 weekData 的值。 - 中文环境但 region 丢失(如只设了
language=zh, script=Hans而无 country)→ 行为依赖 likelySubtags 表。
实践建议 :Calendar 相关 UI 永远传完整 BCP47 locale(zh_CN、en_US),不要只传语言码。
2. roll() 和 add() 混用导致日期错乱
现象 :1 月 31 日 "加一个月" 变成 3 月 3 日,或滚动 MONTH 后日份跨月。
根因 (calendar.cpp add() / roll()):
add(MONTH, 1):set 后pinField(DAY_OF_MONTH)→ 2 月 28/29,正确。roll(MONTH, 1):只做模回绕 + pinField,不改变年份等更大字段。roll(DAY_OF_MONTH, 1)在月末会回绕(29→1),add则进位到下月。
实践建议 :业务上"加一个月"永远用 add();roll() 只用于"在同范围内循环选择"(如时间选择器的滚轮)。
3. 夏令时转换日的墙钟歧义
现象:欧洲用户报告"选 2:30 保存后变成 3:30"或"某天 23 点到 1 点之间出现两次/没有"。
根因 :DST 切换时刻存在不存在的时间 (拨快)和重复的时间(拨慢)。代码里有两套选项:
| 场景 | 默认行为 | 可配置项 |
|---|---|---|
| 被跳过的时间(skipped) | WALLTIME_LAST:取切换后 |
setSkippedWallTimeOption() |
| 重复的时间(repeated) | WALLTIME_LAST:取后者 |
setRepeatedWallTimeOption() |
排查要点:
computeZoneOffset()对非 BasicTimeZone 有 6 小时窗口的负偏移检测兜底;WALLTIME_NEXT_VALID走getImmediatePreviousZoneTransition(),时区必须是 Olson/Simple/RuleBased/VTimeZone 四种之一才生效。
实践建议:闹钟/提醒类应用显式设置两个 wall-time option,并写单测覆盖切换日。
4. add(DAY_OF_MONTH, N) 后墙钟时间漂移
现象:加一天后时分秒变了(如 10:30 → 9:30)。
根因 :add() 对 >= DAY_OF_MONTH 的字段保持墙钟不变,会补偿 ZONE+DST 偏移变化;但若跨度超过 24h(Samoa 2011-12-30 跳整天,ticket:9452),补偿量 % kOneDay 后仍有 24h 跳变,刻意不补偿。
实践建议:跨时区排班/计费系统,存储用 UTC 毫秒,展示前才本地化;不要对本地化后的 Calendar 做长跨度 add。
5. WEEK_OF_YEAR 跨年坑(ISO 周)
现象 :12 月 31 日算出来是"明年第 1 周"或"第 53 周",后端按 year + weekOfYear 存库后排序错乱。
根因 :computeWeekFields() 明确会把年首/年尾重叠周分到相邻年,YEAR_WOY 与 YEAR 可能差 1。只有 firstDay=周一 + minimalDays=4(ISO)才是 ISO 周规则。
实践建议:
- 需要 ISO 周时用
getWeekYear()+getWeeksInWeekYear(),不要用get(YEAR) + get(WEEK_OF_YEAR); - 或者用
LocalDate.get(IsoFields.WEEK_BASED_YEAR)(java.time 侧无此歧义)。
6. 字段冲突消解的"隐式优先级"
现象 :同时 set 了 DAY_OF_MONTH 和 WEEK_OF_MONTH + DAY_OF_WEEK,结果取了后者(或反过来),开发觉得"随机"。
根因 :resolveFields(kDatePrecedence) 的规则是:字段行全部已设置才参与竞争,行内取 stamp 最大者 。后 set 的字段 stamp 大,所以"后设置的赢"。但 kResolveRemap 有特殊规则(YEAR 覆盖 YEAR_WOY 时用 DAY_OF_MONTH 等)。
实践建议 :一次只设置一套日期字段;在 set 新方案前对旧字段 clear(),不要叠加。
7. 宽松模式(lenient)掩盖脏数据
现象 :月份传 13、日传 32 不报错,静默算出"明年 1 月"------或反过来非宽松模式下用户输入"2 月 30 日"直接抛 U_ILLEGAL_ARGUMENT_ERROR(validateFields())。
根因 :fLenient 默认 true;computeTime() 开头非 lenient 时才逐字段 validateField() 校验。
实践建议 :接收外部输入后立即 setLenient(false) + computeTime() 触发校验,把错误挡在入口处。
8. isWeekend() 与直觉不符
现象:中东/以色列 locale 下周六返回工作日,或周五被算成周末。
根因 :weekend 是数据驱动的(fWeekendOnset/fWeekendCease),支持跨周 (onset > cease,如周五周六型)和非午夜起止 (onsetMillis/ceaseMillis,如周五日落开始)。getDayOfWeekType() 已废弃但仍在用这套逻辑;ceaseMillis >= 86400000 表示整天。
实践建议 :不要硬编码"周六日是周末",业务排班一律走 isWeekend(date) 并注入对应 locale。
9. Locale → 历法选错
现象 :泰国用户看到"佛历 2567"惊呼 bug;或指定 @calendar=japanese 却返回公历。
根因 :getCalendarTypeForLocale() 按 region 读 calendarPreferenceData(TH → buddhist);calendar 关键字非法时静默回退格里历。另外 ja_JP_TRADITIONAL 自 ICU-20187 起不再canonicalize 成 japanese 历法。
实践建议 :显式传 calendar 关键字(th_TH@calendar=buddhist)并验证 getType() 返回值。
10. 性能:日历实例创建的隐藏成本
现象 :列表/循环里反复 Calendar.getInstance() 导致卡顿或 GC 压力。
根因分析:
- Java 侧 :
createInstance()每次 new,且setWeekData走WeekDataCache(SoftCache,可被 GC 回收,miss 后重新解析资源); - C++ 侧 :
createInstance(zone, locale)有UnifiedCache+ clone 优化,但createInstance(locale)仍可能走 service 框架。
实践建议:
- 高频场景复用实例(注意
Calendar非线程安全,用 ThreadLocal); - 绝不在
RecyclerView.onBindViewHolder里 getInstance; - 大量周计算直接用
java.time(无 ICU 资源解析开销)或缓存结果。
11. fieldDifference / getActualMaximum 的 O(n) 陷阱
现象 :算两个日期差几百年,或频繁 getActualMaximum 时 CPU 飙高。
根因 :fieldDifference() 是倍增+二分但每步都 setTimeInMillis + add;getActualHelper() 从 leastMaximum 起逐个 add 试探直到回绕(WEEK_OF_MONTH 类字段约几十次迭代)。
实践建议 :大跨度差值用毫秒差换算;getActualMaximum(DAY_OF_MONTH) 用 YearMonth.lengthOfMonth()(java.time)代替。
12. Android 双 ICU 栈的数据/版本错位
现象 :同一设备上 android.icu.util.Calendar.getFirstDayOfWeek() 与 java.util.Calendar / java.time 结果偶尔不一致;升级系统后行为变化。
根因:
android_icu4j(android.icu.*)与icu4c(libcore 底层)是两份实现 ,共享icudtl.dat但代码版本可能不同步;java.util.Calendar/java.time走 libcore 对 ICU4C 的封装,不完全等价于android.icu;- weekData 随 ICU 版本升级会变(CLDR 更新)。
实践建议:一个模块内统一用一种日历栈;跨栈比较结果前先写兼容性测试;关注每年 Android 大版本的 ICU 升级说明。
13. 时区对象身份与序列化
现象 :clone/反序列化后 isEquivalentTo 返回 false,或 adoptTimeZone 传入 null 静默无效。
根因:
isEquivalentTo()要求 *typeid 相同 + 所有配置相等 + fZone 深比较 ------子类混用(如ISO8601CalendarvsGregorianCalendar)typeid 不同即不等;adoptTimeZone(nullptr)直接 return,不报错;operator==还需getTimeInMillis相等(会触发 lazy compute,注意 status)。
实践建议:比较日历用"时间戳 + calendar type 字符串"自行判断,不依赖 equals。
14. 极端值与溢出
现象 :儒略日计算在超大年份上报 U_ILLEGAL_ARGUMENT_ERROR;fStamp 达到 127 后触发 recalculateStamp() 重排导致"先 set 的字段优先级反转"的错觉。
边界:
| 项 | 限制 |
|---|---|
| 支持年份 | 儒略日 ±0x7F000000 ≈ ±580 万年 |
| time | MIN/MAX_MILLIS(±8.6e10 年量级,lenient 时 pin 到边界) |
| year 参数 | > INT32_MAX/400 直接拒绝(防后续乘法溢出) |
| NaN | setTimeInMillis(NaN) → ILLEGAL_ARGUMENT |
实践建议 :天文/历史类 App 提前 clamp 输入年份;批量 set 超过 127 次字段的极端用例先 clear()。
15. 废弃 API 的兼容包袱
getDayOfWeekType() / getWeekendTransition() 标了 @Deprecated 但内部仍在用(isWeekend() 依赖它们);EDateFields 旧枚举靠 cast 桥接到 UCalendarDateFields。老代码迁移时注意新旧枚举越界(cast 后无运行时校验,roll((EDateFields)23, ...) 会越界)。
速查表:症状 → 代码定位
| 症状 | 定位 |
|---|---|
| 周一/周日起始错乱 | setWeekData() region 解析 + "001" 回退 |
| 月份相加日份溢出 | add() 的 pinField(DAY_OF_MONTH) |
| DST 切换时刻偏移 | computeZoneOffset() / wall-time options |
| 周数年界错乱 | computeWeekFields() woy==0 / lastDoy 分支 |
| 字段冲突结果随机 | resolveFields(kDatePrecedence) stamp 机制 |
| 非法日期不报错 | lenient 默认 true,validateFields() 未走 |
| 周末判断错误 | fWeekendOnset/Cease(Millis) 数据驱动 |
| 泰国历法不对 | getCalendarTypeForLocale() calendarPreferenceData |
| 创建卡顿 | Java 无实例缓存 / WeekDataCache 软引用失效 |
| 差值计算慢 | fieldDifference() / getActualHelper() 迭代 |
| 行为系统升级后变化 | ICU/CLDR 版本升级,weekData 数据变更 |
参考源码:AOSP external/icu(android-17.0.0_r1)------ icu4c/source/data/misc/supplementalData.txt、android_icu4j/.../android/icu/util/Calendar.java、icu4c/source/i18n/calendar.cpp