ICU Calendar 实际工作问题排查手册

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_CNen_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_VALIDgetImmediatePreviousZoneTransition(),时区必须是 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_WOYYEAR 可能差 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_MONTHWEEK_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_ERRORvalidateFields())。

根因fLenient 默认 truecomputeTime() 开头非 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,且 setWeekDataWeekDataCache(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 + addgetActualHelper() 从 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_icu4jandroid.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 深比较 ------子类混用(如 ISO8601Calendar vs GregorianCalendar)typeid 不同即不等;
  • adoptTimeZone(nullptr) 直接 return,不报错;
  • operator== 还需 getTimeInMillis 相等(会触发 lazy compute,注意 status)。

实践建议:比较日历用"时间戳 + calendar type 字符串"自行判断,不依赖 equals。

14. 极端值与溢出

现象 :儒略日计算在超大年份上报 U_ILLEGAL_ARGUMENT_ERRORfStamp 达到 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.txtandroid_icu4j/.../android/icu/util/Calendar.javaicu4c/source/i18n/calendar.cpp

相关推荐
ii_best2 小时前
安卓脚本/ios开发软件按键精灵实战:自动定位弹窗广告关闭按钮坐标的通用方案
android·ios·自动化
天空之城--2 小时前
Android Jetpack Compose 状态管理浅析
android·android jetpack
企业数字化笔记6 小时前
固定资产财务账与实物账怎么对账?折旧快照、差异检测与SQL核对
android·数据库·sql
千里马-horse8 小时前
第 66 章:在 Android 上运行 Windows 游戏
android·aosp
云边有个稻草人12 小时前
飞牛 NAS 远程访问实战:星空组网连接 Mac 与安卓,从 Compose 部署到 5G 验证
android·5g·macos
聚美智数12 小时前
手机号归属地-手机号归属地查询-手机归属地-运营商归属地
android·智能手机
恋猫de小郭12 小时前
Shopify 从 React Native 回到 Swift/Kotlin,但是你以为有手就行??
android·前端·ios
菠萝加点糖12 小时前
Android ChipGroup 使用说明
android
我命由我1234513 小时前
Android Compose 开发,使用 ConstraintLayout,但是引入的是旧的 ConstraintLayout
android·java·java-ee·android studio·android jetpack·android-studio·android runtime