1. 日历类API全景:从 java.util.Calendar 到 kotlinx-datetime 的选型博弈
先聊一个很多 Kotlin 开发者都会困惑的问题:明明 JDK 里已经有 java.util.Calendar 了,为什么现在写代码时总感觉浑身不自在?原因很简单——这个东西是 1997 年设计的,带着浓重的 Java 1.1 时期味道活在今天,对 Kotlin 这种追求表达力、不可变性和空安全的语言来说,牵强得很。
日历类 API 在 Kotlin 生态里其实是分层存在的。最底层是 JDK 自带的 java.util.Calendar 和 java.util.Date;再往上是 JDK 8 引入的 java.time 体系;如果项目是 Kotlin 多平台(KMP),还有一个官方发布的 kotlinx-datetime 库。每一个层级都有它存在的理由和使用场景,但很多人踩坑的根本原因就两个:一是拿旧 API 硬写新需求,二是对新旧 API 混杂使用的边界不清楚。
1.1 java.util.Calendar 的历史包袱:为什么说它是"可变的陷阱"
先别急着把 Calendar 喷得体无完肤,老项目里它仍然是主角,尤其是维护多年的 Android 应用,代码里到处是 Calendar.getInstance() 这种写法。但在 Kotlin 里用它,你会面临三个棘手问题。
第一个问题是可变性。Calendar 的所有字段都是可写的,你调用了 set 方法之后,同一个实例的状态就变了。这在多线程环境下非常危险,两个协程共享同一个 Calendar 实例时,一个修改月份,另一个读到的是被污染的数据。Kotlin 的 val 只是个引用不可变,并不保证对象内部状态不可变,所以照样踩坑。
第二个问题是月份从 0 开始。Calendar.JANUARY 是 0,Calendar.DECEMBER 是 11。这可能是 Java 历史上最大的 API 设计失误之一,每年都有无数新手在这个地方翻车。你写好一个日期 2024 年 12 月 1 日,代码是 Calendar.getInstance().set(2024, 12, 1),结果发现日志打印出来是 2025 年 1 月 1 日。这种问题排查起来特别烦,因为它不报错,只是结果悄悄错了。
第三个问题是get 和 set 的语义混乱。Calendar 里既有 get(Calendar.MONTH) 这种读取方式,又有 set(Calendar.DAY_OF_MONTH, 1) 这种修改方式。它没有类型安全的字段访问,传错常量只会得到一个看似合理但实际错误的结果。
我的建议很简单:新代码不要用 java.util.Calendar。除非你在维护一个历史遗留模块,而且短期内没有重构预算,否则这是纯粹给自己埋雷。Kotlin 项目里哪怕用 java.time 都需要降级处理(后面细说),但至少那是干净的 API。
1.2 java.time:你真正应该掌握的现代日历 API
java.time 是 JDK 8 引入的日期时间 API,设计者吸取了 Joda-Time 的教训,把可变性彻底抛弃,所有核心类都是不可变的。LocalDate 代表日期(年、月、日),LocalDateTime 代表日期加时间,Instant 代表时间戳,ZonedDateTime 代表带时区的完整时间。它们是线程安全的,可以放心在协程里共享。
用 Kotlin 写 java.time 的代码有一种"终于能正常呼吸"的感觉。举个例子,获取今天:
val today: LocalDate = LocalDate.now()获取当月的第一天和最后一天:
val firstDay = today.with(TemporalAdjusters.firstDayOfMonth()) val lastDay = today.with(TemporalAdjusters.lastDayOfMonth())计算两个日期之间相隔多少天:
val days = ChronoUnit.DAYS.between(startDate, endDate)这些操作在 Calendar 时代每个都要写一大段循环或者手算,现在都是一行搞定。更重要的是,代码的可读性完全不在一个量级,类型系统会帮你挡住很大一部分错误。
但这里有一个 Kotlin 开发者绕不开的问题:Android 怎么办?如果你的 minSdk 低于 26(Android 8.0),java.time 在系统层面是不存在的。解决方法是用 desugaring,在 Gradle 配置里开启核心库脱糖,Android Gradle Plugin 4.0 之后支持得比较完善。配置方式大致是在 build.gradle.kts 里加上:
android { compileOptions { isCoreLibraryDesugaringEnabled = true } } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2") }这些配置说完,我再强调一次:不要因为 desugaring 麻烦就退回 Calendar,这个成本是值得的。
1.3 kotlinx-datetime:Kotlin 多平台的日历统一方案
如果你的项目涉及 Kotlin Multiplatform,比如共享逻辑要同时跑在 Android 和 iOS 上,java.time 就不好使了,因为 iOS 那边没有对应的 API。这时候官方推荐的是 kotlinx-datetime 库,它提供了统一的日期时间类型,底层在 Android 上还是走 java.time,但对你暴露的 API 是完全一致的。
基础用法是这样的:
import kotlinx.datetime.* // 获取当前时刻 val now: Instant = Clock.System.now() // 转成某个时区的本地时间 val currentTime: LocalDateTime = now.toLocalDateTime(TimeZone.currentSystemDefault()) // 获取今天的日期 val today: LocalDate = currentTime.date注意这里有个容易混淆的点:kotlinx-datetime 的 LocalDateTime 和 java.time 的 LocalDateTime 不是同一个类型,它们的转换方式也不同。在 KMP 共享代码里,你只能用 kotlinx-datetime 的类型,而到了 Android 平台层,你往往需要转成 java.time 的类型才能调用 Android 系统的 API。这个转换可以用扩展函数封装起来,避免在业务代码里到处散落转换逻辑。
综合来看,选型逻辑很清楚:Android 单平台项目用 java.time(开启 desugaring),KMP 项目在共享模块用 kotlinx-datetime,老项目短期内可以继续用 java.util.Calendar 但要有替换计划。三套 API 放在一张表里对比一下:
| API 类型 | 可变性 | 线程安全 | 多平台支持 | Android 兼容性 | 适用场景 |
|---|---|---|---|---|---|
| java.util.Calendar | 可变 | 否 | 仅 JVM | 原生支持 | 维护老代码 |
| java.time | 不可变 | 是 | 仅 JVM | 需 desugaring | Android/JVM 新代码 |
| kotlinx-datetime | 不可变 | 是 | 全平台 | 需依赖库 | KMP 共享代码 |
2. 核心 API 实操:日期运算、格式化与时区转换的细节拆解
光会选 API 还不行,日历相关的操作里真正容易出错的往往是最基本的运算和格式化。这一节我按照实际开发中最常见的场景拆开讲,每个例子都带完整代码。
2.1 LocalDate 的创建、比较与日期运算
LocalDate 的创建方式非常灵活。除了 LocalDate.now(),你还能用 of 来指定年月日:
val date = LocalDate.of(2024, 12, 25)注意,这里月份是 1 到 12,和 Calendar 的 0 到 11 完全不同。如果你之前写过 Calendar 代码,这个转变需要一点适应时间,但适应之后就再也不想回去了。
日期运算有个容易忽略但特别好的特性:运算结果自动处理跨月跨年。比如 2024 年 1 月 31 日加一个月,结果是 2024 年 2 月 29 日(2024 年是闰年),而不是变成 3 月 2 日,也不会有异常抛出。这非常符合直觉。
val jan31 = LocalDate.of(2024, 1, 31) val feb29 = jan31.plusMonths(1) // 2024-02-29比较日期远近用 isBefore、isAfter、isEqual,这些方法名读起来跟口语一样:
val isPast = today.isBefore(targetDate)实际开发里我经常需要计算"离某个纪念日还有多少天",用 ChronoUnit 一行搞定:
val daysUntilBirthday = ChronoUnit.DAYS.between(today, birthday)如果 birthday 已经过了,这个值是负数,所以通常还要先判断一下:
val safeDays = if (daysUntilBirthday < 0) daysUntilBirthday + 365L else daysUntilBirthday当然这是简化处理,闰年场景要更严谨的话,可以用循环加一年来判断,但对于大多数倒计时场景,这个精度够用了。
2.2 DateTimeFormatter:格式化与解析的常见误区
格式化日期看起来是最简单的操作,但恰恰是踩坑重灾区。Kotlin 里用 DateTimeFormatter 来定义格式:
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") val formatted = localDateTime.format(formatter)格式串里有个非常阴险的差异:小写的yyyy表示年份,大写的YYYY表示"周周年份"(week-based-year)。这两者在大多数情况下结果一样,但当年的最后几天或者年初,可能差一年。比如 2024 年 12 月 30 日那一周,实际上属于 2025 年的周周年份体系,用YYYY-MM-dd格式化出来可能是 2025-12-30。这个 bug 我见过不止一次出现在报表和日志里,排查起来极其隐蔽。
解析也有坑。解析字符串时最好用LocalDate.parse(str, formatter)这种带 formatter 的重载,而不是单纯调用LocalDate.parse(str),否则默认格式是 ISO 标准(2024-12-25),很多业务场景的日期字符串根本不是这个格式。
顺便说一句,DateTimeFormatter 是线程安全的,你可以把它定义成单例或者顶层 val,不用每次调用都 new 一个。这在协程并发场景下特别重要。
2.3 时区转换:从时间戳到本地时间的正确姿势
时间戳(epoch millis)是跨端传输的统一语言。服务端返回的时间经常是1711526400000这种数字,客户端要转成本地时间展示。正确的做法是先转成 Instant,再指定时区转成 ZonedDateTime,最后取日期和时间:
val timestamp = 1711526400000L val instant = Instant.ofEpochMilli(timestamp) val dateTime = instant.atZone(ZoneId.of("Asia/Shanghai")) val localDate = dateTime.toLocalDate() val localTime = dateTime.toLocalTime()这里有一个很多人容易想当然的坑:不要直接在前端硬编码"北京时间就是 UTC+8"。中国确实全年 UTC+8,没有夏令时,但你的用户可能在其他时区,或者你的 App 未来有出海计划。正确做法是获取系统默认时区:
val dateTime = instant.atZone(ZoneId.systemDefault())这样设备在哪个时区就显示哪个时区的时间,完全自动适配。
在 kotlinx-datetime 里,时区转换的 API 稍有不同:
val dateTime = now.toLocalDateTime(TimeZone.currentSystemDefault())TimeZone.currentSystemDefault() 在 Android 上会读取系统时区设置,用法和 java.time 的 ZoneId.systemDefault() 是等价的。
3. 协程与日历操作的结合:将"回调地狱"变成挂起函数
日历相关操作看似轻量,实际上在高频场景或者涉及系统服务调用时,一定要考虑线程问题。这一节我讲两个实际开发中特别实用的点:一是 Android 上日历数据的读写为什么不建议放在主线程,二是如何把 Spinner 这类回调事件用协程的挂起函数优雅封装。
3.1 日历操作为什么需要协程:卡顿的根源
你可能会觉得,不就是读一个日期、计算一下间隔吗,这能有多慢?问题不在日期运算本身,而在 Android 系统提供的日历服务。CalendarContract 的读写是走 ContentProvider 的,这是一个跨进程调用,底层涉及 Binder 通信和数据库查询,耗时从几毫秒到几十毫秒不等。如果用户日历里存了几千个事件,查询全量数据时主线程必然会卡顿,严重的会直接触发 ANR(Application Not Responding)。
所以我的习惯是:所有涉及 CalendarContract 的操作,一律放进 IO 线程。用协程处理这种场景太自然了:
viewModelScope.launch { val events = withContext(Dispatchers.IO) { queryCalendarEvents() } // 回到主线程更新 UI _uiState.value = events }如果你更习惯使用 Flow,也可以把它封装成一个 flow:
fun observeCalendarEvents(): Flow<List<CalendarEvent>> = flow { emit(queryCalendarEvents()) }.flowOn(Dispatchers.IO)这里的关键是 flowOn(Dispatchers.IO),它确保上游的查询操作在 IO 线程执行,而 collect 的时候默认是主线程,天然适合更新 UI。这个模式是 Android 上处理日历数据的标准姿势。
3.2 Spinner 变化事件转挂起函数:一个实用封装技巧
热搜词里出现了"android kotlin spinner变化事件",说明很多人都在找怎么把传统回调式的监听器转换成协程风格。Spinner 的 onItemSelected 回调是典型的"事件监听"模式,它的特点是:监听器一旦注册就长期存在,事件发生时回调会被触发多次。
但如果某个逻辑只需要"等到用户选中某一项后执行一次",你完全可以用 suspendCoroutine 挂起一个协程,等回调触发时恢复它。这个技巧在一次性选择场景下特别顺手:
suspend fun awaitItemSelection(spinner: Spinner): Int = suspendCoroutine { continuation -> spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { continuation.resume(position) } override fun onNothingSelected(parent: AdapterView<*>?) { // 什么都不选,恢复协程但返回 -1 或默认值 continuation.resume(-1) } } }然后在使用时就可以这样写:
lifecycleScope.launch { val selectedPosition = awaitItemSelection(spinner) val selectedDate = dates[selectedPosition] // 执行后续逻辑 }这个封装还有一个好处:如果你要处理的监听器本身只允许注册一次,避开了重复注册的问题。但要注意,spinner 在初始加载时就会触发一次 onItemSelected,这会立即恢复协程,我们的挂起函数可能还没等你通知协程启动就已经返回了。实际项目里需要加个标志位或者只在一开始设置一次监听器,这个细节要根据场景调整。
老实说我自己在做这个封装时踩的坑就是初始回调。后来我的解决方案是:先设置 adapter 和监听器之后,再主动调一次一个 await 函数,或者干脆把初始回调返回的 position 当作硬编码默认值来处理。没有完美的方案,还是要结合 UI 流程决定。
3.3 回调转挂起工具类:更通用的解法
如果你在项目里频繁遇到"老式回调 + 新协程代码"的混用,建议抽象一个通用的回调转挂起工具类,而不是每次手写 suspendCoroutine。下面这段代码是一个简单的模板:
suspend fun <T> awaitCallback( block: (Callback<T>) -> Unit ): T = suspendCoroutine { continuation -> block(object : Callback<T> { override fun onSuccess(result: T) { continuation.resume(result) } override fun onError(error: Throwable) { continuation.resumeWithException(error) } }) }这样调用方就不用关心底层回调细节了。不过要提醒一句:这种封装适合"一次性回调"场景,如果回调会触发多次,那就得用 Channel 或者 callbackFlow 了,它们能承载流式数据:
fun observePosition(spinner: Spinner): Flow<Int> = channelFlow { val listener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { trySend(position) } override fun onNothingSelected(parent: AdapterView<*>?) { // ignore } } spinner.onItemSelectedListener = listener awaitClose { spinner.onItemSelectedListener = null } }用 callbackFlow 的好处是支持取消,不会漏掉事件,撤掉监听器的时机也由协程生命周期决定,代码整体干净很多。
4. Android 日历事件操作:CalendarContract 实战指南
前面聊的多是日期时间的计算、格式化,那么"日历"作为 Android 系统的一个实体功能,我们还需要知道怎么读取系统日历里的日程事件、怎么往系统日历里插入一条新事件。这部分是很多做日历类 App、会议类 App、提醒类 App 的开发者的刚需,我单独开一节讲透。
4.1 权限准备与事件查询
要在 Android 上读取系统日历数据,Manifest 里必须声明 READ_CALENDAR 权限。如果你还要写入事件,则需要 WRITE_CALENDAR。这两者都属于危险权限,需要在运行时动态申请:
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Manifest.permission.READ_CALENDAR } else { Manifest.permission.READ_CALENDAR }其实 Android 6.0 之前 READ_CALENDAR 属于安装时权限,无需动态申请,但现代项目基本都要求用 Activity Result API 来申请运行时权限。这里不展开完整代码,我只强调一个容易被忽略的点:查询之前先检查权限是否真的已授予,不能只在 Manifest 里声明就以为万事大吉,否则 ContentResolver 会抛 SecurityException。很多人在测试环境没问题,一到线上就崩溃,就是忽略了动态权限的时序问题。
事件查询的基本流程是通过 ContentResolver 查 CalendarContract.Events.CONTENT_URI:
val projection = arrayOf( CalendarContract.Events._ID, CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART, CalendarContract.Events.DTEND, CalendarContract.Events.EVENT_TIMEZONE ) val cursor = contentResolver.query( CalendarContract.Events.CONTENT_URI, projection, null, null, "${CalendarContract.Events.DTSTART} DESC" )cursor 的使用记得放在 try/finally 里,遍历完之后一定要 close,否则 Cursor 会泄漏,频繁操作可能拖垮进程。Kotlin 里可以用 use 函数管理:
cursor?.use { c -> while (c.moveToNext()) { val title = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.TITLE)) val start = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)) // ... } }这里面 DTSTART 和 DTEND 都是毫秒时间戳,拿到之后按上一节讲的时间戳转本地时间的方法展示即可。
4.2 插入一条新日历事件:坑与完整流程
往系统日历插入事件比查询复杂一点,因为必须先得到一个合法的 calendar_id。系统里默认有至少一个日历账户,但它的 id 不是固定值,必须通过查询 CalendarContract.Calendars 来获取:
val calendarId = queryDefaultCalendarId(contentResolver)查询逻辑大致是筛选 CalendarContract.Calendars.IS_PRIMARY 为 1 的记录,拿到 CALENDAR_ID 字段。如果找不到主日历,再退化成查第一条可见的日历。
拿到 calendarId 之后,插入事件的代码是这样的:
val values = ContentValues().apply { put(CalendarContract.Events.CALENDAR_ID, calendarId) put(CalendarContract.Events.TITLE, "产品评审会议") put(CalendarContract.Events.DESCRIPTION, "月度产品评审") put(CalendarContract.Events.DTSTART, startMillis) put(CalendarContract.Events.DTEND, endMillis) put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) put(CalendarContract.Events.GUESTS_CAN_INVITE_OTHERS, 1) } val uri = contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)这里有一个非常容易出错的地方:EVENT_TIMEZONE 字段必须设置合法的时区 id。如果你漏掉或者传了空字符串,某些手机会插入失败,另一些手机会用默认时区强行写入,行为完全不确定。所以千万别偷懒,直接取 TimeZone.getDefault().id 就好。
时间戳 startMillis 和 endMillis 可以用 java.time 来算:
val start = ZonedDateTime.of(2025, 6, 10, 14, 0, 0, 0, ZoneId.systemDefault()) val end = start.plusHours(1) val startMillis = start.toInstant().toEpochMilli() val endMillis = end.toInstant().toEpochMilli()另外,如果事件需要重复发生,可以在 insert 之后调用 CalendarContract.Events 对应的 Reminders 表来添加提醒,或者在 events 表里使用 RRULE 字段(比如 FREQ=WEEKLY;COUNT=10)。RRULE 字段如果格式不合法,系统会静默忽略,所以测试时要仔细验证实际效果。
5. 常见问题与排查心得:我的踩坑记录
日历相关 API 的坑很多不属于编译错误,而是"行为不符合预期",这种问题最难排查。我把这几年的踩坑记录整理成一张速查表,再挑几个展开说说。
5.1 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 日期月份总是多 1 或少 1 | 把 Calendar 的 0 起始月份和 LocalDate 的 1 起始月份混用 | 统一切到 java.time,不再使用 Calendar.MONTH |
| 格式化后年份变到上一年/下一年 | 误用 YYYY(周周年份) | 统一用小写 yyyy |
| 插入日历事件后时间不对 | EVENT_TIMEZONE 没设置或者设置错时区 | 每次插入都显式指定 TimeZone.getDefault().id |
| 查询日历事件时闪退 | 缺少运行时权限 WRITE_CALENDAR 或 READ_CALENDAR | 用 Activity Result API 正确申请权限 |
| 日期运算结果超出预期(比如 1 月 31 日加一个月变成 3 月) | 使用了 set 系列方法而不是 plus 方法 | 永远用 plusDays / plusMonths |
| kotlinx-datetime 和 java.time 类型互相赋值报错 | 两套体系类型不兼容 | 写转换扩展函数,不在业务层混用 |
| 在主线程执行 CalendarContract 查询导致卡顿 | 跨进程 Binder 调用耗时 | 用 withContext(Dispatchers.IO) |
5.2 说说我只记得住的那些坑
坑一:Calendar 的 set 方法会造成字段联动修改。很多人以为 set(Calendar.DAY_OF_MONTH, 1) 只改天,实际上它会重新计算整周、整月的关联字段。如果同一天内你先调用了 set(Calendar.MONTH, 5),再 set(Calendar.DAY_OF_WEEK_IN_MONTH, 1),最终结果可能不是你预期的那一天。这在 java.time 里完全不存在,因为 LocalDate 没有 set 方法,只有 with 方法,而且 with 只接受明确的值或 TemporalAdjuster。
坑二:desugaring 配置里漏加依赖。Gradle 编译时不报错,运行时却报 NoClassDefFoundError,指向 java.time 的类。这种情况十有八九是只加了 compileOptions,忘记在 dependencies 里加 coreLibraryDesugaring。我给你说,这两个层级缺一不可,少一个就崩。
坑三:kotlinx-datetime 0.6.0 以后 Instant 类型变了。旧版本里常用 kotlinx.datetime.Instant,新版本里 Clock.System.now() 返回的是 kotlin.time.Instant,两者在 API 上略有差异,尤其在两个库共存的项目里容易出现类型推导混乱。遇到这种情况不用慌,看异常信息里类的完整包名就能区分。
坑四:格式化线程安全问题的另一面。DateTimeFormatter 本身线程安全,但 SimpleDateFormat 不是。老代码里如果混用了 SimpleDateFormat,并发格式化时会出现数据错乱。如果你接手了一个老项目,建议把所有 SimpleDateFormat 替换成 DateTimeFormatter,这属于低风险高收益的重构。
5.3 关于测试:日期代码必须写单元测试
日历相关代码是所有代码里最适合单元测试的一类,因为你不需要 mock 任何东西,只要传入固定日期就能断言结果。我的习惯是写一个纯函数,比如"计算某月的最后一个工作日",然后针对月份、周末、节假日各种边界条件写单测。这里有个小技巧:用 java.time 的 LocalDate.of 构建测试数据比用真实当前时间靠谱得多,因为真实时间会让测试结果不稳定,而且 CI 环境里时区还不一定相同。
还有一点经验:日期时间工具函数建议都设计成纯函数,不依赖全局状态和系统时钟。这样测试时才能自由定制时间源,生产环境也能在关键节点注入真实当前时间。如果你把 LocalDate.now() 直接写在业务里,单元测试几乎无法编写。
6. 写在最后:一点个人体会
日历 API 的话题其实非常大,从基础的日期运算到复杂的时区策略,从单机日历到云端日程同步,每一个方向都能单独写一篇万字长文。我在这篇文章里覆盖的是日常开发里最高频、最容易犯错的几个面:API 选型、日期运算与格式化、协程封装、Android 系统日历事件读写。
我个人的体会是:日期时间相关的代码很少出现在产品宣传里,但它像一个地基,一旦出错,用户的感知非常直接——会议时间对不上、倒计时跳错、提醒提前一天触发。这些都属于"低风险高负反馈"的 bug,排查成本远高于普通功能问题。所以与其靠运行时试错,不如在代码层面就把最容易出错的几个点堵死:统一用 java.time 或 kotlinx-datetime,避免新旧混用;所有日期运算走纯函数;涉及 Android 系统日历查询时务必走协程和 IO 线程。
最后再分享一个小技巧:如果你在 Kotlin 项目里经常需要做日期计算,不妨自己封装一个 DateUtils 对象,收敛所有日期操作的入口。这样以后发现问题时只改一个文件,其他业务代码完全不动,维护成本是最低的。日历 API 的复杂度不在于难,而在于杂。把复杂性封装在边界里,是 Kotlin 开发者在这个领域最值得做的一件事。