我刚开始接触HarmonyOS应用开发的时候,总觉得日历这种项目太“基础”了,不就是画个网格把数字填进去吗?直到自己动手做了一遍,才发现这里面的水远比想象中深:每个月的天数不一样、闰年怎么处理、某个月的第一天到底是星期几、上个月末尾的空缺怎么补、点完切换按钮之后界面状态怎么同步……任何一个细节没想清楚,渲染出来的日历都是错位的。这篇就以“智能日历制作”为实例,把我在ArkTS + ArkUI上从零实现一个完整日历的全过程拆开来讲,包括算法推导、界面搭建、状态管理和调试经验,希望能给正在练手HarmonyOS应用的朋友一点参考。
1. 项目思路与整体设计
1.1 为什么拿日历练手最合适
日历是一个典型的“麻雀虽小五脏俱全”的应用。它不依赖网络、不依赖后端,核心逻辑全在前端,却又天然涉及数据处理、状态管理、UI渲染、组件复用这四个HarmonyOS应用开发里最基础也最重要的能力。做完一个日历,你对@State状态装饰器的响应机制、ForEach循环渲染的用法、Grid网格布局的调参套路,基本就有手感了。
另外日历的“智能”二字也值得琢磨。别把它做成一个死板的静态网格,而是要让它具备三层能力:第一,能正确展示任意年月的日期排列;第二,能响应用户的切换操作,流畅地在不同月份之间跳转;第三,能标记特殊日期,比如高亮今天、支持选中某个日期。这三层全部实现,这个日历就不再是“能看”的Demo,而是“能用”的小工具。
我这里定的目标是做成一个单页应用:顶部是年月标题加左右切换按钮,中间是“日一二三四五六”的星期表头,下方是6行7列的日期网格。点击日期可以选中,选中的格子有独立的高亮样式,今天也有特殊标记。整体垂直居中,适配不同屏幕宽度时网格自动拉伸。
1.2 核心难点拆解
日期类应用真正的复杂度不在UI,而在日期计算。我拆出三个必须正面解决的问题:
第一个是月份天数问题。大月31天、小月30天,2月最特殊,平年28天、闰年29天,而闰年又牵扯到“四年一闰,百年不闰,四百年再闰”的规则。
第二个是星期定位问题。要知道某个月的第一天是星期几,才能决定这个月的1号该从网格的第几列开始放。这一步做错,整个日历从第一个格子就偏了。
第三个是网格天数补全问题。一个标准日历网格通常是42格(6行7列),但一个月的实际天数只有28到31天,这意味着网格的前段可能要有上个月的结尾日期,后段要补下个月的开头日期。补多了不行,补少了布局会塌,必须精确计算。
把这三点想透了,剩下的就是纯编码功夫。下面我会按算法、界面、联调三个层面逐步展开。
2. 核心算法拆解
2.1 闰年判断:一个条件能写错三个版本
判断闰年的标准规则其实就一句话:能被4整除但不能被100整除的年份是闰年,能被400整除的年份也是闰年。写成代码是这样:
export class DateUtils { static isLeapYear(year: number): boolean { return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); } }这个写法看起来简单,但我见过不少新手写出过两个典型错误版本。第一个版本漏掉了“不能被100整除”的条件,结果1900年被当成闰年,而1900年实际是平年,因为虽然能被4整除,也能被100整除,但不能被400整除。第二个版本用了一长串if嵌套,把简单问题复杂化,虽然结果是对的,但可读性很差,后续维护容易改出错。
判断闰年的目的是服务2月天数计算,而2月天数只是“当月天数”的一个分支,所以最好把这两个逻辑分开,各做各的,后面调用时才不会一头雾水。
2.2 每月天数计算:用数组做基础表
一年十二个月的天数,平年情况下是固定的:1月31天、2月28天、3月31天、4月30天、5月31天、6月30天、7月31天、8月31天、9月30天、10月31天、11月30天、12月31天。这里特别容易记错的点是7月和8月都是31天,中间不隔着30天的小月。
我用一个数组存平年每月天数,然后单独处理2月:
static getMonthDays(year: number, month: number): number { const daysInMonth: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (month === 2 && DateUtils.isLeapYear(year)) { return 29; } return daysInMonth[month - 1]; }调用时要注意month的取值习惯。我统一用“1代表1月,12代表12月”的自然语义,因此数组索引要记得减1。这个问题看似不起眼,但在我实际开发中遇到过好几次:前一个月传的是数组索引值,后一个月又传的是自然月份值,混在一起,算出来的日期永远是错的。
2.3 星期定位算法:从一个锚点日往前推
要算“某年某月1日是星期几”,最常见的方法是查表公式,但公式本身不好记也不直观。我习惯用“锚点累计法”:选一个已知星期几的日期作为锚点,然后计算目标日期与锚点之间隔了多少天,再对7取模,就得到目标日期的星期。
我选定的锚点是2000年1月1日,这一天是星期六。把这个日期写成“星期6”(用一个约定,0代表周日,1代表周一,依此类推),然后:
static getFirstDayOfWeek(year: number, month: number): number { let totalDays = 0; // 累加从2000年到目标年份前一年的总天数 for (let y = 2000; y < year; y++) { totalDays += DateUtils.isLeapYear(y) ? 366 : 365; } // 累加目标年份中从1月到目标月份前一月的总天数 for (let m = 1; m < month; m++) { totalDays += DateUtils.getMonthDays(year, m); } // 2000年1月1日是星期六,约定0为周日 return (6 + totalDays) % 7; }这里的totalDays是目标月1号与锚点日之间的天数差。因为锚点是星期六,索引为6,所以用(6 + totalDays) % 7。得到的结果如果是0,表示这天是周日,如果是1,表示是周一,以此类推。
我在测试这个函数时发现一个容易踩的坑:如果传入月份时把“12月”传成“0月”或者“13月”,循环逻辑会直接出错。所以我会在前面加一层参数校验,超出1到12范围的直接返回-1,调用方拿到负数就知道是参数传错了,而不是拿一个莫名其妙的结果去渲染界面。
2.4 生成42格数据模型
有了上述两个基础函数,接下来要把一个月的日期铺成一个6行7列的网格。前段补上个月的尾巴,后段补下个月的开头。我定义了一个DayItem接口来承载每个格子的信息:
export interface DayItem { day: number; isCurrentMonth: boolean; isToday: boolean; isSelected: boolean; }生成网格数据的逻辑如下:
static getDayList(year: number, month: number, todayStr: string): DayItem[] { const daysInMonth = DateUtils.getMonthDays(year, month); const firstDayOfWeek = DateUtils.getFirstDayOfWeek(year, month); // 上个月的天数 const prevMonthDays = DateUtils.getMonthDays(year, month - 1 === 0 ? 12 : month - 1); const list: DayItem[] = []; // 补齐上个月末尾 for (let i = firstDayOfWeek - 1; i >= 0; i--) { list.push({ day: prevMonthDays - i, isCurrentMonth: false, isToday: false, isSelected: false }); } // 当前月 for (let day = 1; day <= daysInMonth; day++) { const dateStr = `${year}-${month}-${day}`; list.push({ day: day, isCurrentMonth: true, isToday: dateStr === todayStr, isSelected: day === 1 && false }); } // 补下个月开头,固定凑满42格 const remain = 42 - list.length; for (let day = 1; day <= remain; day++) { list.push({ day: day, isCurrentMonth: false, isToday: false, isSelected: false }); } return list; }这里有几个细节说明一下。firstDayOfWeek是0到6的整数,0表示周日,1表示周一。如果1号是周三(索引3),那么前段要补3个格子,分别是上个月倒数第3天、倒数第2天、倒数第1天。循环从firstDayOfWeek - 1开始,也就是2、1、0,刚好对应三个格子。
prevMonthDays计算时要注意月份边界:如果当前是1月,上个月其实是去年的12月,所以要先判断再计算。42 - list.length的写法是算剩余格子数,不用手动算每次要补多少天,逻辑上更稳。
选中的状态初始统一设为false,用户点击后再动态更新,这点放在界面层处理。
3. UI实现与状态管理
3.1 页面结构:Column + Row的层级嵌套
HarmonyOS的ArkUI声明式UI,写界面就像搭积木。我的布局结构是:最外层用Column垂直排列,里面分三块——顶部操作栏、星期表头、日期网格。
顶部操作栏用Row横向排列,左侧是一个“上月”按钮,中间是“某年某月”的文本,右侧是“下月”按钮。日期网格我用Grid组件实现,设置columnsTemplate为1fr 1fr 1fr 1fr 1fr 1fr 1fr,也就是7列等分,行数不固定,由内容自动撑开。这样在不同屏幕宽度下,格子宽度会自适应拉伸,比手工计算宽高的方式省心得多。
还有一个容易被忽略的细节:把星期表头从日期网格里单独拆出来做,而不是合在一起。因为星期表头是固定的7列文字,而日期网格是动态数据,两者混在一个组件里会增加复杂度。拆开之后,表头用简单的Row加ForEach渲染“日一二三四五六”,下面放数据网格,各管各的,逻辑清晰。
3.2 状态定义:哪些数据需要驱动UI刷新
在ArkUI里,用@State装饰的变量一旦变化,会自动触发对应UI的重新渲染。我这个日历需要三个状态:当前显示的年、当前显示的月、当前选中的日期字符串。下面是关键代码:
@State currentYear: number = 2025; @State currentMonth: number = 1; @State selectedDate: string = '';为什么选中的日期也要放进状态里?因为当用户点击某一天时,我要改变这个格子的背景色;当用户切换月份再切回来时,之前选中的高亮还应该保留。如果选中日期不参与状态管理,界面就不会响应点击变化,看起来像“失灵”了一样。
另外今天的标记不能写死,不然应用跨天运行后会显示错误。我在aboutToAppear生命周期里动态取系统日期,格式化成年-月-日字符串,传给getDayList做对比,这样每天第一次打开应用时都会重新计算今天。
3.3 日期网格渲染:ForEach的key选择有讲究
渲染日期网格用的是ForEach,这里有一个非常关键的细节:keyGenerator参数。如果不提供key,框架在列表数据变化时可能无法精确识别哪一项变了,容易产生复用错乱。比如从1月切到2月,格子数量变化很大,没有key时会出现闪烁甚至显示错位。
我用的key是每个格子的唯一标识,由“年份-月份-天数-是否当前月”拼接而成。这样切换月份时,每个格子都有稳定的身份标识,框架能准确判断哪些需要创建、哪些需要销毁、哪些需要更新。
渲染单个格子的代码如下:
Grid() { ForEach(this.dayList, (item: DayItem) => { GridItem() { Column() { Text(item.day.toString()) .fontSize(16) .fontColor(this.getTextColor(item)) } .width('100%') .height(48) .justifyContent(FlexAlign.Center) .backgroundColor(this.getBgColor(item)) .borderRadius(8) .onClick(() => { this.selectedDate = `${this.currentYear}-${this.currentMonth}-${item.day}`; this.dayList = DateUtils.getDayList(this.currentYear, this.currentMonth, this.todayStr); }) } }, (item: DayItem) => `${this.currentYear}-${this.currentMonth}-${item.day}-${item.isCurrentMonth}`) }getTextColor和getBgColor是我封装的两个方法,用来根据DayItem的属性决定文字颜色和背景色。逻辑分三层:非当前月的日期用浅灰色,表示占位但不强调;今天的日期用品牌色背景加白色文字;选中的日期用另一个强调色,与今天区分开。这里颜色不要搞太花哨,我吃过亏:刚开始用了五六种颜色,最后界面像打翻的调色盘,用户根本分不清哪个是重点。
4. 完整代码与运行效果
4.1 日期工具类的完整实现
把上面散落的代码整合成一个完整的DateUtils工具类:
export class DateUtils { static isLeapYear(year: number): boolean { return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); } static getMonthDays(year: number, month: number): number { if (month < 1 || month > 12) { return -1; } const daysInMonth: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (month === 2 && DateUtils.isLeapYear(year)) { return 29; } return daysInMonth[month - 1]; } static getFirstDayOfWeek(year: number, month: number): number { if (month < 1 || month > 12) { return -1; } let totalDays = 0; for (let y = 2000; y < year; y++) { totalDays += DateUtils.isLeapYear(y) ? 366 : 365; } for (let m = 1; m < month; m++) { totalDays += DateUtils.getMonthDays(year, m); } return (6 + totalDays) % 7; } static getTodayStr(): string { const now = new Date(); return `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`; } static getDayList(year: number, month: number, todayStr: string): DayItem[] { const daysInMonth = DateUtils.getMonthDays(year, month); const firstDayOfWeek = DateUtils.getFirstDayOfWeek(year, month); const prevYear = month === 1 ? year - 1 : year; const prevMonth = month === 1 ? 12 : month - 1; const prevMonthDays = DateUtils.getMonthDays(prevYear, prevMonth); const list: DayItem[] = []; for (let i = firstDayOfWeek - 1; i >= 0; i--) { list.push({ day: prevMonthDays - i, isCurrentMonth: false, isToday: false, isSelected: false }); } for (let day = 1; day <= daysInMonth; day++) { const dateStr = `${year}-${month}-${day}`; list.push({ day: day, isCurrentMonth: true, isToday: dateStr === todayStr, isSelected: false }); } const remain = 42 - list.length; for (let day = 1; day <= remain; day++) { list.push({ day: day, isCurrentMonth: false, isToday: false, isSelected: false }); } return list; } }注意我在getTodayStr里用了now.getMonth() + 1,因为Date对象的月份索引是从0开始的,1月对应0。这个坑我在第一次写的时候踩过,死活显示“今天是0月1日”,排查半天才想起来。
4.2 页面代码的完整实现
页面部分的完整结构如下:
@Component export struct CalendarPage { @State currentYear: number = 2025; @State currentMonth: number = 1; @State selectedDate: string = ''; @State dayList: DayItem[] = []; private todayStr: string = ''; aboutToAppear(): void { this.todayStr = DateUtils.getTodayStr(); const now = new Date(); this.currentYear = now.getFullYear(); this.currentMonth = now.getMonth() + 1; this.dayList = DateUtils.getDayList(this.currentYear, this.currentMonth, this.todayStr); } changeMonth(offset: number): void { let newMonth = this.currentMonth + offset; let newYear = this.currentYear; if (newMonth > 12) { newMonth = 1; newYear += 1; } else if (newMonth < 1) { newMonth = 12; newYear -= 1; } this.currentYear = newYear; this.currentMonth = newMonth; this.dayList = DateUtils.getDayList(this.currentYear, this.currentMonth, this.todayStr); } getTextColor(item: DayItem): string { if (!item.isCurrentMonth) { return '#999999'; } if (item.isToday) { return '#FFFFFF'; } return '#333333'; } getBgColor(item: DayItem): string { if (item.isToday) { return '#007DFF'; } return '#FFFFFF'; } build() { Column() { Row() { Button('上月') .onClick(() => this.changeMonth(-1)) Text(`${this.currentYear}年${this.currentMonth}月`) .fontSize(18) .fontWeight(FontWeight.Bold) .layoutWeight(1) .textAlign(TextAlign.Center) Button('下月') .onClick(() => this.changeMonth(1)) } .width('100%') .padding(16) Row() { ForEach(['日', '一', '二', '三', '四', '五', '六'], (item: string) => { Text(item) .fontSize(14) .fontColor('#666666') .layoutWeight(1) .textAlign(TextAlign.Center) }) } .width('100%') .padding({ top: 8, bottom: 8 }) Grid() { ForEach(this.dayList, (item: DayItem) => { GridItem() { Column() { Text(item.day.toString()) .fontSize(16) .fontColor(this.getTextColor(item)) } .width('100%') .height(48) .justifyContent(FlexAlign.Center) .backgroundColor(this.getBgColor(item)) .borderRadius(8) .onClick(() => { if (item.isCurrentMonth) { this.selectedDate = `${this.currentYear}-${this.currentMonth}-${item.day}`; this.dayList = DateUtils.getDayList(this.currentYear, this.currentMonth, this.todayStr); } }) } }, (item: DayItem) => `${this.currentYear}-${this.currentMonth}-${item.day}-${item.isCurrentMonth}`) } .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr') .columnsGap(4) .rowsGap(4) .width('100%') .padding(12) .layoutWeight(1) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } }这里onClick里做了个判断:只有点击当前月的日期才更新选中状态,点占位的上个月或下个月日期不响应。这个交互取舍见仁见智,我见过一些日历点击占位日期会跳转到对应月份,但那个逻辑会让整个应用多一层月份跳转状态,新手容易绕晕,所以我先做成“点击占位日不响应”,保证核心逻辑简单可靠。
4.3 运行效果与联调记录
把页面挂到入口组件后,我在模拟器上跑了一次,重点验证几个边界场景:
1月展示:切到2025年1月,1号是周三,网格前段应该补周一、周二两天的日期,即2024年12月30日和12月31日。实测显示正确,两个灰色占位格子落在前两列。
2月展示:切到2025年2月,平年28天,1号是周六。前段补5个格子(周一到周五),后段补到42格,尾部出现3月的开头几天。实测网格底部刚好填满,没有明显缺行或空行。
12月切换:从12月点“下月”按钮,年份自动加1,变成第二年1月。从1月点“上月”按钮,年份自动减1,变成去年12月。实测切换后标题文字和数据同步更新,没有出现“标题是1月,日期却是2月”的错乱。
今天标记:应用启动当天,对应日期格子显示品牌色背景和白色文字。从模拟器改系统日期再重启应用,标记也跟随变化。
选中状态:点击当前月任意日期,格子背景变为强调色。切换月份再切回来,选中样式丢失——这是我当前实现的预期行为,因为选中状态绑定在currentYear和currentMonth的组合上,如果希望跨月保留,得额外加一个映射表,属于功能增强项,先不展开。
5. 常见问题与调试经验
5.1 日期全部错位一格:星期约定不一致的坑
我第一次跑通界面时发现,1号的格子总是落在一列偏右的位置。排查了半天,问题出在星期编号的约定上:我在getFirstDayOfWeek里约定“0代表周日、1代表周一”,但在表头数组里写的是['日', '一', '二', '三', '四', '五', '六'],索引0对应“日”,这个是一致的。
真正的问题出在补位循环上。firstDayOfWeek - 1这个式子,当1号是周三时,索引3,循环从2到0,补3个格子。看起来没错,但如果我某次把“星期日到底是0还是7”搞混了,循环次数就会多1或少1。这里我总结了一个自查方法:找一个月1号是周一的月份,比如2024年4月,如果网格第一格正确显示4月1日,说明星期约定自洽;如果第一格是空的或者显示上个月的日期,那一定是补位数量错了。
5.2 ForEach渲染错乱:加了key还是一样
调试过程中还遇到过一个诡异问题:切换月份时,某些格子的文字更新了,但背景色还是上一个月份的。后来发现是getDayList返回的数组虽然在变,但如果两个相邻月份的DayItem数量恰好相同,且keyGenerator生成的字符串没有包含月份信息,框架就会认为这是同一批节点,只更新文字不更新样式。
解决办法就是给keyGenerator加上currentYear和currentMonth的前缀,让每个月的格子身份都不同。我最终用的key是${this.currentYear}-${this.currentMonth}-${item.day}-${item.isCurrentMonth},实测切换月份后样式和文字都会完整刷新,不会再出现残留。
5.3 节流与重复点击问题
快速连续点击“下月”按钮,有时会出现界面跳过了某个月份的情况。原因不是状态丢失,而是点击事件里没有做防抖,快速点击触发多次changeMonth,界面在极短时间内连续渲染,肉眼看起来就像跳月了。
解决方式很简单:在changeMonth里记录一个isChanging标志位,进入函数时判断,如果正在切换中则直接返回,切换完成后再重置标志。加了这个保护后,连续点击也只会一个月一个月地跳,不会一次跳两个月。
5.4 日历模块的扩展思路
做完这个基础智能日历后,我还测试了几个可扩展方向:给日期添加农历显示、在日期下加一个小圆点标记表示有日程、长按日期弹出详情面板。这些功能都是在当前数据结构上做加法,不用推翻现有架构。如果你也想练手,我建议先把我这版基础功能完整跑通,再尝试扩展,这样每一步的改动都能精准定位到问题,不会把bug混在一起找。
最后再分享一个小技巧:调试日期类应用时,多准备几个已知的“参照月份”,比如2024年2月(闰年)、2025年2月(平年)、2025年1月(1号周三)、2025年6月(1号周日)。每次改完算法,先把这几个月份跑一遍,比对实际日历软件的结果,比盯着代码空想要高效得多。日历项目本质上是“算法精度+状态管理+UI渲染”三者的结合,你把这条链路摸透了,HarmonyOS应用开发的基本功也就扎实了。