13、Calendar Provider:访问日历数据、读取事件、创建事件

日历功能,说白了就是手机上的「时间管家」。你想想看,现在哪个App不需要跟时间打交道?会议提醒、生日推送、行程同步……背后都是Calendar Provider在干活。

我最早接触这个API是在做一个企业协作App的时候。当时产品经理说:「我们要把钉钉的日程同步到系统日历里。」我心想,这不就是读写ContentProvider嘛,简单。结果一上手才发现——权限、账户、时区、重复事件……坑还真不少。

今天我就带你把这些坑一个个填平。

13.1 Calendar Provider 的数据模型

先搞清楚它长什么样。Calendar Provider 的核心表有三张:

表名 对应URI 说明
Calendars content://com.android.calendar/calendars 日历账户信息,比如「我的Google日历」、「公司Exchange日历」
Events content://com.android.calendar/events 具体的事件,比如「周三下午3点开会」
Instances content://com.android.calendar/instances/when 事件的实例,专门处理重复事件的时间展开

嗯,这里要注意:Instances 表是只读的。你不能直接往Instances里插数据,它是由系统根据Events里的重复规则自动生成的。

13.2 权限声明——第一步就卡住很多人

我记得第一次跑demo,直接崩了个SecurityException。为什么?因为日历权限分两级:

  • READ_CALENDAR:读取日历和事件
  • WRITE_CALENDAR:创建/修改/删除事件

而且从Android 6.0开始,这两个都是运行时权限。光在Manifest里声明不够,还得动态申请。

Manifest声明:

<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />

小技巧:我习惯把权限申请封装成一个工具方法,回调里统一处理。别在Activity里到处写requestPermissions,后期维护起来太痛苦。

13.3 读取日历列表——先找到能用的日历

你不能随便往任意日历里写事件。每个日历都属于一个账户(比如Google账号、手机本地账户)。写之前,得先查一下当前设备上有哪些日历可用。

// 查询所有可见的日历
val cursor = contentResolver.query(
    Calendars.CONTENT_URI,
    arrayOf(
        Calendars._ID,
        Calendars.NAME,
        Calendars.ACCOUNT_NAME,
        Calendars.ACCOUNT_TYPE
    ),
    "${Calendars.VISIBLE} = 1",  // 只查可见的
    null,
    null
)

cursor?.use {
    while (it.moveToNext()) {
        val id = it.getLong(0)
        val name = it.getString(1)
        val account = it.getString(2)
        Log.d("Calendar", "日历ID: $id, 名称: $name, 账户: $account")
    }
}

我个人习惯把查询结果缓存起来,因为日历列表不会频繁变化。每次写事件前都查一遍,性能上划不来。

13.4 读取事件——别一次性拉太多

读取事件时,最忌讳的就是全表扫描。你想想看,手机日历里可能存了几千条事件,全拉回来不卡才怪。

正确的做法是按时间范围过滤。用Instances表最合适:

val beginTime = // 今天0点的时间戳(毫秒)
val endTime = // 明天0点的时间戳

val uri = Uri.parse("content://com.android.calendar/instances/when/$beginTime/$endTime")
val cursor = contentResolver.query(
    uri,
    arrayOf(
        Instances.TITLE,
        Instances.BEGIN,
        Instances.END,
        Instances.EVENT_LOCATION
    ),
    null,
    null,
    "${Instances.BEGIN} ASC"
)

cursor?.use {
    while (it.moveToNext()) {
        val title = it.getString(0)
        val begin = it.getLong(1)
        val end = it.getLong(2)
        val location = it.getString(3)
        // 处理事件...
    }
}

注意:Instances表的URI格式是固定的,beginTime和endTime必须是毫秒级时间戳。我曾经传了秒级时间戳进去,结果查出来全是空的,排查了半天才发现是单位搞错了。

13.5 创建事件——核心操作

创建事件需要往Events表里插数据。这里有个关键点:必须指定日历ID,否则系统不知道这个事件属于哪个账户。

val calendarId = 1L // 从日历列表里选一个
val beginTime = System.currentTimeMillis() + 3600_000 // 一小时后
val endTime = beginTime + 1800_000 // 持续半小时

