查看: 111|回复: 1

鸿蒙万年历应用开发:CalendarEngine与农历算法实战

[复制链接]
发表于 3 小时前 | 显示全部楼层 |阅读模式
万年历是移动端高频应用,开发一款功能完整的鸿蒙万年历应用,不仅需要展示公历日期,还要集成农历、节气、传统节日等中国特色功能。本文基于HarmonyOS NEXT(API 20+),从零构建该应用,重点分享CalendarEngine日期计算引擎和农历转换算法的实现思路。

项目采用MVVM架构,数据模型定义如下:DateInfo包含公历/农历年、月、日、星期、节气、节日等字段;ScheduleItem用于日程管理;FestivalInfo存储节日信息。

日历核心引擎CalendarEngine提供基础日期计算能力:
  1. // CalendarEngine.ets
  2. static DAYS_OF_MONTH: number[] = [31,28,31,30,31,30,31,31,30,31,30,31];
  3. static isLeapYear(year: number): boolean {
  4.     return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
  5. }
  6. static getDaysOfMonth(year: number, month: number): number {
  7.     if (month === 2) return this.isLeapYear(year) ? 29 : 28;
  8.     return this.DAYS_OF_MONTH[month - 1];
  9. }
  10. static getFirstDayOfMonth(year: number, month: number): number {
  11.     const m = month >= 3 ? month : month + 12;
  12.     const y = month >= 3 ? year : year - 1;
  13.     const k = y % 100;
  14.     const j = Math.floor(y / 100);
  15.     const day = 1;
  16.     const h = (day + Math.floor(13 * (m + 1) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) + 5 * j) % 7;
  17.     return (h + 6) % 7;
  18. }
  19. static getMonthGridRows(year: number, month: number): number {
  20.     const days = this.getDaysOfMonth(year, month);
  21.     const firstDay = this.getFirstDayOfMonth(year, month);
  22.     return Math.ceil((days + firstDay) / 7);
  23. }
  24. static formatDate(year: number, month: number, day: number, sep: string = '-'): string {
  25.     return `${year}${sep}${String(month).padStart(2,'0')}${sep}${String(day).padStart(2,'0')}`;
  26. }
  27. static parseDate(dateStr: string): { year: number; month: number; day: number } {
  28.     const parts = dateStr.split('-');
  29.     return { year: parseInt(parts[0]), month: parseInt(parts[1]), day: parseInt(parts[2]) };
  30. }
  31. static daysBetween(date1: string, date2: string): number {
  32.     const d1 = this.parseDate(date1);
  33.     const d2 = this.parseDate(date2);
  34.     const toDays = (y: number, m: number, d: number): number => {
  35.         let days = 0;
  36.         for (let i = 1970; i < y; i++) days += this.isLeapYear(i) ? 366 : 365;
  37.         for (let i = 1; i < m; i++) days += this.getDaysOfMonth(y, i);
  38.         return days + d - 1;
  39.     };
  40.     return Math.abs(toDays(d1.year,d1.month,d1.day) - toDays(d2.year,d2.month,d2.day));
  41. }
  42. static getToday(): { year: number; month: number; day: number } {
  43.     const now = new Date();
  44.     return { year: now.getFullYear(), month: now.getMonth()+1, day: now.getDate() };
  45. }
  46. static getTodayString(): string {
  47.     const t = this.getToday();
  48.     return this.formatDate(t.year, t.month, t.day);
  49. }
复制代码

