Android日历源码实战:自绘月视图与Repository数据层架构

发布时间:2026/10/16 6:49:27
Android日历源码实战:自绘月视图与Repository数据层架构 简介一份安卓日历源码学习包面向安卓应用开发者和有自定义日历需求的工程师适合希望深入系统日历实现的学习者。资料围绕日历合约类与日历提供者等核心类覆盖内容提供者数据操作、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技术栈整体选择偏保守但对日历场景是合理的。语言用的是KotlinUI用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): ListLocalDate? { 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: ListLocalDate? emptyList() private var eventsByDate: MapLocalDate, ListCalendarEvent 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): ListCalendarEvent { 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 mutableListOfCalendarEvent() 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:nameandroid.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): ListCalendarEvent }Repository只做一件事把上游数据源暴露给ViewModel同时可以在这一层做缓存和统一处理。简化版本如下class CalendarRepository( private val dataSource: CalendarDataSource ) { suspend fun getEvents(start: LocalDate, end: LocalDate): ListCalendarEvent { 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的签名实现一个从网络拉数据的版本即可返回值都是ListCalendarEvent。MemoryCalendarDataSource的实现逻辑很简单内部维护了一个MutableListCalendarEvent第一次创建时塞进几条本月的演示事件之后支持增删改。它存在的意义有两个一是开发阶段不需要权限就能调试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()来取日期显式指定用设备时区转换。这个改动要做在所有日期换算的地方包括网格日期、事件查询范围、提醒时间计算。从那以后我每写一个跟时间相关的模块都会强制过一遍“把设备时区改到UTC14再改到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): SetLocalDate }MonthCalendarView只依赖这个接口按天判断是否画圆点。这样业务侧的标记逻辑可以独立变化比如从服务端拉取排班表或者结合本地缓存判断。6.2 周起始日和主题色配置化把DateUtils.getMonthGridDates的firstDayOfWeek参数提到一个AppConfig单例里初始化时从服务端配置读取主题色则抽成几个Paint颜色常量替换成自己App的品牌色。这两处改完日历的外观就能贴合宿主App。6.3 改造后的验证清单每次改动后我会按这个清单过一遍切换月份看日期网格是否错位连续跨月事件是否正确显示点击日期后事件列表是否回顶系统日历里增删事件后回到页面是否刷新把设备时区改到西半球日期是否偏移。这套验证做完才算一个可以在真实环境交付的日历组件。那次把周起始日写死上线后被海外用户反馈日期错位之后我每次改日历都会强制走一遍上述验证从那时起再没有出过同类问题。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询