val values = ContentValues().apply {
    put(Events.CALENDAR_ID, calendarId)
    put(Events.TITLE, "Android架构课 - Calendar Provider")
    put(Events.DESCRIPTION, "深度解析日历数据访问")
    put(Events.EVENT_LOCATION, "线上会议室")
    put(Events.DTSTART, beginTime)
    put(Events.DTEND, endTime)
    put(Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
    put(Events.ALL_DAY, 0) // 非全天事件
}

val eventUri = contentResolver.insert(Events.CONTENT_URI, values)
val eventId = eventUri?.lastPathSegment?.toLong()
Log.d("Calendar", "创建成功,事件ID: $eventId")

嗯,这里有个坑:EVENT_TIMEZONE 必须填。不填的话,系统会拿默认时区,但不同厂商的ROM处理方式不一样。我遇到过小米手机上不填时区直接抛异常的情况。

13.6 重复事件——RRULE 的写法

如果你要创建「每周一上午10点开会」这种重复事件,就得用到RRULE(Recurrence Rule)。

val values = ContentValues().apply {
    put(Events.CALENDAR_ID, calendarId)
    put(Events.TITLE, "周会")
    put(Events.DTSTART, monday10Am)
    put(Events.DTEND, monday11Am)
    put(Events.EVENT_TIMEZONE, "Asia/Shanghai")
    put(Events.RRULE, "FREQ=WEEKLY;BYDAY=MO;COUNT=10")
    // 每周一,共10次
}

RRULE的语法遵循iCalendar标准。常用的几个:

  • FREQ:DAILY / WEEKLY / MONTHLY / YEARLY
  • BYDAY:MO,TU,WE,TH,FR,SA,SU
  • COUNT:重复次数
  • UNTIL:结束日期(格式:20251231T235959Z)

避坑指南:我曾经写过一个每月最后一天重复的事件,用了BYMONTHDAY=-1。结果在2月份就出问题了——2月只有28天,系统直接跳到了3月。后来改用BYDAY=-1MO(最后一个周一)才稳定。所以,边界日期一定要测试

13.7 知识体系总览

下面这张图把Calendar Provider的核心流程串起来了:

Calendar Provider 核心流程 权限申请 READ_CALENDAR / WRITE_CALENDAR 查询可用日历 Calendars.CONTENT_URI 读取事件 Instances 表 + 时间范围 创建事件 Events 表 + ContentValues 按时间过滤 避免全表扫描 指定日历ID + 时区 RRULE 处理重复 核心:权限 → 日历 → 事件(读/写)

13.8 更新与删除事件

更新和删除跟其他ContentProvider一样,通过URI加WHERE条件:

// 更新事件标题
val updateValues = ContentValues().apply {
    put(Events.TITLE, "更新后的标题")
}
contentResolver.update(
    Events.CONTENT_URI,
    updateValues,
    "${Events._ID} = ?",
    arrayOf(eventId.toString())
)

// 删除事件
contentResolver.delete(
    Events.CONTENT_URI,
    "${Events._ID} = ?",
    arrayOf(eventId.toString())
)

注意:删除事件时,如果事件是重复事件,系统默认只删除当前实例。要删除整个系列,需要设置Events.ORIGINAL_IDEvents.ORIGINAL_SYNC_ID。这个逻辑有点绕,我建议你在测试环境先跑一遍,确认行为符合预期。

13.9 实战建议

最后,分享几个我在实战中沉淀下来的经验:

  • 异步操作:所有日历操作都放在子线程。ContentProvider的CRUD虽然快,但数据量大时还是会卡UI。
  • 账户选择:如果设备上有多个日历账户,最好弹个选择器让用户自己选。别自作主张往某个账户里写。
  • 同步问题:写入事件后,系统不会立即同步到云端。如果需要强制同步,可以调用ContentResolver.requestSync(),但别频繁调用,会耗电。
  • 测试覆盖:不同厂商的ROM对日历Provider的实现有差异。华为、小米、三星我都踩过坑。建议在主流机型上都跑一遍。

好了,Calendar Provider的核心内容就这些。说白了就是三张表、两个权限、一个RRULE。你只要把数据模型理清楚,剩下的就是熟练工种了。


公众号:蓝海资料掘金营,微信deep3321