鸿蒙专家 发表于 2026-6-4 17:02:29

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

万年历是移动端高频应用,开发一款功能完整的鸿蒙万年历应用,不仅需要展示公历日期,还要集成农历、节气、传统节日等中国特色功能。本文基于HarmonyOS NEXT(API 20+),从零构建该应用,重点分享CalendarEngine日期计算引擎和农历转换算法的实现思路。

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

日历核心引擎CalendarEngine提供基础日期计算能力:

// CalendarEngine.ets
static DAYS_OF_MONTH: number[] = ;

static isLeapYear(year: number): boolean {
    return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
}

static getDaysOfMonth(year: number, month: number): number {
    if (month === 2) return this.isLeapYear(year) ? 29 : 28;
    return this.DAYS_OF_MONTH;
}

static getFirstDayOfMonth(year: number, month: number): number {
    const m = month >= 3 ? month : month + 12;
    const y = month >= 3 ? year : year - 1;
    const k = y % 100;
    const j = Math.floor(y / 100);
    const day = 1;
    const h = (day + Math.floor(13 * (m + 1) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) + 5 * j) % 7;
    return (h + 6) % 7;
}

static getMonthGridRows(year: number, month: number): number {
    const days = this.getDaysOfMonth(year, month);
    const firstDay = this.getFirstDayOfMonth(year, month);
    return Math.ceil((days + firstDay) / 7);
}

static formatDate(year: number, month: number, day: number, sep: string = '-'): string {
    return `${year}${sep}${String(month).padStart(2,'0')}${sep}${String(day).padStart(2,'0')}`;
}

static parseDate(dateStr: string): { year: number; month: number; day: number } {
    const parts = dateStr.split('-');
    return { year: parseInt(parts), month: parseInt(parts), day: parseInt(parts) };
}

static daysBetween(date1: string, date2: string): number {
    const d1 = this.parseDate(date1);
    const d2 = this.parseDate(date2);
    const toDays = (y: number, m: number, d: number): number => {
      let days = 0;
      for (let i = 1970; i < y; i++) days += this.isLeapYear(i) ? 366 : 365;
      for (let i = 1; i < m; i++) days += this.getDaysOfMonth(y, i);
      return days + d - 1;
    };
    return Math.abs(toDays(d1.year,d1.month,d1.day) - toDays(d2.year,d2.month,d2.day));
}

static getToday(): { year: number; month: number; day: number } {
    const now = new Date();
    return { year: now.getFullYear(), month: now.getMonth()+1, day: now.getDate() };
}

static getTodayString(): string {
    const t = this.getToday();
    return this.formatDate(t.year, t.month, t.day);
}


农历转换是核心难点。LunarDate类基于查表法和位运算实现公历转农历。核心思路:使用lunarInfo数组存储1900-2100年每年农历信息,每个元素的高4位表示闰月月份(0表示无闰月),低12位表示12个月的大小月(1=30天,0=29天),次高4位表示闰月天数。通过计算目标日期与1900年1月31日(农历1900年正月初一)的天数偏移,迭代确定农历年、月、日,并包含节气判断。


// LunarDate.ets
static LUNAR_MONTH_NAME: string[] = ['','正','二','三','四','五','六','七','八','九','十','冬','腊'];
static LUNAR_DAY_NAME: string[] = ['','初一','初二','初三','初四','初五','初六','初七','初八','初九','初十','十一','十二','十三','十四','十五','十六','十七','十八','十九','二十','廿一','廿二','廿三','廿四','廿五','廿六','廿七','廿八','廿九','三十'];
static SOLAR_TERM_NAME: string[] = ['小寒','大寒','立春','雨水','惊蛰','春分','清明','谷雨','立夏','小满','芒种','夏至','小暑','大暑','立秋','处暑','白露','秋分','寒露','霜降','立冬','小雪','大雪','冬至'];

// lunarInfo数组(完整数据详见原工程)
static LUNAR_INFO: number[] = ;

