Java万年历源码:闰年判断与蔡勒公式实现详解
发布时间:2026/10/15 10:17:25 锦皓数字建站

简介这是一份面向Java初学者和课程设计学生的完整“万年历记事本”项目资料以Swing图形界面实现万年历展示和日记记录功能是学习日期时间处理、GUI组件布局、事件驱动编程及文件读写的综合案例。压缩包内仅含1个doc文档大小317KB文档将课程设计报告、核心类calendar的完整源码、运行效果截图与工作总结整合在一起查阅和复用都很方便。目前已有184人学习/下载资源虽小但内容完整适合作为Java课程设计参考、期末项目模板或桌面应用开发入门练习。内容详细讲解了java.util.Calendar与java.time包的日期计算方法JFrame、JComboBox、JTextField、JTextArea等Swing组件的组合运用ActionListener与MouseListener事件响应以及FileWriter/BufferedReader读写日记、闰年判断、月份天数计算等具体实现并涉及try-catch异常处理、布局管理器和主函数入口设计。读者从中不仅能获得可直接借鉴的源代码和设计思路还能看到作者对总体设计、功能模块、布局管理和排错过程的完整记录无论是学习还是二次开发都能快速入手对提升Java综合编程能力很有帮助。1. JAVA万年历源码一个老题目为何仍有新坑“JAVA万年历源码”这个标题在各平台一搜一大把但真正能扛住跨世纪边界检查的版本并不多。某开发者拿来一个现成源码发现 1900 年到 2030 年都正常到 2100 年 2 月却多出 29 号——问题不在代码抄没抄对而在闰年判断和星期基准没立稳。这篇笔记不是给一个下载包而是把万年历从日期算法、源码分层到终端与界面两种输出的完整落地方案讲清楚适合刚学 Java 需要综合作业的同学也适合想从旧代码里抠出日期处理经验的中级开发者。2. 核心算法先立住闰年、星期与每月天数的计算公式万年历看着简单真正决定源码质量的是三个底层计算一年是否闰年、某天是星期几、每月有多少天。这三件事互相牵连只要一个判断写错表面看只是某个月多打一天实际整个月份的星期对齐都会跟着歪掉。所以我习惯先把算法单独写成一个小工具类不急着做界面。2.1 闰年的正确判定为什么四个条件里“百年”是陷阱完整的闰年规则是能被 400 整除的年份是闰年能被 4 整除但不能被 100 整除的年份也是闰年其余都不是。很多源码只写year % 4 0导致 1900、2100 这种年份被错误当作闰年2 月凭空多出一天。另一个常见误写是“能被 4 整除且能被 100 整除”也算闰年这会把每百年的两个年份同时搞错。public final class DateAlgo { // 闰年判定400 的倍数直接是闰年 // 否则必须是 4 的倍数且不是 100 的倍数 public static boolean isLeapYear(int year) { return (year % 4 0 year % 100 ! 0) || (year % 400 0); } // 返回某年某月的天数month 范围 1-12 public static int daysInMonth(int year, int month) { switch (month) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31; case 4: case 6: case 9: case 11: return 30; case 2: return isLeapYear(year) ? 29 : 28; default: throw new IllegalArgumentException(month must be 1-12: month); } } }这段代码把两个判断放在同一个类里逻辑说明如下闰年判定先看 400再回头检查 4 和 100顺序上不要在year % 4 0后直接返回 true必须把不能被 100 整除这条带上。daysInMonth用 switch 而不是数组好处是非法月份能抛出异常不至于让调用方拿到一个越界的默认值。参数year和month都是 int调用时建议先由上层做范围校验工具类本身只负责“给定合法输入返回正确结果”。2.2 星期计算选用蔡勒公式参数、边界与代码实现求某天是星期几常见思路是用一个已知基准日推算比如“2000 年 1 月 1 日是星期六”然后算出相差天数再对 7 取模。这种做法不是不行但基准日的星期一旦记错或者中间用Calendar类混算导致时区干扰结果会整体偏移一天。我一般直接用蔡勒公式它不需要依赖系统当前时间输入年月日就能算出星期适合写进纯算法类里。public static int dayOfWeek(int year, int month, int day) { // 蔡勒公式只接受 3-14 月因此把 1 月、2 月看成上一年的 13 月、14 月 if (month 3) { month 12; year--; } int century year / 100; // 世纪数 int yearOfCentury year % 100; // 世纪内年份 // 公式返回 0星期日1星期一以此类推 int week (yearOfCentury yearOfCentury / 4 century / 4 - 2 * century 26 * (month 1) / 10 day - 1) % 7; // Java 的 % 可能得到负数这里统一修正到 0-6 return (week % 7 7) % 7; }逻辑说明蔡勒公式被很多实现写错关键在于 1、2 月要做“年份倒退、月份加 12”的调整否则计算结果跟公历对不上。代码里26 * (month 1) / 10是公式的固定项不要约分成2.6 * (month 1)整数除法会丢失精度结果星期直接乱掉。参数里century是年份前两位yearOfCentury是后两位例如 2024 年对应 century20、yearOfCentury24。最后的(week % 7 7) % 7是为了把 Java 负数取余修正成数学取模否则 1 月等某些输入会返回负值。2.3 月份天数的第二种写法两套数组解决“闰年二月”用 switch 写天数清晰但有些源码会把平闰两套数组封装成查表方式适合在后面需要频繁按月份索引的场景。我在模型层里更倾向数组策略因为万年历要连续输出整年 12 个月数组查询速度稳定代码也短。public static int daysInMonthByArray(int year, int month) { int[] commonYear {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month 1 || month 12) { throw new IllegalArgumentException(month out of range: month); } if (month 2 isLeapYear(year)) { return 29; } return commonYear[month - 1]; }参数说明commonYear下标从 0 开始所以访问时要减 1这里没有单独建闰年数组而是对 2 月做特判两套数组的好处是逻辑直观坏处是容易忘记“下标从 0 开始”导致差一错误。相比之下switch 版本虽然行数多些但胜在不会越界。真实项目中我建议保留 switch 版本作为唯一实现数组写法只适合理解平闰年切换的思路。无论选哪种都要在注释里写明白 month 的合法范围是 1 到 12非法输入必须抛异常不能悄悄返回错误天数。3. 把源码结构拆开模型、控制器与打印视图各管什么不少初学者的万年历源码是把计算、打印、输入全塞进 main 方法几百行下来根本没法调。更稳的做法是按职责拆成三层数据模型负责保存日期和计算规则控制器负责接收输入并调用算法视图负责把结果打印成终端表格或渲染到界面。这样算法可以单独做单元测试换界面也不用重写日期逻辑。3.1 用类把“一天”封装起来不可变模型的意义模型层如果只传 year、month、day 三个散落的 int调用时很容易传错参数顺序。我一般会把单个日期封装成不可变对象让编译器帮我们检查类型也能在调试时直接打印对象内容。public final class CalendarDay { private final int year; private final int month; private final int day; public CalendarDay(int year, int month, int day) { this.year year; this.month month; this.day day; } public int getYear() { return year; } public int getMonth() { return month; } public int getDay() { return day; } public int dayOfWeek() { return DateAlgo.dayOfWeek(year, month, day); } public boolean isSameDay(CalendarDay other) { return year other.year month other.month day other.day; } Override public String toString() { return year - month - day; } }参数与设计说明构造器里没有做合法性校验原因是模型对象应由控制器创建控制器知道输入范围模型层只保证“被创建出来的对象状态不可变”。final关键字加上只读 getter避免别处把日期改坏。dayOfWeek()直接复用第二章的蔡勒公式业务方不用关心算法细节。isSameDay用来做边界比较比如翻页时判断新选中的月份是否和当前是同一个月份。3.2 控制器把“年月”变成“当月六行七列”的数据控制器是万年历源码里最容易写乱的部分。它要做三件事校验年月范围、算出当月 1 号是星期几、把 1 号之前的空位和当月每天填进一个二维数组。数组我固定用 6 行 7 列因为日历最长跨六周空位用 0 表示视觉上比较干净。public class MonthData { public final int year; public final int month; public final int[][] cells; public MonthData(int year, int month) { this.year year; this.month month; this.cells buildCells(year, month); } private int[][] buildCells(int year, int month) { // 表格存储 1 号前的空白与日期空白用 0 填充 int[][] grid new int[6][7]; int firstDay DateAlgo.dayOfWeek(year, month, 1); // 把星期天作为 0则 1 号应放入第 firstDay 列 int offset firstDay; int days DateAlgo.daysInMonth(year, month); int index 1; for (int row 0; row 6; row) { for (int col 0; col 7; col) { if (row 0 col offset) { grid[row][col] 0; } else if (index days) { grid[row][col] index; } else { grid[row][col] 0; } } } return grid; } }逻辑说明firstDay来自蔡勒公式当 1 号是星期日时返回 0正好落在第 0 列是星期一时返回 1落在第 1 列。这样构造出来的cells天然就是“日历表格”的形状后面无论打印还是画界面都直接遍历二维数组不需要再算每行起始位置。参数方面offset只在第一行起作用超出当月天数后全部填 0不过多出的 0 会在视图层被忽略。这个类有个小缺点两个月的数据要建两个对象但因为MonthData只保存 int 数组内存代价可以忽略。3.3 打印视图按星期表头对齐的文本输出控制台版视图要把二维数组排成一张人能看懂的表格。这里有个终端兼容问题中文和数字宽度不一致直接拼接字符串很容易歪掉。我的做法是统一用四字符宽输出数字靠右星期表头也补足到相同宽度。public class MonthPrinter { public static void printMonth(MonthData data) { String[] headers {Mon, Tue, Wed, Thu, Fri, Sat, Sun}; for (String h : headers) { System.out.printf(%4s, h); } System.out.println(); for (int[] row : data.cells) { for (int cell : row) { if (cell 0) { System.out.print( ); } else { System.out.printf(%3d , cell); } } System.out.println(); } } }参数说明%4s是右对齐占四格英文星期缩写正好四个字符日期数字用%3d占三格加一个空格保证数字和空白整体占四格列与列不会挤在一起。空位输出三个空格等于占满四格但少一个数字。这样做的代价是表头看起来有点紧凑但胜在各种终端下列都能对齐。如果你希望表头用中文“一 二 三”需要额外做全角宽度处理这一章先用英文表头把原理讲透中文对齐问题在第 5 章再单独说。4. 让源码跑起来控制台一键命令到 Swing 界面改造算法和分层都就位后第一步先在控制台把完整流程跑通不要一上来就做界面。控制台版最适合快速验证写一个几行的入口类读取命令参数调用控制器交给打印视图。跑通后再把同一个MonthData接到 Swing 上日期逻辑完全不用改。4.1 控制台版最小启动类main 函数、参数解析与异常兜底入口类的职责是解析args把字符串转成 int再交给MonthData和MonthPrinter。我习惯让程序支持不传参数时打印当前月份传两个参数时打印指定年月这样既方便演示也方便自动化测试。import java.time.LocalDate; public class CalendarConsole { public static void main(String[] args) { int year; int month; if (args.length 2) { year Integer.parseInt(args[0]); month Integer.parseInt(args[1]); } else { LocalDate today LocalDate.now(); year today.getYear(); month today.getMonthValue(); } // 范围校验防止月份输入 13 或年份过小导致算法异常 if (month 1 || month 12) { System.err.println(month must be between 1 and 12); return; } MonthData data new MonthData(year, month); MonthPrinter.printMonth(data); } }逻辑说明默认取当前月时用了java.time.LocalDate这是标准库推荐的新日期 API它可以拿到本地年月不掺时区概念。手动传参时用Integer.parseInt这里没有做 try-catch因为演示程序里参数非法就该直接抛异常方便定位问题如果你要交付给别人用可以在外层套一层 try-catch 并打印友好提示。month校验放在调用MonthData之前因为工具类只负责处理合法输入边界控制属于控制器的职责。测试命令类似传“2024 7”打印 2024 年 7 月传“2100 2”就能快速验证第二章说的闰年边界。4.2 Swing 版整体框架JFrame、JPanel 与日历表格渲染控制台跑通后做 Swing 界面就只是把二维数组搬到JTable。核心思路是自定义一个TableModel让getValueAt直接返回MonthData.cells里的值0 显示为空字符串。这样表格的行列结构天然对应星期一到星期日。import javax.swing.*; import javax.swing.table.AbstractTableModel; public class CalendarTableModel extends AbstractTableModel { private final MonthData data; public CalendarTableModel(MonthData data) { this.data data; } Override public int getRowCount() { return 6; } Override public int getColumnCount() { return 7; } Override public Object getValueAt(int rowIndex, int columnIndex) { int value data.cells[rowIndex][columnIndex]; return value 0 ? : value; } Override public String getColumnName(int column) { String[] names {Mon, Tue, Wed, Thu, Fri, Sat, Sun}; return names[column]; } }参数说明getValueAt返回的是 ObjectJTable 会调用toString来显示所以日期数字直接返回 int 即可空位返回空字符串会让格子看起来是空白。getColumnName用于表头使用英文缩写避免全角对齐问题。这个模型类不持有界面组件只持有MonthData因此切换月份时只需要替换 model 里的 data 引用再调用table.repaint()整张表立即刷新。这是 Swing 里最值得养成的习惯界面和数据源分离后面无论加按钮还是加下拉框都改不动日期计算。4.3 交互细节月份切换与跨年滚动的体验只显示一个月的万年历不够至少要支持上一月、下一月和年份选择。交互层我习惯用一个JComboBoxInteger选年份、一个JComboBoxInteger选月份外加两个按钮做翻月。事件监听里只更新MonthData并让表格重新显示。public class CalendarFrame extends JFrame { private JTable table; private CalendarTableModel model; private JComboBoxInteger yearBox; private JComboBoxInteger monthBox; public CalendarFrame() { setTitle(JAVA万年历); setDefaultCloseOperation(EXIT_ON_CLOSE); setLayout(new BorderLayout()); JPanel top new JPanel(); yearBox new JComboBox(); monthBox new JComboBox(); for (int y 1900; y 2100; y) { yearBox.addItem(y); } for (int m 1; m 12; m) { monthBox.addItem(m); } top.add(yearBox); top.add(monthBox); add(top, BorderLayout.NORTH); MonthData initialData new MonthData(2024, 7); model new CalendarTableModel(initialData); table new JTable(model); add(new JScrollPane(table), BorderLayout.CENTER); yearBox.addActionListener(e - updateTable()); monthBox.addActionListener(e - updateTable()); pack(); setVisible(true); } private void updateTable() { int y (Integer) yearBox.getSelectedItem(); int m (Integer) monthBox.getSelectedItem(); model.setData(new MonthData(y, m)); } }交互说明下拉框年份限在 1900 到 2100既覆盖绝大多数应用场景也避开算法可能越界的极端年份。翻月按钮没有写在这里但实现方式一样拿到当前年月后加 1 或减 1遇到 12 月往后翻要年份加一、月份回到 1遇到 1 月往前翻要年份减一、月份回到 12。推荐把这段“加减月份”的逻辑写到控制器层而不是散在按钮事件里否则两个按钮会写两段几乎一样的代码。界面刚起步时不必做得很花哨能让表格正确翻页、跨年、显示闰年二月核心源码就已经合格了。5. 万年历源码开发中的 5 个常见坑现象、原因与修法算法和界面都搭好后真正的麻烦往往藏在边界条件里。下面这 5 个坑是我在评审和调试中见过次数最多的每一条都按“现象 → 原因 → 解决”来写你可以直接照着排查自己的代码。5.1 星期基准错位整张日历从星期日挪到星期一现象打印某个月的日历日期数字没错但第一格总是空在星期天表头却从星期一开始或者相反1 号本该是周二却显示在周三那一列。原因蔡勒公式返回的 0 可能被当作星期一而表格第一列被设计成星期天或反了。解决先单独打印DateAlgo.dayOfWeek(2024, 7, 1)确认返回值再检查buildCells里的offset赋值。如果表头是“Mon 到 Sun”那么 0 代表周日时offset 应该等于firstDay如果 0 代表周一offset 就要写成(firstDay 6) % 7。我建议把所有讨论星期几的逻辑统一成“0 表示星期日”并写进类注释。5.2 闰年漏判 4002100 年 2 月凭空多出 29 号现象程序在 2024 年显示正确改成 2100 年 2 月后日历表格里出现了 29 日。原因闰年判断只写了year % 4 0没有排除能被 100 整除但不能被 400 整除的年份2100 正好落在这一类。解决把第二章的isLeapYear原样替换进项目并在单元测试里加一条assertFalse(DateAlgo.isLeapYear(2100))。这个坑极容易在“看起来已经写对”的代码里出现因为 2000 年是闰年、1900 年不是闰年而 2100 年太远手测很少跑到。5.3 Calendar 与 LocalDate 混用日期悄悄偏移一天现象本地运行万年历一切正常打成 jar 包放到服务器上后某天日期总是差一天。原因代码里既用了Calendar.getInstance()又用了LocalDate.now()前者依赖系统默认时区后者读取系统当前日期服务器时区被设置为 UTC 时两个 API 的“今天”可能不同。解决纯日历计算不要混用两套 API显示日期统一用LocalDate它不携带时区信息如果一定要用Calendar就用Calendar.getInstance(TimeZone.getTimeZone(Asia/Shanghai))显式指定时区。万年历的本质是公历日期演算牵扯进时区只会让问题变得玄学。5.4 闰秒与夏令时万年历到底要不要管现象有同学看到系统时间是 23:59:60就认为万年历该处理闰秒还有人问要不要考虑夏令时调整。原因把“时间”和“日历”混在了一起。万年历展示的是日期格子分钟和秒的闰秒调整不会改变今天是几号夏令时只影响某天的小时长度也不改变“7 月 15 日”这个日期。解决源码里不引入时间计算只用java.time.LocalDate和自写算法这样既避开闰秒也不会被时区规则影响。遇到有人质疑时可以看 2017 年等有闰秒的日期Calendar 显示当天依然是 24 小时日期永远是连续整数这就是万年历的正确模型。5.5 中文表头打歪终端里“一”和“10”的宽度战争现象把星期表头改成中文“一 二 三 四 五 六 日”后日期列对不齐有的行向左歪两格。原因中文字符在常见终端里占两个英文宽度String.format(%4s, 一)只按字符数算导致中文比数字宽出一倍。解决要么继续用英文表头要么在打印时手动计算显示宽度。我常用一个简单方法先输出表头“一 二 三 四 五 六 日”每个中文字后面补一个空格日期数字统一用%3d输出这样中文和数字在大多数终端都能对齐如果还歪就改成 Grid 布局的 Swing 界面让表格自己处理列宽一劳永逸。6. 进阶验证用两行脚本扫遍 1900 到 2100 的每一天万年历算得对不对不能只靠肉眼抽查几个月。最可靠的办法是拿标准库当裁判用LocalDate遍历一段年份范围每天比较星期顺手校验每月天数是否一致。这样能一次性暴露闰年判断、月份天数、跨世纪边界等问题。import java.time.LocalDate; public class CalendarValidator { public static void main(String[] args) { for (int y 1900; y 2100; y) { for (int m 1; m 12; m) { int expectDays DateAlgo.daysInMonth(y, m); int actualDays LocalDate.of(y, m, 1).lengthOfMonth(); if (expectDays ! actualDays) { System.out.println(days mismatch: y - m); } for (int d 1; d expectDays; d) { int expectWeek DateAlgo.dayOfWeek(y, m, d); int actualWeek LocalDate.of(y, m, d).getDayOfWeek().getValue() % 7; if (expectWeek ! actualWeek) { System.out.println(week mismatch: y - m - d); } } } } System.out.println(validation done); } }注意比较前做了一个换算LocalDate.getDayOfWeek().getValue()返回 1 到 7分别表示周一到周日转成 0 到 6 才能和蔡勒公式的结果对齐。这个验证器还可以进一步升级为 JUnit 测试把校验逻辑写进Test以后每次改算法都自动重跑防止“修了一个坑引出另一个坑”。如果输出为空且最后打印validation done说明 1900 到 2100 的每一天都和标准库吻合源码的日期正确性就可以放心了。我自己的习惯是这种验证器永远保留在项目里哪怕最终交付的只是一个很小的万年历 demo也会把“能被任何标准实现验证”当作完成标准。日期计算这种东西错一天就是彻底错靠眼睛看永远看不过来能自动验证才有底气说源码是好的。希望这篇笔记能帮你在万年历这个经典题目上少踩几个坑把代码写成能放心交付、能继续扩展的样子。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。