简介:一份安卓日历源码学习包,面向安卓应用开发者和有自定义日历需求的工程师,适合希望深入系统日历实现的学习者。资料围绕日历合约类与日历提供者等核心类,覆盖内容提供者数据操作、SQLite数据库持久化、权限管理、事件增删改、同步适配器机制、闹钟管理器提醒、多账户日历管理及界面适配器等关键知识点,可帮助读者从系统源码层面理解日历功能完整的实现链路,包括事件模型中的开始时间、结束时间、地点、提醒字段,以及多日历账户之间的关联关系与后台同步任务调度逻辑。压缩包共35个文件,以Java源码和class字节码为主,另含安卓清单文件、布局与资源文件、APK安装包及工程配置文件,总体积仅83KB,内容精炼便于快速浏览。已有370人学习下载。通过分析源码中的表结构、内容提供者调用方式和界面加载逻辑,可以掌握日历事件模型、提醒重复规则以及通知触发流程;结合APK与dex文件可进行反编译对照,理解完整构建链路,适合从源码层面系统提升日历类应用的开发能力。
1. 一份能直接跑的 Android 日历源码:月视图自绘与日程管理一次到位
之前给App加日历日程页,翻过系统自带日历的源码,业务和界面耦合得很紧,想改个周起始日要牵连好几个类。后来拿到一份android日历源码,核心思路是把月视图做成自绘View,数据层用Repository包一层,事件既能读系统日历也能换成自己的本地数据源。整个工程是一个能直接编译运行的日历App,同时也可以当功能模块嵌入现有项目。适合两类读者:想在应用里做日历/日程功能、不想从零画网格的开发者,以及想读一份完整的自定义View加CalendarProvider示例的进阶学习者。下面从架构分层、月视图绘制、数据层实现到避坑和二次改造逐一拆开讲。
2. 模块架构与技术选型:为什么把日历拆成 View 和数据层两部分
2.1 项目定位:完整应用和可嵌入模块的平衡
这份源码不是那种只有一个Activity、画个静态网格的演示Demo。打开工程能看到完整的三段式交互:顶部是月份切换栏,中间是自绘月视图,底部是当天的事件列表。点击某一天,月视图高亮,事件列表跟着切换;左右滑动或点箭头可以切换月份;长按日期可以新建事件,新建后数据落库并刷新界面。也就是说,拿过来直接就能当独立App用。
同时它的模块边界又做得比较干净。UI层不直接碰ContentResolver,数据层不知道今天星期几。想嵌进自己的项目时,只需要拷贝ui/month目录、data目录和util目录,再把自己的MainActivity作为宿主,把MonthCalendarFragment挂上去即可。我在本地改过一次,把MainActivity换成自己的壳,删掉自带的侧边栏和设置页面,只留日历加事件列表,整个改动不到一个小时。能做到这一点,依赖的是后面要讲的Repository分层,而不是把代码堆在一个类里。
这种“既能跑完整App,又能拆成组件”的定位,其实比纯组件库更适合拿来学习。组件库为了通用性会引入大量抽象,读起来费劲;纯Demo又缺少真实的数据流转。这份源码停在了一个合适的位置:抽象就两层,接口足够简单,但数据流是完整的。
2.2 技术栈与架构:MVVM + Repository + 自绘 View
技术栈整体选择偏保守,但对日历场景是合理的。语言用的是Kotlin,UI用XML配合ViewBinding,月视图是自定义View用Canvas绘制。数据层同时支持两个数据源:一个是系统日历的CalendarProvider,一个是本地内存仓库,用于展示无权限时的模拟数据。调度层用协程和StateFlow做异步加载,提醒功能通过AlarmManager实现。
为什么不用第三方日历库或者Compose?常见的原因有三个。第一,日历控件的定制需求通常很具体,比如要画农历、画节气、画自定义标记点,第三方库看似省事,真到改绘制层时不如自己画来得直接。第二,这份源码的月视图绘制逻辑只有几百行,维护成本并不高。第三,Compose版本的日历绘制现在也成熟了,但XML加自定义View依然是存量项目里最常见的组合,兼容性更好。
架构上分成三层,依赖方向是单向的:
| 层级 | 主要成员 | 职责 |
|---|---|---|
| UI层 | Fragment、MonthCalendarView、EventListAdapter | 绘制、手势、列表展示 |
| 状态层 | MonthViewModel、StateFlow | 持有选中日期和事件数据,桥接UI与数据 |
| 数据层 | CalendarRepository、CalendarDataSource、两个实现类 | 事件查询、数据源切换、提供数据 |
UI层只依赖状态层,状态层只依赖数据层接口。数据层两个实现类之间没有任何横向依赖,切换数据源时只改一处绑定。状态层选择StateFlow而不是LiveData,是因为StateFlow在协程里做数据变换更顺手,collect时天然支持协程取消,不需要处理lifecycleOwner的绑定,代码里少一套空安全判断。如果你的项目还没迁到协程,换回LiveData也不难,两个实现类完全不受影响。
2.3 目录结构逐层拆解
工程源码的目录组织如下,我把与日历业务无关的壳代码省略了:
app/src/main/java/com/example/calendar/ ├── ui/ │ ├── MainActivity.kt │ ├── month/ │ │ ├── MonthCalendarFragment.kt │ │ ├── MonthCalendarView.kt │ │ └── MonthViewModel.kt │ ├── week/ │ │ └── WeekCalendarView.kt │ └── event/ │ ├── EventListFragment.kt │ └── EventListAdapter.kt ├── data/ │ ├── model/ │ │ └── CalendarEvent.kt │ ├── repo/ │ │ ├── CalendarRepository.kt │ │ └── CalendarDataSource.kt │ └── source/ │ ├── SystemCalendarDataSource.kt │ └── MemoryCalendarDataSource.kt ├── util/ │ ├── LunarUtil.kt │ └── DateUtils.kt └── provider/ └── ReminderScheduler.kt逐个说职责。ui/month是核心,MonthCalendarView负责月视图的自绘,MonthCalendarFragment负责把选中日期和事件数据绑定起来,MonthViewModel负责从数据层拿数据。ui/week是周视图,内部复用了自绘思路,只是把日期范围换成当前周。ui/event是下方事件列表,逻辑很简单,就是跟着选中日期刷新数据。
data/repo里的CalendarDataSource是接口,source目录下的两个类分别是系统日历实现和内存实现。util里的LunarUtil内置了公历转农历的数据表,DateUtils负责周起始日和日期范围计算这类纯函数逻辑。provider里的ReminderScheduler是对AlarmManager的封装,新建事件后设置提醒用的。
有一个边界值得学习:自定义View没有被ViewModel直接调用,所有View和ViewModel之间的通信都是通过Fragment转手的。也就是说,MonthCalendarView不知道ViewModel存在,它只对外暴露监听器和设置方法。这样做的目的是让View保持纯UI状态,不持有任何生命周期相关的东西,未来要把它换成Compose版本,Fragment和ViewModel完全不用动。
3. 核心模块拆解:月视图从日期计算到 Canvas 绘制的完整链路
月视图是整个源码的视觉核心。多数人看日历源码第一反应是“网格怎么画的”,其实网格本身不难,真正决定绘制对不对的是两件事:当前月1号落在第几列,以及这个月有多少天。这两件事算错,后面画什么都歪。
3.1 日期计算:先定基准,再画网格
我把日期计算单独放在DateUtils里,它不依赖任何Android类,纯Kotlin函数,写完可以直接做单元测试。先看核心函数:
object DateUtils { // 默认一周从周一开始,firstDayOfWeek 传 1 表示周一,7 表示周日 fun getMonthGridDates(year: Int, month: Int, firstDayOfWeek: Int): List<LocalDate?> { val firstDay = LocalDate.of(year, month, 1) val daysInMonth = firstDay.lengthOfMonth() // 当天是星期几,1=周一 ... 7=周日 val firstWeekday = firstDay.dayOfWeek.value // 计算1号前需要补几个空位 val leadingEmpty = (firstWeekday - firstDayOfWeek + 7) % 7 val totalSlots = ceil((leadingEmpty + daysInMonth) / 7.0).toInt() * 7 return (0 until totalSlots).map { index -> if (index < leadingEmpty) { null } else { firstDay.plusDays((index - leadingEmpty).toLong()) } } } }先说参数。firstDayOfWeek是配置项,传1代表周一开头,传7代表周日开头,避免写死。leadingEmpty是1号前面的空格数,比如某月1号是周三,按周一起始算,前面要补两个空位,它的计算方式是(3 - 1 + 7) % 7 = 2。totalSlots一定是7的倍数,这样网格永远完整,不会出现最后一行只有两三个格子的问题。
返回值里的null代表空格,LocalDate代表实际日期。为什么不用字符串而用LocalDate?因为后面事件数据是按LocalDate分组的,网格日期直接作为Map的key去查事件,避免再做一次日期解析,也避免字符串格式化带来的时区隐患。这里用YearMonth.lengthOfMonth()拿当月天数,比用Calendar.getActualMaximum更简洁,也不会被系统时区干扰。
这个函数有个细节值得学:把“配置”作为参数传进来,而不是在函数内部用Calendar.getInstance()读系统默认值。因为系统默认值会跟随用户Locale变化,同样一个月份,在不同地区设备上画出来的第一格完全不同。把决定权交给上层配置,UI层永远知道会发生什么。
3.2 MonthCalendarView 的测量与绘制
拿到日期网格后,剩下的就是绘制问题。MonthCalendarView直接继承View,没有用RecyclerView来画格子,因为42个格子以内的绘制用Canvas反而更简单,还避免了嵌套滚动。先看onMeasure和onDraw的关键代码:
class MonthCalendarView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private var gridDates: List<LocalDate?> = emptyList() private var eventsByDate: Map<LocalDate, List<CalendarEvent>> = emptyMap() private var selectedDate: LocalDate = LocalDate.now() private var currentMonth: YearMonth = YearMonth.now() private val textPaint = Paint().apply { isAntiAlias = true textSize = dp2px(14f) textAlign = Paint.Align.CENTER } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val width = MeasureSpec.getSize(widthMeasureSpec) val height = dp2px(280f) // 固定6行左右的高度,也可按比例算 setMeasuredDimension(width, height) } override fun onDraw(canvas: Canvas) { drawGridLines(canvas) drawDateNumbers(canvas) drawEventMarkers(canvas) drawSelectedCircle(canvas) } }onMeasure里做了一个简化处理:宽度由父布局决定,高度固定为280dp。这个固定高度在6行网格时足够放日期数字和标记点。如果要做高度自适应,可以用width / 7f算出列宽,再乘以行数去反推高度,但那样在字体缩放时容易把标记点挤出去,所以源码选择了固定高度,简单且稳定。
onDraw按四个步骤依次绘制:先画网格线,再画每个格子里日期数字,然后画有事件日期的标记点,最后画选中日期的圆圈。绘制顺序有讲究,网格线和日期数字是底,标记点是覆盖在数字上的,选中圈要最后画,否则会被日期数字盖住。具体的网格线绘制逻辑:
private fun drawGridLines(canvas: Canvas) { gridPaint.color = Color.parseColor("#E0E0E0") val cellWidth = width / 7f val cellHeight = height / gridRows for (i in 0..7) { canvas.drawLine(i * cellWidth, 0f, i * cellWidth, height.toFloat(), gridPaint) } for (i in 0..gridRows) { canvas.drawLine(0f, i * cellHeight, width.toFloat(), i * cellHeight, gridPaint) } }这里的gridRows是gridDates.size / 7,正常月份算出来是5或6,所以每次绘制前要重新计算行数,不能写死6。cellWidth固定用width / 7f,最后一个格子的宽度可能因为除不尽差几个像素,绘制时直接用i * cellWidth取浮点数,视觉上是均匀的。
日期数字的绘制用到了textAlign = CENTER,这样drawText时传的x坐标就是格子中心,不需要手动量文本宽度。baseline用Paint.FontMetrics计算,把文本放在竖直方向中心偏下一点。这个细节虽然小,但真会影响观感,baseline不对的时候数字看起来会整体偏上或偏下。
3.3 点击与手势:从像素坐标换算回日期
绘制解决了显示,交互要解决的是点击。MonthCalendarView只暴露了一个监听器:
interface OnDateSelectedListener { fun onDateSelected(date: LocalDate) } fun setOnDateSelectedListener(listener: OnDateSelectedListener) { this.listener = listener }点击事件的换算逻辑在onTouchEvent里:
override fun onTouchEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_DOWN) { val cellWidth = width / 7f val cellHeight = height / gridRows val col = (event.x / cellWidth).toInt() val row = (event.y / cellHeight).toInt() val index = row * 7 + col if (index in gridDates.indices) { gridDates[index]?.let { date -> selectedDate = date listener?.onDateSelected(date) invalidate() } } return true } return super.onTouchEvent(event) }这里有个简化:直接在ACTION_DOWN里处理,而不是用GestureDetector。原因是月视图不需要区分单击、双击、长按之外的复杂手势,ACTION_DOWN的延迟最低,手指按下去界面立刻响应。如果后续要加滑动切换月份,可以在此基础上扩展ACTION_MOVE的位移判断,但那样要把ACTION_DOWN的点击逻辑拆开,避免按下瞬间就触发选中。
坐标换算是整数运算:col是事件x坐标除以列宽取整,row是y坐标除以行高取整,index = row * 7 + col正好落到gridDates的对应位置。如果该位置是null空格,直接忽略,不做任何选中;如果是实际日期,更新选中状态并回调监听器。注意event.actionMasked和event.action的区别,多点触控时要用前者,这也是一个容易被忽略的细节。
3.4 Fragment 侧的事件联动
选中日期之后,Fragment要做两件事:更新ViewModel里的选中日期,重新过滤事件列表;让事件列表滚动到当天的第一条事件。第二件事容易被忽略,但体验差异很大。事件列表按日期分组,如果用户月初选中1号,列表顶部是1号的事件,然后往下翻到15号,再点15号时列表数据虽然变了,滚动位置还停留在原来的偏移上,用户会以为列表没刷新。所以源码里在adapter数据变更后做了一次滚动定位:
eventListAdapter.submitList(eventsForSelectedDate) if (eventsForSelectedDate.isNotEmpty()) { binding.eventList.scrollToPosition(0) }这行scrollToPosition(0)把列表拉回顶部,配合数据刷新,用户每次点击日期都从该日期最早的事件开始看。如果不想回顶,也可以改成滚动到对应事件的position,但日历场景下“点击日期看当天安排”的预期就是从头开始看,回顶更符合直觉。
还有一个联动细节:点击日期时,如果这个月的数据还没有加载完成,事件列表会先清空再等数据回来。ViewModel里用StateFlow保存事件列表,加载中下发空列表,加载完成再下发完整列表,这样UI不会展示上一个月的残留数据。下面第4章会讲这部分数据是怎么加载的。
4. 数据层实战:CalendarProvider 与 Repository 双源切换的一次实现
日历App的数据层和普通列表App有个明显区别:系统本身就有日历事件数据,存在CalendarProvider里,直接去读就能用。但真实项目中又未必愿意把App的数据塞进系统日历,比如要做请假审批、排班这类业务日程,数据应该在服务端。这份源码的好处是数据层给出了两个实现,读系统日历和读本地内存数据并存,切换只动一行。
4.1 查询系统日历事件:权限、投影与查询参数
先看读系统日历的实现类,它封装了对CalendarContract.Events表的查询:
class SystemCalendarDataSource(private val context: Context) : CalendarDataSource { override suspend fun getEventsBetween(start: LocalDate, end: LocalDate): List<CalendarEvent> { return withContext(Dispatchers.IO) { val startMillis = start.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() val endMillis = end.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() val projection = arrayOf( CalendarContract.Events._ID, CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART, CalendarContract.Events.DTEND, CalendarContract.Events.ALL_DAY, CalendarContract.Events.EVENT_COLOR ) val selection = "${CalendarContract.Events.DTSTART} >= ? AND ${CalendarContract.Events.DTSTART} < ?" val selectionArgs = arrayOf(startMillis.toString(), endMillis.toString()) val events = mutableListOf<CalendarEvent>() context.contentResolver.query( CalendarContract.Events.CONTENT_URI, projection, selection, selectionArgs, null )?.use { cursor -> while (cursor.moveToNext()) { events.add(CalendarEvent( id = cursor.getLong(0), title = cursor.getString(1), startTime = cursor.getLong(2), endTime = cursor.getLong(3), allDay = cursor.getInt(4) == 1, color = cursor.getInt(5) )) } } events } } }先说权限。读系统日历需要READ_CALENDAR权限,Android 6.0以上要在运行时动态申请。源码的做法是在进入日历页时检查ContextCompat.checkSelfPermission,没有权限就弹授权对话框,拒绝后降级到MemoryCalendarDataSource,保证页面不会白屏。如果你在自己的项目里接入这段代码,记得在AndroidManifest里加上<uses-permission android:name="android.permission.READ_CALENDAR"/>,而且一定要处理拒绝授权的分支。
再解释查询参数。日历事件的DTSTART存的是自1970年以来的毫秒时间戳,所以按时间范围查询最合理的方式是传两个毫秒值。这里我把end加了1天,变成end.plusDays(1).atStartOfDay(),目的是把结束日一整天都包含进来,否则当天的事件会因为毫秒数大于半夜零点而被排除。这是查日历事件最常见的边界错误之一,第5章还会单独讲。
选择DTSTART作为查询条件而不是DTEND,也有讲究。用开始时间过滤能保证“这个月有开始的事件”都被查出来,但会漏掉上个月开始、这个月才结束的跨月事件。严格的做法是同时考虑开始时间和结束时间的重叠区间,查询条件会变成DTSTART < 月末 AND DTEND > 月初,更准确但写起来长一点。源码选了前者,是因为跨月事件在月视图上会显示在开始日期当天,如果用户想看到整个跨月事件的完整周期,还需要额外的按月拆分逻辑,这超出了这份源码的定位。
cursor.use {}是Kotlin的扩展函数,确保cursor用完后自动关闭。query操作被withContext(Dispatchers.IO)移到后台线程,因为ContentResolver的query虽然内部有Binder调用,但结果集遍历和对象构造都在调用线程,数据量大时会卡UI。这里有个血泪经验:永远不要在主线程做cursor遍历,哪怕只是几十条事件,在某些ROM上也会出现明显的掉帧。
4.2 Repository 层设计:UI 不感知数据从哪来
数据层接口长这样,两个实现类分别继承它:
interface CalendarDataSource { suspend fun getEventsBetween(start: LocalDate, end: LocalDate): List<CalendarEvent> }Repository只做一件事:把上游数据源暴露给ViewModel,同时可以在这一层做缓存和统一处理。简化版本如下:
class CalendarRepository( private val dataSource: CalendarDataSource ) { suspend fun getEvents(start: LocalDate, end: LocalDate): List<CalendarEvent> { return dataSource.getEventsBetween(start, end) } }看起来有点薄,像是白包了一层,但它把“数据从哪来”隔离在了业务之外。ViewModel持有的依赖是CalendarRepository,不是某个具体的DataSource实现。如果想要把数据源从系统日历换成服务器接口,只需要新建一个实现CalendarDataSource的类,然后在创建Repository时换掉传入的实例,UI层代码一行都不用动。
具体到这份源码,绑定关系在Fragment里:
val source: CalendarDataSource = if (hasCalendarPermission()) { SystemCalendarDataSource(requireContext()) } else { MemoryCalendarDataSource() } val repository = CalendarRepository(source) val viewModel = MonthViewModel(repository)这就是双源切换的全部成本。正常权限下用系统日历,拒绝授权后用内存模拟数据,数据格式一致,UI不会因为权限拒绝而崩。对要接自己服务端的开发者来说,照着MemoryCalendarDataSource的签名实现一个从网络拉数据的版本即可,返回值都是List<CalendarEvent>。
MemoryCalendarDataSource的实现逻辑很简单,内部维护了一个MutableList<CalendarEvent>,第一次创建时塞进几条本月的演示事件,之后支持增删改。它存在的意义有两个:一是开发阶段不需要权限就能调试UI;二是作为“数据源接口如何实现”的最小示例,比看文档直接。如果要做更完整的缓存策略,可以在Repository里按“缓存优先、网络兜底”的思路扩展,但那会引入数据陈旧度的处理,这份源码刻意没有加。
4.3 事件变更同步:ContentObserver 与前台刷新兜底
读到了系统日历事件之后还面临一个问题:用户在系统日历App里新建了一个日程,回到我们的日历页,怎么知道数据变了?如果只有进入页面时查一次,用户停留在页面期间的数据变更就看不到。源码用ContentObserver监听系统日历表的变化:
private val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { viewModel.refreshCurrentMonth() } } override fun onResume() { super.onResume() context?.contentResolver?.registerContentObserver( CalendarContract.Events.CONTENT_URI, true, observer ) viewModel.refreshCurrentMonth() } override fun onPause() { super.onPause() context?.contentResolver?.unregisterContentObserver(observer) }registerContentObserver的第二个参数true表示监听子目录的变更,日历事件的变化通常会通知到Events表URI及其子路径,这里用true更稳妥。onPause里必须注销observer,否则Fragment不可见时还在收通知,轻则浪费电量,重则引发内存泄漏。
不过ContentObserver在某些系统版本上存在不稳定情况,偶尔会收不到通知,或者延迟很久。所以源码在onResume里同时做了一次兜底刷新,每次页面回到前台都重新查一遍当月数据。这两个机制配合,平时靠observer实时更新,前台切换时再全量兜底,基本覆盖了所有变更场景。代价是onResume多一次query,但因为查询范围只有一个月,数据量很小,实测对性能影响可以忽略。
这套“observer保实时、onResume保兜底”的组合,还可以换成更省的做法:只在事件列表为空时兜底刷新。但日历场景里用户可能删掉当天全部事件,空列表也需要触发刷新,用“页面可见就刷”反而更简单。数据变更本来就是低频操作,多刷一次的成本远低于漏刷一次的用户困惑。
5. 避坑指南:Android 日历开发中 5 个最常见的踩坑现场
日历功能看着简单,真正上线后出问题的往往不是复杂的算法,而是日期处理里几个不起眼的点。这一节列五个我在不同项目里实际遇到过的问题,都是现象、原因、解决的完整记录,希望对你有帮助。
5.1 周起始日不统一导致月初定位错位
现象:同样的App,国内用户看到的月视图第一列是周一,海外用户看到的第一列是周日,而且月初几号的格子位置跟着整体错位,看起来像是“每月1号画歪了”。
原因:开发时直接在代码里用了Calendar.getInstance().firstDayOfWeek,这个值跟随系统Locale,国内设备通常返回2(周一),海外设备返回1(周日)。初版代码没有显式指定起始日,导致不同地区网格定位不一致。
解决:在DateUtils里把起始日改成配置项,默认周一,需要周日开头的产品在Application初始化时设置。关键是不要在绘制函数内部读取系统默认值,把决定权交给上层。改完之后,我把海外用户的时区、Locale、系统语言组合起来测了一遍,用不同Locale配置的设备分别跑,网格定位不再变化。
5.2 时区把事件日期偏移一天
现象:用户反映某天创建的事件,第二天再看跑到前一天或后一天去了,而且只发生在跨国出差或设备时区变更之后。
原因:CalendarContract.Events的DTSTART存的是UTC毫秒,读取时如果用Calendar.getInstance().get(Calendar.DAY_OF_MONTH)拿日期,这个值依赖设备当前时区。开发机恰好在北京时区,测试时发现不了问题,用户飞到东时区后事件就整体偏移了。
解决:全部改用Instant.ofEpochMilli(startTime).atZone(ZoneId.systemDefault()).toLocalDate()来取日期,显式指定用设备时区转换。这个改动要做在所有日期换算的地方,包括网格日期、事件查询范围、提醒时间计算。从那以后我每写一个跟时间相关的模块,都会强制过一遍“把设备时区改到UTC+14再改到UTC-12看日期有没有变”的验证。
5.3 月视图与事件列表的滚动冲突
现象:月视图放在事件列表上方,手指在日历区域上下滑动时,事件列表跟着滚动,月视图完全没反应。
原因:事件列表是RecyclerView,默认情况下它的滚动事件会向上传递到父布局,月视图的事件被RecyclerView拦截了。
解决:月视图本身不处理垂直滚动,只处理点击,所以要让事件列表在手指落在月视图区域时不去响应滚动。做法是把RecyclerView的onInterceptTouchEvent重写:
class CalendarAwareRecyclerView : RecyclerView { override fun onInterceptTouchEvent(e: MotionEvent): Boolean { // 手指起点在月视图区域时,不拦截滚动 val startY = lastMotionY if (startY < monthViewHeight) return false return super.onInterceptTouchEvent(e) } }判断条件是手指按下时的Y坐标是否落在月视图范围内。这个方案有个前提,就是月视图固定在顶部,高度已知。如果月视图是要跟随滚动的,那就要换NestedScrolling机制,成本和复杂度都会上一个台阶,我一般会建议产品把日历固定,别做成随列表滚动的设计。
5.4 全量刷新让切月明显卡顿
现象:切换月份时,月视图和事件列表大约卡顿200毫秒,低端机上能感觉到明显的“闪一下”。
原因:初版代码在切换月份后直接adapter.notifyDataSetChanged(),并且月视图也走了invalidate()重绘全部42个格子。事件列表数据量虽然不大,但notifyDataSetChanged会触发整个列表的重新绑定,月视图的invalidate又会重走一遍onDraw,两者叠加就让切换动画掉帧。
解决:事件列表改成ListAdapter配合DiffUtil,只有数据真正变化时才更新条目;月视图的invalidate保留,因为网格本身变化不大,但标记点数据改为按日期Map直接读取,避免在draw里做循环查表。优化之后,切换月份只是当月数据变化,DiffUtil只刷新有差异的几行,卡顿感消失。对于日历这种数据量小的场景,DiffUtil的收益可能不如大列表明显,但它能逼迫你写出更规范的数据更新链路,值这个成本。
5.5 农历数据表的年份上限问题
现象:用户反馈某一天农历显示为空白,而且集中在2049年前后的日期。
原因:LunarUtil里内置的农历数据表只覆盖了1900到2100年,超出范围直接返回空字符串。初版代码没有做边界处理,导致超出范围的日期农历一栏什么都不显示,看起来像bug。
解决:在LunarUtil的入口处加范围判断,超出数据表年份时回退显示公历日期,同时在注释里标明“如需扩展年份请更新数据表”。这一点其实比改代码更重要:数据表类功能的边界不是运行时错误,而是静默地返回错误数据,回退到公历至少不会让用户觉得系统坏了。如果产品确实需要更广的年份范围,建议换用天文算法计算农历,而不是无限扩充数据表。
6. 二次开发实战:把这份源码改造成自己的日程组件
拿到源码跑通之后,真正的价值在于怎么按自己产品的需求改。我建议按下面几个方向动手,每个方向改动量都不大,但对理解整个工程很有帮助。
6.1 把标记点数据源接口化
现在的月视图标记点是直接读eventsByDate这个Map的,如果你想显示“有排班的日期”“有请假审批的日期”,可以把它抽象成接口:
interface MarkerProvider { fun getMarkerDates(start: LocalDate, end: LocalDate): Set<LocalDate> }MonthCalendarView只依赖这个接口,按天判断是否画圆点。这样业务侧的标记逻辑可以独立变化,比如从服务端拉取排班表,或者结合本地缓存判断。
6.2 周起始日和主题色配置化
把DateUtils.getMonthGridDates的firstDayOfWeek参数提到一个AppConfig单例里,初始化时从服务端配置读取;主题色则抽成几个Paint颜色常量,替换成自己App的品牌色。这两处改完,日历的外观就能贴合宿主App。
6.3 改造后的验证清单
每次改动后,我会按这个清单过一遍:切换月份看日期网格是否错位;连续跨月事件是否正确显示;点击日期后事件列表是否回顶;系统日历里增删事件后回到页面是否刷新;把设备时区改到西半球日期是否偏移。这套验证做完,才算一个可以在真实环境交付的日历组件。那次把周起始日写死上线后被海外用户反馈日期错位之后,我每次改日历都会强制走一遍上述验证,从那时起再没有出过同类问题。希望帮到你。
本文还有配套的精品资源,点击获取