news 2026/10/21 10:09:10

JAVA万年历源码解析:农历转换与日期逻辑避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JAVA万年历源码解析:农历转换与日期逻辑避坑指南

简介:这份JAVA万年历源码面向Java初学者与课程设计实践者,以完整可运行的桌面程序帮助读者掌握日期时间处理、Swing界面设计与事件驱动编程。项目将万年历与记事本功能合二为一,左侧为日记输入区,右侧为日历展示区,支持月份下拉选择、前后翻月翻年、一键回到当前日期,并可通过保存、删除按钮管理日记记录,删除时弹出确认对话框防止误操作。源码中涵盖Calendar与java.time日期操作、闰年判断、当月天数与起始星期计算、文件读写与异常处理、多种布局管理器组合等核心知识点,结构清晰,适合作为课程设计参考或Java GUI入门练手项目。资源包为1个doc文件,约317KB,内含课程设计报告与完整源码说明,从总体设计、变量与方法表到运行效果截图一应俱全。目前已有184人学习,便于读者对照文档理解代码组织思路,快速完成自己的万年历程序。

1. 从一份 JAVA 万年历源码说起:为什么日期逻辑总在二月翻车

很多人第一次接触 JAVA 万年历源码,是在课程设计或者工具类练手项目里。表面看它只是打印一张日历表,实际动手才发现,真正难的不是循环和排版,而是农历转换、闰年判断、节气推算这几块硬骨头。我见过不少版本,公历部分跑得挺顺,一到农历就集体翻车,尤其是春节前后的日期,差一天是常态。这份源码要解决的核心问题就三个:给定任意公历日期,算出对应农历、干支、生肖,再把它渲染成一张能看的月历。适合谁?适合已经会写基础 Java、想找一个能把算法、数据结构、格式化输出串起来的练手项目的人。也适合需要在自己系统里嵌入日期选择器、排班表、记账本这类功能的开发者,直接拿这套逻辑去改。下面我按实际落地顺序,把这份源码拆开讲透。

2. 农历转换的底层逻辑:一张表撑起整个万年历

2.1 为什么农历不能用公式硬算

公历是纯太阳历,闰年规则简单到可以写成一行三元表达式。农历是阴阳合历,月份跟着月相走,年份又要对齐回归年,所以必须靠“置闰”来补差。这个置闰不是均匀的,十九年七闰只是平均规律,具体哪一年闰哪个月,历史上由观测决定,现代由天文台发布。这意味着你不可能用一个封闭公式从公历直接推出农历,必须依赖一张预先算好的数据表。常见做法是:把 1900 年到 2100 年(或更长)每年的农历信息压缩成一个整数,存进数组。每个整数里用二进制位记录:哪几个月是大月、哪一年有没有闰月、闰月是几月、正月初一对应的公历日期偏移。这份 JAVA 万年历源码用的就是这套方案,核心是一个long[] lunarInfo数组,长度对应年份跨度。

// 农历数据表,每个 long 的二进制位编码了一年的信息 // 低 4 位:闰月月份,0 表示无闰月 // 5-16 位:1-12 月的大小月,1 为大月 30 天,0 为小月 29 天 // 17-20 位:闰月天数,1 为大月 // 其余位:正月初一相对于当年 1 月 1 日的偏移天数 private static final long[] LUNAR_INFO = { 0x04bd8L, 0x04ae0L, 0x0a570L, 0x054d5L, 0x0d260L, 0x0d950L, 0x16554L, 0x056a0L, 0x09ad0L, 0x055d2L, // ... 后续年份数据,实际项目中会补全到 2100 年 };

这段代码的关键在于位运算的约定。0x04bd8这个值,低 4 位是8,表示闰月是八月;中间 12 位表示各月大小;高位表示偏移。你不需要背下每个十六进制数,但必须理解解码函数怎么写。下面这个lunarYearDays方法就是用来算某农历年总天数的。

// 计算农历某年总天数 private static int lunarYearDays(int year) { int sum = 348; // 12 个月先按小月 29 天算,共 348 天 long info = LUNAR_INFO[year - 1900]; // 从第 4 位到第 15 位,逐位检查是否为大月 for (int i = 0x8000; i > 0x8; i >>= 1) { if ((info & i) != 0) { sum += 1; // 大月补 1 天 } } // 再加上闰月天数 return sum + leapDays(year); }

