简介:一份来自CSDN的寿星万年历安卓端完整源码,围绕农历/节气展示这一核心场景,演示了Android四大组件中的Activity/Fragment/Service配合使用、ContentProvider与SQLite本地数据读写、系统Calendar API同步、AlarmManager定时提醒及Android 6.0+运行时权限等关键机制,是Android开发者拆解真实日历类项目的好素材。整个资源包含179个文件,以65个Java类、64个XML布局及资源配置、42个PNG图标为主,另带SQLite数据库、Android-support-v4.jar等运行与调试基础组件,压缩包体积仅2.34MB,结构清晰、便于按模块分析。源码内置农历转换与节气推算算法、自定义MonthView日历网格、SlidingMenu侧滑菜单、WheelView滚动选择器及借款计算器等现成模块,可直观学习滚动列表适配、自定义View绘制、动态布局加载和事件分发等进阶写法。已有930人浏览学习,适合希望从零梳理Android数据层、UI层与系统服务协同的开发者参考实践。
1. 安卓应用要做农历、节气、干支,最省心的是直接拿一套成熟源码来改
做过日历类应用的开发者都有同感:公历转农历看似简单,真要把闰月、节气时刻、干支纪年全部算对,自己从头写至少要多花两三周,而且很容易在边界日期上翻车。寿星万年历sxwnl安卓源码就是为这个场景准备的——它把公农历转换、节气计算、干支生肖、闰月判断这些硬骨头算法做成了可直接调用的安卓工程,打开就能跑,核心逻辑是纯Java实现,不依赖服务端。它的价值在于:你不需要懂天文算法细节,只需要搞清楚怎么调用、怎么改界面、怎么处理边界,就能把一个能用的万年历功能嵌进自己的App里。
这套源码适合三类人:一是产品里需要“农历日期+节气提醒”但不想从零写算法的客户端工程师;二是想研究公农历换算实现的学生或入门开发者;三是接外包单子需要快速交付日历模块的开发者。接下来从源码结构、核心调用、集成步骤、经典踩坑、验证方法到进阶技巧,一条线讲完。
2. sxwnl 源码第一眼:从类结构看懂万年历的换算主线
2.1 目录与核心类:谁是入口、谁是算法、谁是展示
拿到这份安卓源码后,先别急着跑。我习惯先打开工程目录,把包结构扫一遍,弄清哪些是算法核心、哪些只是界面壳子。sxwnl 源码的典型结构大致是这样:
src/main/java/com/demo/calendar/ ├── core/ │ ├── sxwnl/ │ │ ├── Solar.java // 公历对象 │ │ ├── Lunar.java // 农历对象 │ │ ├── LunarSolarConverter.java // 公农历互转核心 │ │ ├── SolarTermsUtil.java // 节气计算 │ │ └── GanZhiUtil.java // 干支纪年纪日 ├── ui/ │ ├── CalendarActivity.java // 日历页入口 │ └── MonthAdapter.java // 网格适配器 └── util/ ├── DateConvertManager.java // 转换管理封装 └── FestivalUtil.java // 节日、节气标记这里每个类的职责很清晰。Solar和Lunar是两个基础数据对象,一个装载公历年月日时分秒,一个装载农历年月日、是否闰月、干支、生肖等字段。LunarSolarConverter是核心入口,所有公农历互转都走它。SolarTermsUtil负责节气计算,它返回的是某个节气在公历里的精确时刻,不是近似日期。GanZhiUtil处理干支和生肖,这里有一个容易搞错的地方:干支和生肖以立春为界,而不是以正月初一为界,这个后面避坑章节再展开。
从使用角度讲,你主要打交道的类是DateConvertManager,这个封装类里面维护了缓存和线程安全逻辑。最底层的SolarTermsUtil和GanZhiUtil一般情况下不需要直接动,除非你要修改节气的判定口径。
2.2 公历转农历与农历转公历:两条主链路的调用方式
万年历的核心功能翻来覆去就是那两件事:公历转农历得到农历日期,农历转公历得到对应的公历日期。sxwnl 的入口方法非常直白,看一眼就能用:
// 公历 2024年2月10日 12点0分0秒 Solar solar = new Solar(2024, 2, 10, 12, 0, 0); Lunar lunar = LunarSolarConverter.SolarToLunar(solar); Log.d("sxwnl", "农历:" + lunar.getLunarYear() + "年" + (lunar.isLeap() ? "闰" : "") + lunar.getLunarMonth() + "月" + lunar.getLunarDay() + "日"); Log.d("sxwnl", "干支:" + lunar.getYearGanZhi() + " 生肖:" + lunar.getAnimal());这段代码的逻辑是:先构造一个公历对象,把年月日时分秒传进去,然后调用SolarToLunar静态方法,拿到一个Lunar对象。getLunarYear()拿农历年,isLeap()判断这个月是否是闰月,getLunarMonth()和getLunarDay()拿农历月和日,getYearGanZhi()拿干支,getAnimal()拿生肖。参数方面要注意:Solar构造函数里的时分秒不是摆设,节气计算依赖精确时间,如果你只需要日期,传 0 点 0 分 0 秒也能算出正确农历日期,但算节气会有偏差。
反向转换同样简单:
Lunar lunar = new Lunar(2024, 1, 1, false); // 2024年正月初一,非闰月 Solar solar = LunarSolarConverter.LunarToSolar(lunar); Log.d("sxwnl", "公历:" + solar.getSolarYear() + "年" + solar.getSolarMonth() + "月" + solar.getSolarDay() + "日");Lunar 的构造函数参数依次是农历年、农历月、农历日、是否闰月。这里有个使用习惯要养成:先确认目标年份的农历年是否正确,比如公历2024年2月10日对应的农历年是甲辰年,但农历年号是 2024,不要被公历年误导。
2.3 节气与干支纪年:不是查表,是算出来的
很多轻量级日历库的节气是硬编码查表,每年手动维护一张表,一过范围就出错。sxwnl 的节气计算走的是天文算法路线,根据年份计算太阳黄经到达特定度数的时刻,然后把时刻换算成公历日期时间。节气数据的获取方式如下:
SolarTerm term = SolarTermsUtil.getSolarTerm(2024, 4); // 第4个节气:清明 Log.d("sxwnl", "清明时刻:" + term.getSolar().getSolarYear() + "-" + term.getSolar().getSolarMonth() + "-" + term.getSolar().getSolarDay() + " " + term.getSolar().getSolarHour() + ":" + term.getSolar().getSolarMinute());第一个参数是年份,第二个是节气序号,从 1 到 24。这里的序号不是月份,而是节气在一年里的排列顺序,比如 1 是小寒,2 是大寒,3 是立春,4 是清明。看到这个调用方式时要知道:节气计算依赖整点时刻,算法内部对 1900 年之后的数据有较高精度,1900 年以前会退化,所以设计产品时别把年份范围拉得太宽。
干支纪年的算法更有意思。它把年份拆成天干地支两个维度循环,60 年一轮回。sxwnl 在计算干支时,以立春时刻作为年份分界点——这意味着同一个公历日期,在立春前和立春后,干支和生肖可能完全不同。
3. 把 sxwnl 集成进你的安卓工程:Gradle 配置与工具类封装
3.1 引入方式:模块拷贝还是源码依赖
sxwnl 源码的集成有两种方式,我自己两种都用过,做出的选择很明确:直接把 core 包拷贝进自己的工程,而不是把整个 demo 工程当作 module 依赖。
理由有三个。第一,demo 工程里的 UI 代码和你自己的视觉风格大概率冲突,改起来成本比拷贝源码高;第二,算法包的依赖只有 Java 标准库,没有第三方库,拷贝后几乎不需要额外配置;第三,通用功能层面的定制(缓存、线程、日志)必须改动源码,拷贝到自己工程里改着顺手。
如果你用 Gradle 管理依赖,只在build.gradle里做最小配置:
android { compileSdk 33 defaultConfig { minSdk 21 targetSdk 33 } } dependencies { // sxwnl 算法包无第三方依赖,不需要额外引入 }注意minSdk建议不低于 21,这部分考虑不是算法本身的要求,而是后面封装用的ConcurrentHashMap、java.time相关类在低版本上有兼容问题。如果你要适配更老的机型,需要自己再做兼容处理。
3.2 封装一个 DateConvertManager:线程安全与结果缓存
sxwnl 的核心转换方法都是静态调用,本身没有状态,意味着线程安全没问题。但实际业务里有个隐患:如果不加缓存,每次在 UI 层调SolarToLunar都会重新做节气计算和干支推算,一个屏幕上几十个日期格子同时刷新,卡顿是必然的。所以我建议做一层封装,把计算结果缓存起来:
public class DateConvertManager { private static final int MAX_CACHE_SIZE = 1024; private final Map<String, Lunar> cache = new LinkedHashMap<String, Lunar>(MAX_CACHE_SIZE, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<String, Lunar> eldest) { return size() > MAX_CACHE_SIZE; } }; public Lunar solarToLunar(int year, int month, int day) { String key = year + "-" + month + "-" + day; Lunar cached = cache.get(key); if (cached != null) { return cached; } Solar solar = new Solar(year, month, day, 0, 0, 0); Lunar lunar = LunarSolarConverter.SolarToLunar(solar); cache.put(key, lunar); return lunar; } }这个封装的思路是:用LinkedHashMap实现一个固定大小的 LRU 缓存,最多缓存 1024 条转换结果,超出后自动淘汰最久没用的记录。key 用公历日期的字符串,value 是转换后的Lunar对象。这样同一页面重复渲染时,直接从缓存取,绕开底层算法。参数方面,MAX_CACHE_SIZE = 1024对日历场景够用,因为一屏最多显示几十天,用户翻来翻去也就几百天。如果你要显示十年日历,可以调大这个值,但要注意内存占用,每个Lunar对象里有多个字符串字段,1024 条大约占几百 KB 内存。
3.3 在 RecyclerView 里按周翻页:从日期到农历的最后一公里
集成到列表里时,不要在onBindViewHolder里直接调算法。我见过太多人把转换写进 UI 绑定流程,最后滑动卡成 PPT。正确做法是提前算好一屏加一页的缓存,然后在绑定阶段只读缓存。
public class MonthAdapter extends RecyclerView.Adapter<MonthAdapter.VH> { private final DateConvertManager convertManager = new DateConvertManager(); private List<DayCell> displayDays = new ArrayList<>(); static class DayCell { int solarYear; int solarMonth; int solarDay; String lunarText; boolean isFestival; } @Override public void onBindViewHolder(VH holder, int position) { DayCell cell = displayDays.get(position); holder.solarText.setText(cell.solarYear + "-" + cell.solarMonth + "-" + cell.solarDay); holder.lunarText.setText(cell.lunarText); if (cell.isFestival) { holder.lunarText.setTextColor(Color.parseColor("#D32F2F")); } } public void updateDays(int year, int month) { // 预计算:传入月首日和月尾日,生成 DayCell 列表 displayDays = buildMonthCells(year, month); notifyDataSetChanged(); } private List<DayCell> buildMonthCells(int year, int month) { List<DayCell> cells = new ArrayList<>(); // 循环当月每一天,通过 convertManager.solarToLunar 得到农历字段 // 组装 lunarText 和 isFestival 后放入 cells return cells; } }这里的核心思想是:UI 绑定只做赋值操作,不做任何计算逻辑。buildMonthCells在数据更新时一次性生成整个月的DayCell列表,之后onBindViewHolder只是把提前算好的字符串往控件上填。if (cell.isFestival)用于高亮传统节日,比如春节、中秋,这类信息也是预先在DayCell里标记好的。
4. 避坑专用章:sxwnl 安卓集成中的五个经典翻车现场
4.1 节气时刻差了 8 小时,对不上权威日历
现象:代码里算出清明是 15:02,但用户手机显示 16:02,怎么看都差一个小时。
原因:sxwnl 的节气算法返回的是东八区北京时间,而系统Calendar默认使用设备本地时区。如果设备在国外时区,或者你用了UTC格式化,就会出现整小时偏移。
解决:输出节气时间时,强制指定时区,不要依赖系统默认值。
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); sdf.setTimeZone(TimeZone.getTimeZone("GMT+08:00")); String text = sdf.format(termTimeMillis);从那以后我遇到日历类需求,所有时间格式化都统一写一个工具方法,强制走东八区,避免各写各的。
4.2 闰月显示成“下个月”,月份序号大了一
现象:2020 年有闰四月,公历 5 月 23 日起进入闰四月。界面上却显示成“五月”,导致整个月的农历日期全部错位。
原因:拿到Lunar对象后,直接用getLunarMonth()当作显示月份,没有先判断isLeap()。闰四月和五月是相邻的两个月,但月份序号都是 4,只是闰月标志不同。
解决:在封装层就把显示文本拼好,避免 UI 层自己判断:
if (lunar.isLeap()) { lunarText = "闰" + lunar.getLunarMonth() + "月"; } else { lunarText = lunar.getLunarMonth() + "月"; }做数据绑定的时候,永远只信任封装层给出的字符串,不要在onBindViewHolder里做逻辑判断。
4.3 生肖在正月初一之后变了,但有人说是“立春才换”
现象:App 在 2024 年 2 月 10 日(正月初一)显示“龙”,而另一款日历在 2 月 4 日立春就显示“龙”,两边对不上。
原因:sxwnl 的getAnimal()按农历年计算,正月初一为界;但部分民俗场景按立春为界。两种说法都有依据,关键是产品口径要统一,不要混用。
解决:在需求阶段明确“生肖/干支按农历年还是立春切换”。如果产品需要立春口径,不要改Lunar.getAnimal(),而是单独用GanZhiUtil按立春时刻计算。这属于定制需求,文档里要写清楚,避免上线后扯皮。
4.4 快速滑动列表时 ANR,“卡死”在某个日期转换
现象:从一月快速滑到十二月,界面卡住几秒,随后弹出“无响应”对话框。
原因:RecyclerView的快速滚动会触发大量onBindViewHolder调用,而每个绑定里都执行了一次SolarToLunar。节气计算的三角函数耗时不低,加上SimpleDateFormat创建对象的开销,主线程直接被打垮。
解决:严格执行前面说的“预计算 + 缓存”方案,并且在DateConvertManager里把缓存改成线程安全版本:
Map<String, Lunar> cache = new ConcurrentHashMap<>();如果数据量特别大,还可以加一个异步填充逻辑:先显示公历日期,等算完再notifyItemRangeChanged补上农历。
4.5 年份超过 1900 年范围,数据直接错乱
现象:输入 1899 年 12 月 31 日,农历显示成 1900 年冬月三十,看起来合理但实际是错的。
原因:sxwnl 的农历数据表只覆盖 1900 年之后,更早年份没有数据支撑,算法只能硬凑。
解决:对外接口做范围校验:
if (year < 1900 || year > 2100) { throw new IllegalArgumentException("sxwnl 仅支持 1900-2100 年"); }产品上线前,把输入框的日期范围限制死,不要给用户留出错空间。
5. 验证与压测:万年历不是“能跑”就行,得对得上日子
5.1 用五十个已知日期做回归对照
集成完成之后第一件事不是看界面,而是做数据验证。我会准备一批“已知正确答案”的日期对,写一个冒烟测试跑一遍,确保算法在主干路上没有偏差。下面是一组常用对照数据,可以直接用:
| 公历日期 | 期望农历 | 期望干支/生肖 |
|---|---|---|
| 2024-02-10 | 甲辰年正月初一 | 甲辰 龙 |
| 2023-01-22 | 癸卯年正月初一 | 癸卯 兔 |
| 2020-01-25 | 庚子年正月初一 | 庚子 鼠 |
| 2020-05-23 | 闰四月初一 | 庚子 鼠 |
| 2000-01-01 | 己卯年冬月廿五 | 己卯 兔 |
写测试代码时可以这样组织:
@Test public void testSolarToLunar() { Lunar lunar = DateConvertManager.getInstance() .solarToLunar(2024, 2, 10); assertEquals("正月初一", lunar.getLunarMonth() + "月" + lunar.getLunarDay() + "日"); assertEquals("甲辰", lunar.getYearGanZhi()); assertEquals("龙", lunar.getAnimal()); }这段测试的最佳实践是:把对照表做成 CSV 或 JSON 放assets目录,测试代码循环读取,每读一行验证一行。以后改算法、改封装,跑一遍这个脚本就知道有没有改坏。我在项目里维护着一个 60 行的对照表,覆盖闰月、春节、除夕、腊月、元旦这些容易出错的边界。
5.2 边界测试:闰月、年首年尾、极早极晚日期
边界测试要专门做一组,因为农历算法的 bug 大多藏在日期的极限位置。我一般会在测试代码里穷举“每月最后一天”和“每年最后一天”:
// 检查一年内所有日期的转换不抛异常且结果有值 @Test public void testEveryDayInOneYear() { DateConvertManager manager = DateConvertManager.getInstance(); int year = 2024; Calendar calendar = Calendar.getInstance(); calendar.clear(); calendar.set(year, 0, 1); for (int i = 0; i < 366; i++) { int y = calendar.get(Calendar.YEAR); int m = calendar.get(Calendar.MONTH) + 1; int d = calendar.get(Calendar.DAY_OF_MONTH); Lunar lunar = manager.solarToLunar(y, m, d); assertNotNull(lunar); assertTrue(lunar.getLunarYear() >= 1900); assertTrue(lunar.getLunarMonth() >= 1 && lunar.getLunarMonth() <= 12); calendar.add(Calendar.DAY_OF_MONTH, 1); } }这种“扫一年”的测试看起来简单,但能抓出大量问题:某一天对应的农历月超出 1-12 范围、某一天转换结果为空、某个月份缺失。另外 2033 年前后是农历算法的经典困难年份,建议单独把 2033 年的全年扫一遍。
5.3 性能压测:转换 10 万次日历的耗时与内存
万年历的算法是纯 CPU 计算,性能指标主要看两点:单次转换耗时、高并发下的内存增长。压测代码长这样:
@Test public void testPerformance() { DateConvertManager manager = DateConvertManager.getInstance(); long start = System.nanoTime(); for (int i = 0; i < 100000; i++) { int year = 1900 + (i % 200); int month = 1 + (i % 12); int day = 1 + (i % 28); manager.solarToLunar(year, month, day); } long cost = System.nanoTime() - start; float avgMs = cost / 100000f / 1_000_000f; Log.d("sxwnl", "平均单次转换耗时: " + avgMs + "ms"); }在我的测试环境里,带缓存的DateConvertManager平均单次耗时在 0.01ms 级别,而不带缓存直接调LunarSolarConverter大约在 0.1-0.5ms。差距看起来不大,但一屏 42 个格子,一页就有 4-20ms,足以让列表滑动掉帧。性能目标定一个数:单次转换不超过 1ms,内存增长不超过 5MB/10万次,超过就要查缓存是不是没生效。
6. 最后一个技巧:把节气做成“当天提醒”,比满屏红字好用
很多日历 App 的节气展示就是一行灰色小字,用户根本注意不到。我建议把“节气当天提醒”做成系统通知,这样能提升活跃度,又不需要额外权限。核心实现是:用 sxwnl 算出下一个节气日期,然后在当天上午 9 点发一条通知。
public void scheduleNextTerm(Context context) { Lunar lunar = DateConvertManager.getInstance().solarToLunar( currentYear, currentMonth, currentDay); // 从当前日期顺延查最近的一个节气 SolarTerm nextTerm = SolarTermsUtil.getNextSolarTerm( currentYear, currentMonth, currentDay); // 构造提醒时间:节气当天上午 9:00 Calendar remindTime = Calendar.getInstance(); remindTime.set(nextTerm.getSolar().getSolarYear(), nextTerm.getSolar().getSolarMonth() - 1, nextTerm.getSolar().getSolarDay(), 9, 0, 0); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); Intent intent = new Intent(context, TermRemindReceiver.class); intent.putExtra("term_name", nextTerm.getName()); PendingIntent pendingIntent = PendingIntent.getBroadcast(context, nextTerm.getSolar().getSolarDay(), intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); alarmManager.setExact(AlarmManager.RTC_WAKEUP, remindTime.getTimeInMillis(), pendingIntent); }这里有两个细节要留意。第一,AlarmManager.setExact在部分国产 Rom 上会被系统限频,导致通知延迟到达,需要引导用户把应用加入“自动启动”白名单。第二,PendingIntent的 requestCode 我直接用日期里的“日”字段,避免同一天多个节气互相覆盖。
我从一开始做日历应用时,总是喜欢把所有日期都算好再展示,结果上了线才发现老机型卡顿、时区偏移、闰月错位这些事。从那以后,我每次集成这类日历算法,都强制自己走一遍“验证脚本 + 边界测试 + 压测”三部曲,再顺手把节气提醒加好,产品完整度立刻高了一个档次。这份源码能直接解决农历算法这块硬骨头,剩下的就是你在 UI 和提醒这些地方多花心思。希望帮到你。
本文还有配套的精品资源,点击获取