农历转换是核心难点。LunarDate类基于查表法和位运算实现公历转农历。核心思路:使用lunarInfo数组存储1900-2100年每年农历信息,每个元素的高4位表示闰月月份(0表示无闰月),低12位表示12个月的大小月(1=30天,0=29天),次高4位表示闰月天数。通过计算目标日期与1900年1月31日(农历1900年正月初一)的天数偏移,迭代确定农历年、月、日,并包含节气判断。
  1. // LunarDate.ets
  2. static LUNAR_MONTH_NAME: string[] = ['','正','二','三','四','五','六','七','八','九','十','冬','腊'];
  3. static LUNAR_DAY_NAME: string[] = ['','初一','初二','初三','初四','初五','初六','初七','初八','初九','初十','十一','十二','十三','十四','十五','十六','十七','十八','十九','二十','廿一','廿二','廿三','廿四','廿五','廿六','廿七','廿八','廿九','三十'];
  4. static SOLAR_TERM_NAME: string[] = ['小寒','大寒','立春','雨水','惊蛰','春分','清明','谷雨','立夏','小满','芒种','夏至','小暑','大暑','立秋','处暑','白露','秋分','寒露','霜降','立冬','小雪','大雪','冬至'];
  5. // lunarInfo数组(完整数据详见原工程)
  6. static LUNAR_INFO: number[] = [0x04bd8, 0x04ae0, ...];
  7. static toLunar(year: number, month: number, day: number): LunarInfo {
  8.     let offset = this.calculateOffset(year, month, day) - 30; // 减去基准日期1900-01-31对应天数
  9.     let lunarYear = 1900, lunarMonth = 1, lunarDay = 1, isLeapMonth = false;
  10.     while (lunarYear < 2100 && offset > 0) {
  11.         const daysInYear = this.getLunarYearDays(lunarYear);
  12.         if (offset < daysInYear) break;
  13.         offset -= daysInYear;
  14.         lunarYear++;
  15.     }
  16.     const leapMonth = this.getLeapMonth(lunarYear);
  17.     const monthDays = this.getLunarYearMonths(lunarYear);
  18.     let monthIndex = 1;
  19.     while (monthIndex <= 12 && offset > 0) {
  20.         if (leapMonth > 0 && monthIndex === leapMonth + 1 && !isLeapMonth) {
  21.             isLeapMonth = true;
  22.             monthIndex--;
  23.             const leapDays = this.getLeapMonthDays(lunarYear);
  24.             if (offset < leapDays) break;
  25.             offset -= leapDays;
  26.         } else {
  27.             if (isLeapMonth && monthIndex === leapMonth + 1) isLeapMonth = false;
  28.             const normalDays = monthDays[monthIndex - 1];
  29.             if (offset < normalDays) break;
  30.             offset -= normalDays;
  31.         }
  32.         monthIndex++;
  33.     }
  34.     lunarMonth = isLeapMonth ? monthIndex - 1 : monthIndex;
  35.     lunarDay = offset + 1;
  36.     const solarTerm = this.getSolarTerm(year, month, day);
  37.     const festivals = this.getFestivals(year, month, day, lunarMonth, lunarDay, isLeapMonth);
  38.     return { lunarYear, lunarMonth, lunarDay, isLeapMonth, lunarMonthName: this.getLunarMonthName(lunarMonth, isLeapMonth), lunarDayName: this.LUNAR_DAY_NAME[lunarDay-1]||'', solarTerm, festivals };
  39. }
  40. private static calculateOffset(year: number, month: number, day: number): number {
  41.     let days = 0;
  42.     for (let y = 1900; y < year; y++) days += CalendarEngine.isLeapYear(y) ? 366 : 365;
  43.     for (let m = 1; m < month; m++) days += CalendarEngine.getDaysOfMonth(year, m);
  44.     days += day;
  45.     return days;
  46. }
  47. private static getLunarYearDays(year: number): number {
  48.     const idx = year - 1900;
  49.     if (idx < 0 || idx >= this.LUNAR_INFO.length) return 365;
  50.     let days = 0;
  51.     const info = this.LUNAR_INFO[idx];
  52.     for (let i = 0; i < 12; i++) days += (info & (1 << i)) ? 30 : 29;
  53.     return days + this.getLeapMonthDays(year);
  54. }
  55. private static getLeapMonth(year: number): number {
  56.     const idx = year - 1900;
  57.     if (idx < 0 || idx >= this.LUNAR_INFO.length) return 0;
  58.     return (this.LUNAR_INFO[idx] >> 12) & 0x0f;
  59. }
  60. private static getLeapMonthDays(year: number): number {
  61.     const leapMonth = this.getLeapMonth(year);
  62.     if (leapMonth === 0) return 0;
  63.     const idx = year - 1900;
  64.     return (this.LUNAR_INFO[idx] >> 13) & 1 ? 30 : 29;
  65. }
  66. private static getSolarTerm(year: number, month: number, day: number): string {
  67.     // 简化实现:使用SOLAR_TERM_OFFSET表判断日期是否接近标准节气
  68.     const index = (month - 1) * 2;
  69.     const termDay1 = this.SOLAR_TERM_OFFSET[index];
  70.     const termDay2 = this.SOLAR_TERM_OFFSET[index+1];
  71.     if (day >= termDay1 - 2 && day <= termDay1 + 2) return this.SOLAR_TERM_NAME[index];
  72.     if (day >= termDay2 - 2 && day <= termDay2 + 2) return this.SOLAR_TERM_NAME[index+1];
  73.     return '';
  74. }
复制代码

在UI层面,利用ArkUI的Grid组件构建月视图,通过CalendarEngine获取每月第一天星期和总天数动态生成网格,每个格子显示公历日期,下方小字显示农历或节气。使用@State和@Link管理选中日期和视图刷新。结合CalendarPicker组件可快速实现日期选择。需要注意的是,HarmonyOS NEXT的Stage模型要求严格声明权限,但万年历应用通常无需特殊权限。日程管理可基于关系型数据库(RDB Store)或首选项(Preferences)实现本地存储,具体可根据数据量选择。

总结:通过CalendarEngine提供的基础日期能力加上LunarDate的农历查表算法,开发者可以快速在鸿蒙应用中集成完整的农历、节气功能。该方案性能稳定,代码复杂度可控,适合直接嵌入生产级应用。后续可继续扩展节日库、倒计时、主题换肤等能力。
回复

使用道具 举报

发表于 3 小时前 | 显示全部楼层

Re: 鸿蒙万年历应用开发:CalendarEngine与农历算法实战

楼主分享得很详细,尤其是CalendarEngine里getFirstDayOfMonth用的蔡勒公式变体,以及农历查表法+位运算存储闰月和大小的思路,实际开发中非常实用。想请教一下,lunarInfo数组里节气数据是单独存储的还是和农历信息整合在一起计算的?另外,对于1900年之前或2100年之后的日期,楼主有没有考虑做外推处理,还是直接限制范围?UI层这块,月视图和年视图切换时,按楼主MVVM的结构,数据绑定是直接用@State监听DateInfo的变化吗?
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-6-4 20:15 , Processed in 0.025089 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部