逻辑说明:先按 12 个小月算出 348 天,然后遍历二进制位,遇到 1 就加一天。0x8000到0x8这个范围正好覆盖 12 个月份的标志位。参数year是农历年份,范围必须在数据表覆盖的区间内,否则数组越界。这里有个容易忽略的点:闰月天数要单独加,因为闰月本身不在这 12 个月的循环里。leapDays方法负责判断闰月是大月还是小月,返回 30 或 29,没有闰月则返回 0。

2.2 公历转农历的完整步骤

有了年天数、月天数、闰月信息,公历转农历就变成一道减法题。思路是:先算出公历日期距离 1900 年 1 月 31 日(基准日)的总天数,然后逐年减去农历年的天数,直到剩余天数不够减,确定农历年份;再逐月减去农历月天数,确定月份和日期。基准日选 1900 年 1 月 31 日是因为那天正好是 1900 年农历正月初一,数据表也是从这一年开始的。

// 公历转农历,返回 [农历年, 农历月, 农历日, 是否闰月] public static int[] solarToLunar(int year, int month, int day) { // 计算距离基准日的天数 int offset = (int) ((dateToDays(year, month, day) - dateToDays(1900, 1, 31))); int lunarYear = 1900; int daysInYear; // 逐年减,确定农历年 while (offset > 0) { daysInYear = lunarYearDays(lunarYear); if (offset < daysInYear) { break; } offset -= daysInYear; lunarYear++; } // 逐月减,确定农历月 int lunarMonth = 1; int leap = leapMonth(lunarYear); boolean isLeap = false; while (offset > 0) { int daysInMonth; // 处理闰月:如果当前月是闰月,天数用闰月天数 if (leap > 0 && lunarMonth == leap + 1 && !isLeap) { daysInMonth = leapDays(lunarYear); isLeap = true; lunarMonth--; // 闰月不占正常月份序号 } else { daysInMonth = monthDays(lunarYear, lunarMonth); } if (offset < daysInMonth) { break; } offset -= daysInMonth; lunarMonth++; if (isLeap && lunarMonth == leap + 1) { isLeap = false; } } // offset 此时是剩余天数,0 表示初一 return new int[]{lunarYear, lunarMonth, offset + 1, isLeap ? 1 : 0}; }

参数说明:year、month、day是公历日期,month从 1 开始。返回值里第四个元素用 1 表示闰月,0 表示正常月。这段代码里最容易出错的是闰月的处理顺序。注意lunarMonth--那一行,目的是让闰月不占用正常月份编号,否则后续月份会整体偏移。另一个坑是offset的边界:当offset等于 0 时,表示当天就是农历初一,所以最后返回offset + 1。如果offset减到负数,说明日期计算有误,通常是基准日或者数据表对不上。

2.3 干支与生肖的推算

干支和生肖不需要额外数据表,纯计算就能出来。天干 10 个、地支 12 个,用农历年份对 10 和 12 取模即可。生肖就是地支的通俗叫法。注意农历年份的起点是正月初一,不是公历 1 月 1 日,所以必须先转成农历年再算。

private static final String[] GAN = {"甲","乙","丙","丁","戊","己","庚","辛","壬","癸"}; private static final String[] ZHI = {"子","丑","寅","卯","辰","巳","午","未","申","酉","戌","亥"}; private static final String[] ANIMALS = {"鼠","牛","虎","兔","龙","蛇","马","羊","猴","鸡","狗","猪"}; // 根据农历年获取干支和生肖 public static String getGanZhi(int lunarYear) { int ganIndex = (lunarYear - 4) % 10; int zhiIndex = (lunarYear - 4) % 12; // 处理负数取模 if (ganIndex < 0) ganIndex += 10; if (zhiIndex < 0) zhiIndex += 12; return GAN[ganIndex] + ZHI[zhiIndex] + "年"; } public static String getAnimal(int lunarYear) { int index = (lunarYear - 4) % 12; if (index < 0) index += 12; return ANIMALS[index]; }

这里减 4 是因为公元 4 年是甲子年,作为基准。取模后如果为负要加回周期长度,这是 Java 取模运算的特性,很多人在这里踩坑。参数lunarYear必须是农历年份,传公历年份会差几天到一个月,春节前后尤其明显。

