万年历是移动端高频应用,开发一款功能完整的鸿蒙万年历应用,不仅需要展示公历日期,还要集成农历、节气、传统节日等中国特色功能。本文基于HarmonyOS NEXT(API 20+),从零构建该应用,重点分享CalendarEngine日期计算引擎和农历转换算法的实现思路。
项目采用MVVM架构,数据模型定义如下:DateInfo包含公历/农历年、月、日、星期、节气、节日等字段;ScheduleItem用于日程管理;FestivalInfo存储节日信息。
日历核心引擎CalendarEngine提供基础日期计算能力:- // CalendarEngine.ets
- static DAYS_OF_MONTH: number[] = [31,28,31,30,31,30,31,31,30,31,30,31];
- 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[month - 1];
- }
- 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[0]), month: parseInt(parts[1]), day: parseInt(parts[2]) };
- }
- 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[] = [0x04bd8, 0x04ae0, ...];
- 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[monthIndex - 1];
- 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[lunarDay-1]||'', 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[idx];
- 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[idx] >> 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[idx] >> 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[index];
- const termDay2 = this.SOLAR_TERM_OFFSET[index+1];
- if (day >= termDay1 - 2 && day <= termDay1 + 2) return this.SOLAR_TERM_NAME[index];
- if (day >= termDay2 - 2 && day <= termDay2 + 2) return this.SOLAR_TERM_NAME[index+1];
- return '';
- }
复制代码
在UI层面,利用ArkUI的Grid组件构建月视图,通过CalendarEngine获取每月第一天星期和总天数动态生成网格,每个格子显示公历日期,下方小字显示农历或节气。使用@State和@Link管理选中日期和视图刷新。结合CalendarPicker组件可快速实现日期选择。需要注意的是,HarmonyOS NEXT的Stage模型要求严格声明权限,但万年历应用通常无需特殊权限。日程管理可基于关系型数据库(RDB Store)或首选项(Preferences)实现本地存储,具体可根据数据量选择。
总结:通过CalendarEngine提供的基础日期能力加上LunarDate的农历查表算法,开发者可以快速在鸿蒙应用中集成完整的农历、节气功能。该方案性能稳定,代码复杂度可控,适合直接嵌入生产级应用。后续可继续扩展节日库、倒计时、主题换肤等能力。 |