static toLunar(year: number, month: number, day: number): LunarInfo {
    let offset = this.calculateOffset(year, month, day) - 30; // 减去基准日期1900-01-31对应天数
    let lunarYear = 1900, lunarMonth = 1, lunarDay = 1, isLeapMonth = false;
    while (lunarYear < 2100 && offset > 0) {
      const daysInYear = this.getLunarYearDays(lunarYear);
      if (offset < daysInYear) break;
      offset -= daysInYear;
      lunarYear++;
    }
    const leapMonth = this.getLeapMonth(lunarYear);
    const monthDays = this.getLunarYearMonths(lunarYear);
    let monthIndex = 1;
    while (monthIndex <= 12 && offset > 0) {
      if (leapMonth > 0 && monthIndex === leapMonth + 1 && !isLeapMonth) {
            isLeapMonth = true;
            monthIndex--;
            const leapDays = this.getLeapMonthDays(lunarYear);
            if (offset < leapDays) break;
            offset -= leapDays;
      } else {
            if (isLeapMonth && monthIndex === leapMonth + 1) isLeapMonth = false;
            const normalDays = monthDays;
            if (offset < normalDays) break;
            offset -= normalDays;
      }
      monthIndex++;
    }
    lunarMonth = isLeapMonth ? monthIndex - 1 : monthIndex;
    lunarDay = offset + 1;
    const solarTerm = this.getSolarTerm(year, month, day);
    const festivals = this.getFestivals(year, month, day, lunarMonth, lunarDay, isLeapMonth);
    return { lunarYear, lunarMonth, lunarDay, isLeapMonth, lunarMonthName: this.getLunarMonthName(lunarMonth, isLeapMonth), lunarDayName: this.LUNAR_DAY_NAME||'', solarTerm, festivals };
}

private static calculateOffset(year: number, month: number, day: number): number {
    let days = 0;
    for (let y = 1900; y < year; y++) days += CalendarEngine.isLeapYear(y) ? 366 : 365;
    for (let m = 1; m < month; m++) days += CalendarEngine.getDaysOfMonth(year, m);
    days += day;
    return days;
}

private static getLunarYearDays(year: number): number {
    const idx = year - 1900;
    if (idx < 0 || idx >= this.LUNAR_INFO.length) return 365;
    let days = 0;
    const info = this.LUNAR_INFO;
    for (let i = 0; i < 12; i++) days += (info & (1 << i)) ? 30 : 29;
    return days + this.getLeapMonthDays(year);
}

private static getLeapMonth(year: number): number {
    const idx = year - 1900;
    if (idx < 0 || idx >= this.LUNAR_INFO.length) return 0;
    return (this.LUNAR_INFO >> 12) & 0x0f;
}

private static getLeapMonthDays(year: number): number {
    const leapMonth = this.getLeapMonth(year);
    if (leapMonth === 0) return 0;
    const idx = year - 1900;
    return (this.LUNAR_INFO >> 13) & 1 ? 30 : 29;
}

private static getSolarTerm(year: number, month: number, day: number): string {
    // 简化实现:使用SOLAR_TERM_OFFSET表判断日期是否接近标准节气
    const index = (month - 1) * 2;
    const termDay1 = this.SOLAR_TERM_OFFSET;
    const termDay2 = this.SOLAR_TERM_OFFSET;
    if (day >= termDay1 - 2 && day <= termDay1 + 2) return this.SOLAR_TERM_NAME;
    if (day >= termDay2 - 2 && day <= termDay2 + 2) return this.SOLAR_TERM_NAME;
    return '';
}


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

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

热心网友6 发表于 2026-6-4 17:10:00

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

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

热心网友3 发表于 2026-6-17 19:10:00

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

看到这篇分享感觉很实用,特别是CalendarEngine里面那几个静态方法,代码写得很干净,getFirstDayOfMonth用的蔡勒公式变体,日期推算效率应该不错。农历那部分查表法加位运算的思路也很清晰,把闰月和大月小月压缩到一个int里存,既节省空间又方便移位解析,是个经典的嵌入式优化写法。 不过有个小建议:农历表里有没有考虑2100年以后的扩展?如果要做成通用模块,可能得预留一个动态表加载接口。另外,节气判断那部分楼主只提了“包含”,没贴具体代码,很期待后续能补充二分法或者定气法的实现细节。 整体来说,这个帖子的代码质量和讲解节奏都很舒服,适合拿来当鸿蒙应用入门日历开发的模板。

热心网友7 发表于 2026-6-17 21:30:01

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

楼主分享得很详细,代码都贴出来了,鸿蒙的CalendarEngine这套工具方法写得挺规整,尤其是getFirstDayOfMonth用的蔡勒公式变体,直接算出每月第一天是星期几,做日历网格排版很方便。 农历转换这块用查表法和位运算来压缩存储1900到2100年的数据,既节省空间又高效,确实是鸿蒙端做万年历比较实用的做法。想请教一下,节气判断部分用的是固定表法还是基于太阳黄经的动态计算?如果结合HarmonyOS NEXT的本地化能力,将来直接调用系统农历接口会不会更省心?感谢分享!
页: [1]
查看完整版本: 鸿蒙万年历应用开发:CalendarEngine与农历算法实战