3. 把源码跑起来:从控制台日历到可复用工具类

3.1 最小可运行版本的目录结构

拿到一份 JAVA 万年历源码,别急着改,先按最小结构跑通。我一般会整理成三个文件:LunarCalendar.java放核心算法,CalendarPrinter.java负责控制台输出,Main.java做入口。这样职责清晰,后面想换成 Swing 或者导出 JSON 都方便。如果你拿到的源码是一个大文件,建议先拆开,否则调试时定位困难。

# 目录结构 src/ main/ java/ com/ example/ calendar/ LunarCalendar.java CalendarPrinter.java Main.java

编译命令用javac即可,不需要额外依赖。如果你的 JDK 是 8 以上,直接javac -d out src/main/java/com/example/calendar/*.java,然后java -cp out com.example.calendar.Main。注意包名要和目录对应,否则会报找不到主类。

3.2 控制台日历的格式化输出

打印月历的关键是算清楚当月 1 号是星期几,然后决定前面补几个空格。公历部分可以用java.util.Calendar或者java.time.LocalDate,但既然我们在练算法,建议自己算,避免依赖库的差异。下面是一个打印公历月历的方法。

// 打印指定公历年份和月份的日历 public static void printMonth(int year, int month) { System.out.println("一 二 三 四 五 六 日"); LocalDate firstDay = LocalDate.of(year, month, 1); int dayOfWeek = firstDay.getDayOfWeek().getValue(); // 1 是周一,7 是周日 int daysInMonth = firstDay.lengthOfMonth(); // 打印前导空格 for (int i = 1; i < dayOfWeek; i++) { System.out.print(" "); } for (int day = 1; day <= daysInMonth; day++) { System.out.printf("%2d ", day); if ((dayOfWeek + day - 1) % 7 == 0) { System.out.println(); } } System.out.println(); }

逻辑说明:dayOfWeek拿到的是 1 到 7,1 表示周一。前导空格数量是dayOfWeek - 1。每打印一个日期,判断是否到了周日((dayOfWeek + day - 1) % 7 == 0),到了就换行。参数year和month是公历,month从 1 开始。这个输出格式是周一开头,如果你习惯周日开头,把表头和取模逻辑调整一下即可。注意printf里的%2d保证个位数占两格,对齐更整齐。

3.3 把农历信息叠加到日历上

光有公历不够,万年历的价值在于农历。我一般会在每个公历日期下面加一行小字,显示农历日期或者节气。控制台里可以用\n换行,但会撑高行距。更实用的做法是只标注初一、十五和节气,避免信息过载。

// 在公历日期后附加农历标注 public static String getLunarLabel(int year, int month, int day) { int[] lunar = LunarCalendar.solarToLunar(year, month, day); int lunarDay = lunar[2]; int lunarMonth = lunar[1]; if (lunarDay == 1) { return lunarMonth + "月"; // 初一显示月份 } else if (lunarDay == 15) { return "十五"; } else { return String.valueOf(lunarDay); } }

参数说明:year、month、day是公历。返回值是用于显示的字符串。这里只做了简单标注,实际项目中你可能还需要加节气判断。节气需要另一张表,记录每个节气对应的公历日期,通常精确到天。如果你不需要节气,这套逻辑已经够用。注意solarToLunar返回的月份是农历月,闰月时第四位为 1,显示时可以加个“闰”字区分。

4. 避坑与排查:农历转换里那些让人抓狂的边界

4.1 春节前后日期差一天

现象:公历 1 月或 2 月的日期,转成农历后和手机日历对不上,差一天。原因:农历数据表里正月初一的偏移量编码有误,或者基准日选错了。很多网上流传的源码基准日写的是 1900 年 1 月 31 日,但数据表却是从 1900 年 1 月 1 日开始编码的,导致整体偏移。解决:核对数据表第一个值对应的农历年,确认基准日。可以用一个已知日期验证,比如 2024 年 2 月 10 日是农历正月初一,跑一下你的转换函数,看输出是不是 2024 年 1 月 1 日。

4.2 闰月月份判断错误

现象:某些年份的闰月显示成了正常月,或者正常月被当成闰月。原因:leapMonth函数返回的闰月月份没有和当前月做正确比较。常见错误是直接用lunarMonth == leap,但闰月是在正常月之后插入的,所以应该是lunarMonth == leap + 1。解决:在逐月减天数的循环里,先判断是否遇到闰月,遇到时用闰月天数,并且不要增加正常月份计数。参考第 2 章里的solarToLunar实现,注意isLeap标志位的切换时机。

4.3 数组越界导致程序崩溃

现象:传入 2100 年之后的日期,程序抛ArrayIndexOutOfBoundsException。原因:农历数据表只覆盖到 2100 年,超出范围后LUNAR_INFO[year - 1900]越界。解决:在solarToLunar入口加范围检查,超出时返回 null 或者抛出带说明的异常。更好的做法是把数据表扩展到 2200 年,但数据来源要可靠,不要自己瞎编。我一般会在工具类里加一个isSupportedYear方法,调用前先判断。

4.4 时区与日期边界

现象:服务器时区和本地时区不一致时,同一天算出来的农历不同。原因:LocalDate.now()依赖系统默认时区,如果服务器是 UTC,晚上 8 点之后日期会跳到第二天。解决:所有日期计算显式指定时区,比如LocalDate.now(ZoneId.of("Asia/Shanghai"))。或者在入口处统一把日期参数化,不要依赖当前时间。这个坑在跨时区部署时特别隐蔽,血泪经验是:只要涉及日期,永远不要用无参的now()。

4.5 性能问题:频繁转换拖慢界面

现象:在列表里渲染几百个日期,每个都调用solarToLunar,界面卡顿。原因:每次转换都要逐年逐月减天数,虽然单次很快,但量大时累积明显。解决:加一层缓存,用Map<String, int[]>把已经算过的日期存起来。键可以用year-month-day拼接。如果日期范围固定,也可以在初始化时批量算好。注意缓存要设置上限,避免内存泄漏。另一个优化是提前把农历年天数、月天数算好存数组,避免重复位运算。

5. 进阶技巧:把万年历做成可配置的日期服务

5.1 用策略模式替换硬编码的数据表

数据表虽然直接,但扩展性差。如果你需要支持多个年份区间,或者从文件加载数据,可以定义一个LunarDataSource接口,把getYearInfo、getLeapMonth等方法抽象出来。默认实现用内置数组,需要时换成读 JSON 或数据库。这样测试也方便,可以注入一个只包含几年的小数据集,不用跑全量。

public interface LunarDataSource { long getYearInfo(int lunarYear); int getLeapMonth(int lunarYear); int getLeapDays(int lunarYear); int getMonthDays(int lunarYear, int lunarMonth); }

参数说明:lunarYear是农历年份。实现类里做好边界检查。这个接口不复杂,但能让你的万年历从“一份源码”变成“一个组件”。我一般会在实现类里加个volatile缓存,避免每次读文件。

5.2 节气计算的简化方案

节气精确计算需要天文算法,对万年历来说太重。常见做法是查表:把 1900 到 2100 年每个节气的公历日期存成一张二维表,一年 24 个节气,每个节气一个日期。表的大小是 201 乘以 24,用short存日期偏移,内存占用很小。查表时根据年份和节气序号直接取。如果你只需要显示节气名称,不需要精确到分秒,这个方案足够。注意节气表的数据来源要权威,不同来源可能差一天。

节气公历日期范围数据存储方式
立春2 月 3 日 - 5 日short 偏移
雨水2 月 18 日 - 20 日short 偏移
惊蛰3 月 5 日 - 7 日short 偏移
春分3 月 20 日 - 22 日short 偏移

5.3 导出为 JSON 供前端调用

如果你的万年历要嵌入 Web 项目,可以把转换结果导出成 JSON。用 Jackson 或者 Gson 都行,我一般用 Jackson,因为 Spring 项目里自带。定义一个LunarDate类,包含农历年、月、日、是否闰月、干支、生肖、节气等字段,然后序列化。注意日期格式用字符串,避免前端解析时区问题。

public class LunarDate { private int lunarYear; private int lunarMonth; private int lunarDay; private boolean leap; private String ganZhi; private String animal; private String solarTerm; // getter 和 setter 省略 }

导出时把solarToLunar的结果填充进去,solarTerm从节气表查。前端拿到 JSON 后直接渲染,不需要再算。这个方案的好处是前后端职责清晰,后端只负责算法,前端只负责展示。注意 JSON 字段名用驼峰,和前端约定好。

5.4 一个我常用的验证习惯

每次改完农历相关代码,我不会只跑几个日期就完事。我会写一个简单的对拍脚本:用 Java 生成 1900 到 2100 年每个月的初一对应的公历日期,然后和一份已知正确的数据做比对。不需要全部对,随机抽 100 个点,如果全对,基本可以放心。这个习惯帮我省了很多后悔药。对拍脚本不用集成到项目里,单独放一个测试类就行。

@Test public void testRandomDates() { Random random = new Random(42); for (int i = 0; i < 100; i++) { int year = 1900 + random.nextInt(200); int month = 1 + random.nextInt(12); int day = 1 + random.nextInt(28); int[] lunar = LunarCalendar.solarToLunar(year, month, day); // 这里调用一个已知正确的参考实现做比对 // assertArrayEquals(expected, lunar); } }

参数说明:随机种子固定,保证每次跑的结果可复现。day限制在 28 以内,避免月份天数不一致的干扰。参考实现可以是你信任的另一个库,或者手工核对过的数据。这个测试不追求覆盖率,只求在关键边界上不出错。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 18:47:48

数据库设计原则实战:从范式、索引到分库分表的避坑指南

接手过很多半路烂尾的系统&#xff0c;复盘的时候总能找出同一个根因——数据库设计在一开始就埋了雷。不少项目前期跑得飞快&#xff0c;上线两三个月后开始频繁慢查询、锁等待、改表半天动不了&#xff0c;这时候再回头调结构&#xff0c;成本已经是设计阶段的好几倍。今天想…

作者头像 李华
网站建设 2026/10/20 10:05:16

CANoe和CANalyzer,到底啥区别

前阵子带新人&#xff0c;他工位电脑装好软件&#xff0c;兴冲冲地问我&#xff1a;"哥&#xff0c;CANoe装好了&#xff0c;咱们开始跑测试用例吧&#xff1f;"我瞥了一眼他任务栏的图标&#xff0c;乐了&#xff1a;"你这装的是CANalyzer。测试用例&#xff1…

作者头像 李华
网站建设 2026/10/18 3:46:43

基于Java+Spring Boot的校园服务平台毕业设计论文与源码实战解析

简介&#xff1a;这份资源是一篇基于Java的校园服务平台设计与开发毕业论文&#xff0c;面向计算机相关专业毕业生及需要完成毕业设计的学生&#xff0c;帮助解决选题、系统设计与论文撰写中的实际问题。压缩包内共1个doc文件&#xff0c;约1.33MB&#xff0c;为完整论文文档&a…

作者头像 李华
网站建设 2026/10/19 2:48:41

OpenAI收购Astral背后:Ruff与uv重塑Python工具链实战

最近在 Python 开发圈子里&#xff0c;最炸的一条消息应该就是 OpenAI 收购 Astral 了。Astral 这家公司你可能不熟&#xff0c;但大多数人的日常工具链里早就有了它的作品&#xff1a;Ruff 和 uv。Ruff 是新一代 Python 静态检查工具&#xff0c;集 linter 和 formatter 于一体…

作者头像 李华
网站建设 2026/10/21 4:56:12

VSCodium扩展市场Failed to fetch:根因排查与镜像换源全解

如果你用的是微软原版 VS Code&#xff0c;扩展市场几乎没人会去折腾。但换成 VSCodium、自建 code-server&#xff0c;或者任何围绕 VS Code 内核做的 Web IDE 后&#xff0c;打开扩展面板出现这类日志就成了家常便饭&#xff1a;[error] [网络] #1824: https://open-vsx.org/…

作者头像 李华
网站建设 2026/10/21 7:16:21

IPSO-SVM:非线性权重粒子群优化支持向量机参数实战解析

最近在做一个数据分类预测的小项目&#xff0c;核心方案是IPSO-SVM。简单说&#xff0c;就是用非线性权重粒子群算法&#xff08;IPSO&#xff09;去优化支持向量机&#xff08;SVM&#xff09;的惩罚参数和核函数参数&#xff0c;解决传统SVM在分类任务上"参数靠猜、效果…

作者头像 李华