简介:Java万年历程序代码(阳历+阴历+节日)是一份面向Java初学者的综合练手资源,解决传统日历应用中阳历、农历双显示与节日标注的核心需求。压缩包共10个文件,包含3个java源文件、5个class编译文件和.classpath、.project工程配置,结构精简;主要代码类涵盖Clock时钟模块、Lunar农历转换算法以及MainFrame主界面框架,便于直接导入Eclipse查看运行逻辑。资源仅18KB,源码量不大但日期计算、闰年判断、农历转换、节日处理等知识点覆盖完整,适合学习Java Swing界面编程、日历算法设计或作为课设参考。已有1357人浏览学习,足见其实用性。对想快速理解阳历与阴历换算、节日判断及表格化日历布局的读者而言,这份资源提供了可直接运行的示例和清晰的模块划分,能缩短从原理到实现的上手路径。
1. Java万年历程序:一份能直接跑的阳历阴历节日桌面工具
桌面开发遇到日历需求时,多数人第一反应是去引现成的日历控件,结果要么只支持公历,要么依赖列表比业务代码还长。这个 Java 万年历程序走的是另一条路:纯 JDK 实现,Swing 搭界面,程序里同时内置了阳历、阴历、二十四节气和常见节日三套数据源。启动后默认显示当月日历,每个格子同时标出农历日期,底部一行告诉你今天是什么日子,支持点击前后翻月、一键回到今天。代码量不大,读得懂也改得动——想快速抄一个日历界面回自己项目的桌面开发者,或者需要一个综合性练手题的 Java 学习者,这份源码都值得拆开看看。
2. 核心模块拆解:阴历查表转换、节气查表与节日规则
2.1 目录结构与模块依赖:一个包装下三件事
先看结构。我把这份源码拆完后按最常见的单模块布局整理,它不依赖任何第三方 Jar,Swing 是 JDK 自带,农历和节气的计算都是纯算法,所以能在命令行里用两行 javac/java 直接跑起来:
src/ └── main/ ├── java/ │ └── calendar/ │ ├── Main.java // 程序入口,初始化窗口 │ ├── LunarCalendar.java // 阳历转阴历核心类 │ ├── SolarTerm.java // 二十四节气查询 │ ├── FestivalManager.java // 公历/农历/规则型节日管理 │ └── CalendarPanel.java // 日历面板,负责绘制与交互 └── resources/ ├── festival.properties // 节日配置,数据与代码分离 └── solarterm.data // 1900-2100年节气日期表模块之间的依赖方向很清晰:Main 建窗口,CalendarPanel 负责画格子,LunarCalendar 和 SolarTerm 提供日期数据,FestivalManager 负责查节日。没有任何循环依赖,改动时基本可以做到「改界面不动算法,改数据不动代码」。这也是二次开发比较舒服的地方,后面第五章会具体展开。
2.2 阳历转阴历:一个 int 数组承载百年农历数据
农历和公历不一样,不能用纯粹的闰年规则直接推算。农历每个月长度由月相决定,大月 30 天、小月 29 天,哪年有闰月、闰哪个月又由节气位置决定,所以工程上最稳的做法是查表法:把 1900 到 2100 年逐年的农历信息预先压缩存好,运行时用位运算解出来。
public class LunarCalendar { // 每个 int 记录 1900+i 这一年的农历信息 // bit 0-11:该年每月是大月(30天)还是小月(29天),1表示大月 // bit 12-15:该年闰月的月份,0表示无闰月 // bit 16:闰月是大月还是小月,1表示大月 private static final int[] LUNAR_INFO = { 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, // 1900-1904 // 完整数据覆盖 1900-2100 年,源码包内已带全 }; // 计算基准日 1900-01-31 与目标日之间的天数差 private static int daysBetween(int y1, int m1, int d1, int y2, int m2, int d2) { long t1 = new GregorianCalendar(y1, m1 - 1, d1).getTimeInMillis(); long t2 = new GregorianCalendar(y2, m2 - 1, d2).getTimeInMillis(); return (int) ((t2 - t1) / 86400000L); } public static LunarDate solarToLunar(int year, int month, int day) { // 以 1900 年 1 月 31 日(农历庚子年正月初一)为基准 int offset = daysBetween(1900, 1, 31, year, month, day); int lunarYear = 1900; // 第一步:按年扣减。农历年可能 12 个月,也可能 13 个月(有闰月) while (offset >= yearDays(lunarYear)) { offset -= yearDays(lunarYear); lunarYear++; } // 第二步:按月扣减,遇到闰月先消费闰月 int leapMonth = leapMonth(lunarYear); int lunarMonth = 1; boolean leapPassed = false; while (true) { boolean isLeap = (!leapPassed && leapMonth == lunarMonth && leapMonth > 0); int days = isLeap ? leapDays(lunarYear) : monthDays(lunarYear, lunarMonth); if (offset < days) { return new LunarDate(lunarYear, lunarMonth, offset + 1, isLeap); } offset -= days; if (isLeap) { leapPassed = true; // 闰月已消费完,后续进入常规月 } else { lunarMonth++; // 常规月前进 } } } }这段代码的核心是两个地方。第一是 LUNAR_INFO 里的 int 被当成了十七位二进制位域:低 12 位对应 12 个农历月的月长,1 表示大月 30 天、0 表示小月 29 天;中间 4 位表示闰月在哪个月,0 表示当年无闰月;最高位表示闰月的大小。第二是基准日,从 1900-01-31 出发,先算出目标日期距离基准日相差多少天,再逐年扣减,扣不完的那一年就是要找的农历年。你把 offset 的值打印出来,对照日历 App 验证一下,就能很快理解整套换算。
这里有个容易忽略的参数范围:LUNAR_INFO 只覆盖 1900 到 2100 年。超出区间的年份要么数组越界,要么算出错误结果,所以翻页按钮和公开方法都要做边界限制。另外,位运算里用「无符号右移 >>>」处理高位才是安全的,因为 int 是带符号类型,直接用 >> 在闰月高位会出问题。
提示:LUNAR_INFO 的位域格式是固定的,改数据时不要动位宽定义。怀疑某一年数据不对,把当年每月天数和日历 App 逐月对照,能对上就说明表没问题。
2.3 节气计算:从经验公式到数据表的两级方案
二十四节气在严格意义上属于天文计算,要精确到几点几分需要引入黄经和时区修正。普通万年历程序通常不会实时算天文,而是采用「通用寿星公式」做近似,或者直接使用预先算好的数据表。这份源码里两种方案都留了位置,默认走数据表,更稳。
public class SolarTerm { private static final String[] TERM_NAMES = { "小寒", "大寒", "立春", "雨水", "惊蛰", "春分", "清明", "谷雨", "立夏", "小满", "芒种", "夏至", "小暑", "大暑", "立秋", "处暑", "白露", "秋分", "寒露", "霜降", "立冬", "小雪", "大雪", "冬至" }; // 每行存一年 24 个节气日,value 高 8 位是月,低 8 位是日 private static final int[][] TABLE = new int[201][24]; static { loadFromResource(); // 解析 resources/solarterm.data } public static String getTerm(int year, int month, int day) { if (year < 1900 || year > 2100) { return ""; } for (int i = 0; i < 24; i++) { int v = TABLE[year - 1900][i]; int m = (v >> 8) & 0xFF; int d = v & 0xFF; if (m == month && d == day) { return TERM_NAMES[i]; } } return ""; } }value 的编码方式是这类节气表最常见的约定:高 8 位存月份,低 8 位存日号,比如 0x0106 表示 1 月 6 日小寒。运行时按「年 + 月 + 日」精确命中,查不到就返回空串,界面上的当天如果不落在任何节气日就不会显示节气名。这套查表逻辑的好处是精度完全由数据决定,想修正某一年某一个节气,改数据文件重跑就行,不用动编译后的类。
如果你拿到手的源码变体用的是公式法,那个公式对大多数年份能用,但个别年份会有一天的误差,尤其是碰上闰年叠加世纪修正的时候。我一般建议展示型项目直接用查表,把「哪天是几号」交给数据,把「要不要显示」交给代码,省心很多。
2.4 节日规则:固定型、农历型和规则型分开处理
节日是万年历里最容易写乱的部分,因为三类节日的计算逻辑完全不同。公历节日是固定日期,直接比对月日;农历节日要先做阳历转农历再比对;而像母亲节这种「5 月第二个星期日」属于规则型节日,得额外算「第几个星期几」。
| 节日类型 | 判定方式 | 示例 | 是否依赖农历转换 |
|---|---|---|---|
| 固定公历 | 公历月日直接命中 | 元旦、劳动节、国庆节 | 否 |
| 固定农历 | 转农历后命中 | 春节、端午、中秋 | 是 |
| 规则型 | 第几个星期几 | 母亲节、父亲节、感恩节 | 否 |
public class FestivalManager { private final Map<String, String> solarFestivals = new HashMap<>(); private final Map<String, String> lunarFestivals = new HashMap<>(); private final Map<String, String> ruleFestivals = new HashMap<>(); public String getFestival(int year, int month, int day) { String key = month + "." + day; // 第一优先:公历节日 String name = solarFestivals.get(key); if (name != null) { return name; } // 第二优先:农历节日,先转阴历再查 LunarDate lunar = LunarCalendar.solarToLunar(year, month, day); name = lunarFestivals.get(lunar.getMonth() + "." + lunar.getDay()); if (name != null) { return name; } // 第三优先:规则型节日,如 5月第2个周日 return getRuleFestival(year, month, day); } }判断逻辑分三步走:先拿公历月日查 solarFestivals;再把当天转农历,用农历月日查 lunarFestivals;都没命中就进入规则型节日的星期计算。规则型节日的实现方式并不复杂,先算出目标月的 1 号是星期几,再推算第几个星期几对应的具体日期,命中就返回节日名。这样把三类节日拆开,优先级也定死,比在一个方法里堆 if 分支清晰得多。
3. 界面与数据层:Swing 日历面板、节日配置与今日信息
3.1 日历面板绘制:先算首日星期几,再决定从哪里画格子
CalendarPanel 是这个程序的界面核心。它继承 JPanel,在 paintComponent 里完成整个月历的绘制,不依赖任何布局组件。绘制顺序是固定的:先算当月第一天是星期几,再决定第一行从哪个格子开始填日期,之后逐行画满 6 行 7 列的网格。
public class CalendarPanel extends JPanel { private int year, month; private int todayY, todayM, todayD; // 系统今天,用于高亮 @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Calendar cal = Calendar.getInstance(); cal.set(year, month - 1, 1); int firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK) - 1; // 周一=0 int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH); for (int day = 1; day <= daysInMonth; day++) { int cellIndex = firstDayOfWeek + day - 1; int row = cellIndex / 7; int col = cellIndex % 7; // 今天加高亮背景 if (day == todayD && month == todayM && year == todayY) { g.setColor(new Color(255, 200, 200)); g.fillRect(5 + col * CELL_WIDTH, 5 + row * CELL_HEIGHT, CELL_WIDTH - 10, CELL_HEIGHT - 10); g.setColor(Color.BLACK); } // 画公历数字 g.drawString(String.valueOf(day), 10 + col * CELL_WIDTH, 20 + row * CELL_HEIGHT); // 画农历文字:转 LunarDate 后取「初一」「十五」这类简化值 LunarDate lunar = LunarCalendar.solarToLunar(year, month, day); g.drawString(lunar.toShortString(), 10 + col * CELL_WIDTH, 38 + row * CELL_HEIGHT); } } }这里最关键的坑是星期的起点。Java 的 Calendar 默认周日是一周的第一天,DAY_OF_WEEK 周日返回 1、周一返回 2。界面如果按周一开头排列,就必须做一次减 1 转换,否则整个月历会整体错位一天。我第一次跑这个程序时日期全部偏了一天,排查了半天发现就是少了这一行转换。这个细节几乎每个日历组件都会遇到,写的时候一定要显式定好「周一 = 0」的约定。
Swing 是单线程模型,所有界面更新必须在 EDT 线程上执行。翻月按钮里直接改 year/month 再 repaint() 是安全的,因为按钮事件本身就运行在 EDT。但如果你在别的线程里调用 setMonth,就会遇到偶发的不刷新或者半渲染问题,需要包一层 SwingUtilities.invokeLater 再更新。这个点在接入定时提醒、网络同步之类的功能时特别容易踩。
3.2 节日配置剥离:properties 文件把数据从代码中拆出来
程序启动时读取 resources/festival.properties,把公历节日、农历节日和规则型节日一次性加载到三张 Map 里。配置文件按前缀区分类型,格式固定:
# 公历节日:solar.月.日=节日名 solar.1.1=元旦 solar.5.1=劳动节 solar.10.1=国庆节 # 农历节日:lunar.月.日=节日名 lunar.1.1=春节 lunar.5.5=端午节 lunar.8.15=中秋节 # 规则型节日:rule.月.第几周.星期几=节日名,7 表示周日 rule.5.2.7=母亲节 rule.6.3.7=父亲节 rule.11.4.4=感恩节public class FestivalManager { private final Properties props = new Properties(); public void load(InputStream in) throws IOException { props.load(in); for (String key : props.stringPropertyNames()) { if (key.startsWith("solar.")) { solarFestivals.put(key.substring(6), props.getProperty(key)); } else if (key.startsWith("lunar.")) { lunarFestivals.put(key.substring(6), props.getProperty(key)); } else if (key.startsWith("rule.")) { ruleFestivals.put(key.substring(5), props.getProperty(key)); } } } }java.util.Properties 的 load 方法默认按 ISO-8859-1 解码,所以 properties 文件里的中文必须写成 \uXXXX 转义形式。你在 IDE 里看到的是「春节」,实际文件字节里存的是转义序列。如果直接把文件存成 UTF-8,load 出来就会出现中文乱码,界面上的节日名全变成问号。这个坑在第一次运行时几乎必踩,后面第四章会单独讲。
把节日放进配置文件而不是硬编码,最大的价值是改节日不用重新编译。比如公司内部要加一个「项目上线日」,直接在文件里加一行,重启程序就生效。数据驱动这一点对二次开发特别重要,后面第五章会具体演示怎么扩展。
3.3 今日信息栏:阴历、节气和节日的联动展示
除了日历面板,程序底部还有一个信息栏,单独一个 JLabel,内容由当前选中日期实时刷新,格式大致是「2025 年 5 月 1 日 农历四月初四 劳动节 立夏」。这行文本的生成逻辑在 Main 里,属于典型的组合调用:
String info = year + "年" + month + "月" + day + "日 农历" + LunarCalendar.solarToLunar(year, month, day).toString() + " " + festivalManager.getFestival(year, month, day) + " " + solarTermManager.getTerm(year, month, day); infoLabel.setText(info);这里能看出整个项目的一个设计习惯:日期逻辑全部集中在算法类,界面层只做字符串拼接。想改展示格式,比如把「立夏」改成「今日立夏」,只需要改 SolarTerm 的返回文案,不必动绘制代码。我通常建议在这种拼接处留一组边界测试,把大年初一、闰月日期、2 月 29 日这些特殊日期丢进去验证,防止后续改动把联动逻辑改坏。
界面交互也很简单:左右箭头按钮各对应 year/month 的加减,点「今天」按钮把年月日重置为当前日期并触发重绘。整体没有复杂的事件分发,适合当 Swing 入门源码读,也适合二次开发时整体替换成 JavaFX 或者其他 UI 框架。
4. 运行与避坑:从编译到跑起来的五个常见问题
4.1 编译运行步骤
这个项目没有第三方依赖,跑起来门槛很低。命令行方式最直接:
# 在项目根目录编译所有 java 文件,必须显式指定 UTF-8 javac -encoding UTF-8 -d out src/main/java/calendar/*.java # 把资源文件复制到输出目录,保证 props.load 能找到配置 cp src/main/resources/festival.properties out/calendar/ cp src/main/resources/solarterm.data out/calendar/ # 运行主类 java -cp out calendar.Main第一行必须带 -encoding UTF-8,否则源码里的中文注释和界面字符串在中文系统默认编码下会编译成乱码,程序跑起来全是问号。第二行拷贝资源是因为 javac 不会自动把 resources 下的文件复制到 out 目录,类加载时 props.load 走的是 classpath 相对路径,找不到文件会直接抛 IOException,或者加载出一张空表导致界面没有节日。
在 IDE 里跑更简单:把 src/main/java 标记为源码根目录,src/main/resources 标记为资源根目录,直接运行 Main。但我推荐第一次先走一遍命令行,这样能真正理解 classpath 和资源文件的关系,后面把这段逻辑移植到自己的项目里时,不会在「编译通过、运行却找不到资源」这种诡异问题上翻车。
注意:先跑通命令行,再进 IDE。命令行失败时的异常信息最直接,能帮你快速定位是编码问题还是资源路径问题。
4.2 避坑:编码、闰月、越界、时区与 JDK 版本
第 1 条:界面中文全部变成问号。
现象:程序能启动,但窗口标题、按钮、日历格子里所有中文显示为乱码或者问号。原因:源码文件是 UTF-8 编码,javac 在中文操作系统下默认按 GBK 解码,字符串在编译阶段就已经坏了。解决:编译时强制 javac -encoding UTF-8,或者在 IDE 里把项目编码统一设置成 UTF-8 后重新编译一次。这条几乎是所有纯 JDK 中文项目的通病,先检查编码再检查代码。
第 2 条:日期整体错位一天。
现象:日历面板里某些日期显示到了前后一天的格子里,农历尤其明显。原因:Calendar.DAY_OF_WEEK 的起点是周日,而界面按周一起始排列,少了减 1 转换。解决:在计算首日偏移时统一转成「周一 = 0」再存,不要在每个格子的绘制循环里反复转换。这也是上一章里我踩过的那个坑,属于日历组件最经典的边界细节。
第 3 条:闰月相关的换算对不上。
现象:农历二月三十出现两次,或者闰月日期显示不出来。原因:LUNAR_INFO 里闰月信息压缩在 int 高位,位运算取错了掩码,或者年份超出数据表覆盖范围。查表法是可靠的,但数据表本身有边界。解决:确认传入年份在 1900-2100 区间;位运算统一用无符号右移 >>> 处理高位;怀疑数据表某年有错时,把当年每月天数和日历 App 逐月对照,先锁定是表的问题还是代码的问题。
第 4 条:翻到 1900 年之前程序崩溃。
现象:往前翻页经过 1900 年时直接抛数组越界异常,界面卡死。原因:LUNAR_INFO 从 1900 年开始存,目标年份减 1900 后下标为负数。解决:在翻页按钮的响应逻辑里限制年份范围 1900-2100,到达边界时禁用对应方向的按钮,并在进入换算的方法入口加一层前置校验。用界面边界挡住数据边界,是最直接也最不容易漏的防御手段。
第 5 条:今天高亮和其他日历 App 对不上。
现象:程序刚启动时高亮的「今天」和手机日历差一天,尤其在上午 8 点前后容易出现。原因:Calendar.getInstance() 取的是系统默认时区,如果运行环境时区设置异常,拿到的日期会往前或往后偏。解决:显式取时区再拿日期,或者直接用 java.time.LocalDate.now() 并确认运行环境的系统时区正确。这个坑在服务器上跑定时任务时特别常见,桌面程序一般还好,但多用户环境部署时一定要确认时区。
5. 二次开发:把万年历改成你自己的日历工具
5.1 扩展节日库:加一行配置,加一个节日
改节日是最容易的。打开 festival.properties,按现有格式追加一行。比如要加「程序员节」,公历 10 月 24 日是固定型节日,写成 solar.10.24=程序员节;要加农历节日「腊八节」,写成 lunar.12.8=腊八节;规则型节日已经支持,直接套用 rule.月.第几周.星期几。注意中文要转成 \uXXXX 才能正确加载,这是第四章里强调过的点。做完这一步,整个节日库就是纯数据配置,不动一行 Java 代码。
5.2 加一个周视图:从网格重绘开始
如果想从月历改成周视图,核心改动在 CalendarPanel。把 paintComponent 里 6 行 7 列的循环改成 1 行 7 列,起始日期不再是本月 1 号,而是「本周第一天」,用 Calendar 算出后触发重绘。同时加一个「视图模式」的状态变量,月/周切换按钮只改这个变量再 repaint。界面改动集中在一个类,算法类和节日类完全不用动,这恰好是这个项目分层的好处。
另一个实用的扩展是「距离下个节日倒计时」。在 FestivalManager 里加一个方法,遍历未来 60 天,找到第一个命中的节日返回距离天数。挂在底部信息栏的 label 上,测试时用跨年日期核对,重点检查 12 月 31 日到元旦、腊月到春节这种边界,避免闰年和跨年把算差。
5.3 收尾
我拆这个项目时最大的教训是:处理日期逻辑时,永远不要只盯着代码想。把今天、大年初一、闰月日、2 月 29 日、1900 年边界、2100 年边界列成一张测试清单,每次改完代码跑一遍,比任何覆盖率指标都更让人放心。从那以后,我每次改动万年历类的代码都会强制走一遍这套「边界日期清单」,先手动跑通再谈自动化。这个项目给了我一个很干净的起点——无依赖、分层清楚、数据可配,希望帮到你。
本文还有配套的精品资源,点击获取