
简介基于Android系统的日历管理器完整项目源码面向Android开发学习者、课程设计与毕业设计学生核心功能包括向系统日历插入日历账户、查询日历账户以及添加、修改、删除日历事件并设置事件提醒。压缩包共46个文件以Java源码、XML界面布局、PNG图片素材为主另含Gradle构建脚本、ProGuard混淆规则、项目说明文档和预览动图整体大小仅2.02MB可直接导入Android Studio运行与调试。已有64人学习下载适合作为系统日历API调用和事件管理类项目的参考范例。源码模块划分清晰账户管理与事件管理分层实现项目说明梳理了从日历账户创建到提醒触发的关键流程配合效果预览可快速理解开发思路如有二次开发需求也能在现有结构上扩展自定义提醒、同步策略等功能。1. 日历管理器写进系统日历前先弄懂“账户”从哪来“日历管理器”听起来只是把事件列表换成另一种UI但真正会让项目卡住的从来不是列表页而是怎么和系统日历的数据层打交道。系统日历不是一张孤立的数据表它由 CalendarProvider 统一对外暴露数据按“账户 — 日历 — 事件”三层归属一个账户下可以挂多本日历一本日历下才有事件。想往系统日历插入数据第一步往往不是 insert Events 表而是先解决“账户”这个前提。很多源码包直接把事件写进去结果在真机上要么抛 SecurityException要么静默失败问题就出在账户没注册、权限没申请、时区字段写错这三件事上。这篇内容围绕源码里最常见的两个功能——向系统日历插入日历账户、查询日历账户——把从配置到落地的完整路径讲清楚适合做课程表、会议预约、排班工具需要通过系统日历同步日程的 Android 工程师。2. 搭建可复现源码工程权限、依赖与Android日历Provider基础配置打开一份日历管理器源码先不要急着看 Activity 和列表 adapter先检查三样东西权限是否声明、认证器Authenticator是否注册、ContentResolver 的调用是否符合系统日历的约束。市面上的问题源码大多不是 UI 写得差而是 Android 6.0 之后的运行时权限没有处理或者国产 ROM 上账户类型没被系统识别导致一运行就 SecurityException。所以这一章先把工程底座打对。2.1 用Android Studio建工程时配好 minSdk、compileSdk 和依赖用 Android Studio 新建项目时建议把 applicationId 取成和日历强相关的名字例如com.example.calendar后面注册账户类型时要用到这个包名体系。build.gradle 里的关键配置如下android { compileSdk 34 defaultConfig { applicationId com.example.calendar minSdk 26 targetSdk 34 versionCode 1 versionName 1.0 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 }这段配置里minSdk 26 是一个比较稳的起点。Android 8.0 之后 CalendarContract 的行为趋于稳定API 26 以下各厂商对日历 Provider 的改动差异很大适配成本高不值得为老版本付出额外精力。compileSdk 34 保证 CalendarContract 里的字段和常量都是完整的例如CALENDAR_ACCESS_LEVEL的取值、Instances.CONTENT_URI的路径参数在 34 上都不会出现编译期缺常量的问题。依赖上不需要引入第三方日历库系统日历操作全部走系统 API额外库只会增加源码包体积和未知冲突。2.2 AndroidManifest里必须注册的权限与账户认证器Android 日历操作需要两个权限READ_CALENDAR和WRITE_CALENDAR。它们属于同一权限组但申请时可以分开授权这一点后面会讲到。Manifest 中除了权限声明还必须注册账户认证器服务否则向系统日历插入账户时系统不认识你的账户类型。Manifest 的关键片段如下uses-permission android:nameandroid.permission.READ_CALENDAR / uses-permission android:nameandroid.permission.WRITE_CALENDAR / service android:name.sync.CalendarAuthenticatorService android:exportedtrue android:permissionandroid.permission.BIND_AUTHENTICATOR intent-filter action android:nameandroid.accounts.AccountAuthenticator / /intent-filter meta-data android:nameandroid.accounts.AccountAuthenticator android:resourcexml/authenticator / /serviceBIND_AUTHENTICATOR是系统提供给账户认证器服务的专属权限不可省略。exportedtrue是因为系统进程需要通过跨进程绑定找到这个 Service。如果漏掉meta-data那一段AccountManager 在查询账户类型时不会把com.example.calendar纳入系统已知类型后续 addAccount 和插入日历都会失败。xml/authenticator文件定义了账户类型内容如下account-authenticator xmlns:androidhttp://schemas.android.com/apk/res/android android:accountTypecom.example.calendar android:labelstring/app_name android:iconmipmap/ic_launcher android:smallIconmipmap/ic_launcher /android:accountType是全局唯一标识全文所有地方都用它来区分“这是你的 App 的账户”。项目里插入日历账户、查询日历账户、卸载时清理账户都依赖这个字符串保持一致。一个 App 可以注册多个账户类型但对日历管理器来说一个就够多了反而需要在查询时做额外过滤。2.3 CalendarAuthenticatorService 的最小实现不能省账户认证器 Service 本身可以是一个空实现但必须有。它的职责是让系统认为这个账户类型真实存在。常见做法是维护一个AbstractAccountAuthenticator的子类实例public class CalendarAuthenticatorService extends Service { private CalendarAuthenticator mAuthenticator; Override public void onCreate() { super.onCreate(); mAuthenticator new CalendarAuthenticator(this); } Override public IBinder onBind(Intent intent) { return mAuthenticator.getIBinder(); } }CalendarAuthenticator继承AbstractAccountAuthenticator其中onAddAccount、onGetAuthToken等方法直接返回 null 或空 Bundle 即可。日历账户不需要密码体系它是纯本地的数据归属标识不需要和任何服务端通信。很多源码包把这个 Service 当作可以删掉的部分实际上它决定了AccountManager.addAccountExplicitly能否被系统接受所以这一块必须留在工程里并且和authenticator.xml中的 accountType 严格对应。2.4 运行时权限申请要放在操作日历之前Android 6.0 之后日历权限属于危险权限必须在运行时申请。常见错误是把权限申请写在首次点击按钮时但日历 Provider 的权限检查发生在 binder 调用那一层一旦权限没授予抛出的 SecurityException 是直接崩溃级别的。建议在进入首页时统一申请代码里用 ActivityCompat 处理String[] permissions { Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR }; if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, permissions, 100); }请求码 100 是自己定义的常量回调里要分别检查两个权限的结果。READ_CALENDAR和WRITE_CALENDAR在系统弹窗里可能被用户分开授权只检查第一个容易出现权限明明没给全代码却继续往下跑的情况。拒绝后再次点击查询或插入应该主动跳转系统设置页引导用户打开权限而不是直接重发请求弹窗因为 Android 11 之后连续拒绝两次会永久禁止再次弹窗。3. 查询日历账户把系统日历读成自己能用的事件模型查询系统日历账户是插入事件的前置步骤。向 Events 表插入一条数据必须带CALENDAR_ID而这个 ID 几乎不会是你硬编码的值它来自 Calendars 表的_ID字段。查询的入口是CalendarContract.Calendars.CONTENT_URI对应的 address 是content://com.android.calendar/calendars但实际使用时不要拼字符串用系统提供的 Uri 常量更安全。3.1 Calendars 表投影字段与 Access Level 的过滤规则查询日历账户时不需要把整张表的字段都查出来只查下面几个就够用字段含义典型值_ID日历 ID插入事件时用1CALENDAR_DISPLAY_NAME日历显示名“我的课程表”ACCOUNT_NAME账户名coursemyappACCOUNT_TYPE账户类型com.example.calendarCALENDAR_ACCESS_LEVEL访问级别700代表 OWNERVISIBLE系统日历中是否可见1可见0隐藏访问级别这一列直接决定你能不能往某本日历里插入事件。级别从低到高分别是CALENDAR_ACCESS_NONE0、CALENDAR_ACCESS_FREE_BUSY100、CALENDAR_ACCESS_READ200、CALENDAR_ACCESS_RESPOND300、CALENDAR_ACCESS_OVERRIDE400、CALENDAR_ACCESS_CONTRIBUTOR500、CALENDAR_ACCESS_EDITOR600、CALENDAR_ACCESS_OWNER700。插入事件要求至少CONTRIBUTOR如果拿到的是只读日历代码里应该在写入前先做检查并给出提示而不是等 insert 返回 null 再猜原因。3.2 用 ContentResolver 查询日历账户并封装成查询结果查询操作本身不复杂麻烦的是 Cursor 的生命周期管理。下面这段是查询日历账户并封装成对象的通用写法public ListCalendarInfo queryCalendars(Context context) { ArrayListCalendarInfo list new ArrayList(); Uri uri CalendarContract.Calendars.CONTENT_URI; String[] projection new String[]{ CalendarContract.Calendars._ID, CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, CalendarContract.Calendars.ACCOUNT_NAME, CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.VISIBLE }; Cursor cursor null; try { cursor context.getContentResolver().query( uri, projection, null, null, CalendarContract.Calendars.CALENDAR_DISPLAY_NAME ASC); if (cursor ! null) { while (cursor.moveToNext()) { CalendarInfo info new CalendarInfo(); info.id cursor.getLong(0); info.displayName cursor.getString(1); info.accountName cursor.getString(2); info.accountType cursor.getString(3); info.accessLevel cursor.getInt(4); info.visible cursor.getInt(5) 1; list.add(info); } } } finally { if (cursor ! null) { cursor.close(); } } return list; }查询时没有传 selection会返回系统里所有账户下的所有日历。对于日历管理器源码来说这个行为是正确的因为用户可能想切换到任意一本已有日历也可能想新建。排序参数建议显式写不写排序时返回顺序不保证getContentResolver().query的第五个参数是 SQL 排序语句传CALENDAR_DISPLAY_NAME ASC可以保证每次列表顺序一致。Cursor 用完必须关闭否则 ContentResolver 的匿名 Cursor 会泄漏长时间运行后日志里会出现Cursor finalized without prior close()的警告而且再次查询会变慢。3.3 按账户名过滤与按时间范围查询实例如果产品只关心自己的账户查询时用 selection 过滤 ACCOUNT_TYPE而不是把全部日历拉回来再在内存里判断String selection CalendarContract.Calendars.ACCOUNT_TYPE ?; String[] args new String[]{com.example.calendar}; Cursor cursor resolver.query( CalendarContract.Calendars.CONTENT_URI, projection, selection, args, null);selection 里的?占位符必须和 selectionArgs 一一对应这是系统日历 Provider 遵循的标准参数化查询方式。不要用字符串拼接拼 SQL账户名或显示名里如果带单引号拼接后整条查询直接崩。查询事件时如果只关心某段时间不要直接查 Events 表再自己判断时间范围应该用CalendarContract.Instances表它是系统日历为时间范围查询专门生成的实例视图。用法是往 CONTENT_URI 上追加起止毫秒值long now System.currentTimeMillis(); long begin now - TimeUnit.DAYS.toMillis(7); long end now TimeUnit.DAYS.toMillis(30); Uri instancesUri CalendarContract.Instances.CONTENT_URI.buildUpon() .appendPath(String.valueOf(begin)) .appendPath(String.valueOf(end)) .build(); Cursor cursor resolver.query(instancesUri, new String[]{ CalendarContract.Instances.EVENT_ID, CalendarContract.Instances.TITLE, CalendarContract.Instances.BEGIN, CalendarContract.Instances.END }, null, null, null);Instances.CONTENT_URI 必须携带 begin 和 end 两个路径参数否则系统会直接抛 IllegalArgumentException这是源码包里极容易踩的坑。查询结果的BEGIN和END是事件实际发生时间对应的毫秒值已经根据事件的时区做过换算直接排序展示即可不要再额外叠加一次时区偏移。4. 向系统日历插入日历账户和事件先注册账户再写CalendarProvider插入日历账户这件事比插入普通数据多了一层“系统身份”的要求。向 Calendars 表插入一行必须包含ACCOUNT_NAME和ACCOUNT_TYPE而且这个账户类型要能通过系统检查。Android 8.0 之后系统对账户类型的校验越来越严格直接构造 ContentValues 塞两个字段就插入的做法在很多机型上会得到空 Uri 或者异常。4.1 直接插入日历账户不生效时先通过AccountManager注册账户正确顺序是先用 AccountManager 注册一个本地账户再把账户信息写入 Calendars 表。注册账户这一步不需要网络AccountManager.addAccountExplicitly是纯本地操作把账户名和类型告诉系统即可Account account new Account(coursemyapp, com.example.calendar); AccountManager am AccountManager.get(context); boolean added am.addAccountExplicitly(account, null, null);第二个参数是密码日历账户用不到传 null 即可。返回值可能是 false代表账户已经存在或者添加被系统拒绝这时不需要报错直接继续后续插入日历的流程因为账户可能来自上一次运行。注册完成后再插入 Calendars 表ContentValues cv new ContentValues(); cv.put(CalendarContract.Calendars.ACCOUNT_NAME, account.name); cv.put(CalendarContract.Calendars.ACCOUNT_TYPE, account.type); cv.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, 我的课程表); cv.put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5); cv.put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER); cv.put(CalendarContract.Calendars.VISIBLE, 1); cv.put(CalendarContract.Calendars.SYNC_EVENTS, 1); cv.put(CalendarContract.Calendars.CALENDAR_TIME_ZONE, TimeZone.getDefault().getID()); Uri calUri resolver.insert(CalendarContract.Calendars.CONTENT_URI, cv); long calendarId ContentUris.parseId(calUri);几个关键参数需要注意。CALENDAR_TIME_ZONE必须是 IANA 时区 ID例如Asia/Shanghai不能写GMT08:00否则部分 ROM 的日历应用解析失败事件时间会整体偏移。CALENDAR_ACCESS_LEVEL设成CAL_ACCESS_OWNER表示你的 App 拥有这本月历后续插入事件不会碰到权限墙。SYNC_EVENTS1是让系统日历服务认可这本日历可以接收数据变更的关键很多源码包漏掉这个字段结果事件插进去了但系统日历应用里看不到。插入成功后ContentUris.parseId拿到的就是本日历的唯一 ID后续插入事件、删除日历都用它定位。提示不要省略 Authenticator 只靠 addAccountExplicitly 插入日历。某些设备上直接插入 Calendars 表也能返回成功但换一台手机就会出现账户类型不存在的异常。第 2 章里注册的CalendarAuthenticatorService和这节的操作是配套的少了任何一个整条链路都不完整。4.2 插入事件并绑定提醒的完整函数拿到 calendarId 之后插入事件就是一个常规的 ContentValues 写入。完整函数如下public long insertEvent(Context context, long calendarId, String title, String description, long startMs, long endMs) { ContentValues cv new ContentValues(); cv.put(CalendarContract.Events.CALENDAR_ID, calendarId); cv.put(CalendarContract.Events.TITLE, title); cv.put(CalendarContract.Events.DESCRIPTION, description); cv.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().getID()); cv.put(CalendarContract.Events.DTSTART, startMs); cv.put(CalendarContract.Events.DTEND, endMs); Uri uri context.getContentResolver().insert( CalendarContract.Events.CONTENT_URI, cv); if (uri null) { throw new IllegalStateException(insert event failed); } long eventId ContentUris.parseId(uri); insertReminder(context, eventId, 10); return eventId; } private void insertReminder(Context context, long eventId, int minutes) { ContentValues cv new ContentValues(); cv.put(CalendarContract.Reminders.EVENT_ID, eventId); cv.put(CalendarContract.Reminders.MINUTES, minutes); cv.put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT); context.getContentResolver().insert( CalendarContract.Reminders.CONTENT_URI, cv); }DTSTART和DTEND接收的是毫秒时间戳但这里有个隐藏要求传入的时间戳要和EVENT_TIMEZONE语义一致。一般做法是先把用户选择的本地时间按目标时区转成毫秒再传进去而不是直接把System.currentTimeMillis()塞进字段。insertReminder里的MINUTES单位是分钟10 代表提前 10 分钟提醒0 代表准时提醒。METHOD_ALERT是通知栏提醒系统默认的 METHOD_DEFAULT 在某些 ROM 上会被忽略显式指定更可靠。4.3 重复事件RRULE与EVENT_TIMEZONE的配合规则日历管理器源码里经常要支持“每周一上课”这类重复日程对应字段是RRULEContentValues cv new ContentValues(); cv.put(CalendarContract.Events.RRULE, FREQWEEKLY;BYDAYMO,TU;COUNT10);RRULE 的规则以DTSTART为基准FREQWEEKLY表示按周重复BYDAYMO,TU表示周一和周二COUNT10表示最多生成 10 次实例。注意UNTIL这个终止条件的值必须是 UTC 时间的毫秒数直接用本地时间转换很容易出现跨天偏差所以建议能不用 UNTIL 就不用改用 COUNT。系统日历会负责把重复实例展开成单独的时间槽不需要业务层自己循环生成多条事件。时区这一块最容易出问题的是跨天事件。比如一个从 23:00 到次日 01:00 的晚间课如果只传本地毫秒值而 EVENT_TIMEZONE 填错系统日历展示时可能变成 22:00 开始。推荐写入前用 java.time 统一转换ZonedDateTime zdt ZonedDateTime.of(2025, 3, 1, 9, 0, 0, 0, ZoneId.of(Asia/Shanghai)); long startMs zdt.toInstant().toEpochMilli();这段代码把2025-03-01 09:00 Asia/Shanghai转成对应的绝对时间毫秒数。写入时EVENT_TIMEZONE同样填写Asia/Shanghai系统日历在展示时就能准确还原出 09:00 这个墙上时间。如果用户没有特殊要求TimeZone.getDefault().getID()是可行方案但一旦产品需要支持用户选择时区就必须用 ZonedDateTime 显式转换。5. 验证日历管理器源码是否可靠三个检查技巧功能写完后不要急着在 UI 上点一遍就交付。日历数据属于跨进程共享数据插入结果受 ROM、账户状态、时区影响很大建议用下面三个方式把源码包的可靠性验证到位。5.1 先用debugDump把账户和事件表打下来在 Activity 或 Manager 里放一个静态调试方法把当前所有账户和事件的关键字段输出到 Logcatpublic static void debugDump(Context context) { ListCalendarInfo calendars queryCalendars(context); for (CalendarInfo c : calendars) { Log.d(CalDebug, c.id | c.displayName | access c.accessLevel | account c.accountName); } }运行时执行adb logcat -s CalDebug就可以看到全部日历账户的状态。之所以不推荐用adb shell content query来验证是因为不同品牌的 ROM 对日历 Provider 的 authority 修改并不一致命令行的 Uri 在小米和原生 Android 上可能指向不同的表。用应用自己打日志的方式验证看到的就是应用实际查询到的数据更可靠。5.2 在系统日历App中做可见性验证插入数据后打开系统日历 App检查三处日历列表是否能切换到你创建的那本“我的课程表”事件是否出现在正确的时间点设置里的账户列表中是否存在coursemyapp。如果系统日历里看不到但日志显示插入成功优先检查VISIBLE和SYNC_EVENTS两个字段这两个字段组合决定了数据是否会暴露给系统日历的 UI 层。如果连事件都没有先回到第 4 章确认账户注册是否真正成功特别是accountType是否和authenticator.xml里保持一致。5.3 卸载App后账户残留的处理AccountManager 注册的本地账户不会随 App 卸载自动删除这是系统行为。用户卸载应用后设置里还会留着coursemyapp这个空账户体验很不好。应在应用的设置页提供“清除日历数据”入口调用Account account new Account(coursemyapp, com.example.calendar); AccountManager.get(context).removeAccount(account, null, null);这个方法会异步移除账户。调用前先用AccountManager.getAccountsByType(com.example.calendar)确认账户存在并且只移除自己类型的账户不要碰用户的 Google 账户或厂商账户。移除账户后该账户下挂载的日历和事件也会一并被系统清理所以给这个按钮加二次确认弹窗是必要的。本文还有配套的精品资源点击获取