news 2026/11/5 18:38:45

HarmonyOS智能日历开发实战:ArkTS+ArkUI从算法到界面

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS智能日历开发实战:ArkTS+ArkUI从算法到界面

我刚开始接触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应用开发的基本功也就扎实了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 13:42:06

YOLOv4目标检测入门:原理、训练与边缘部署全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 16:49:46

HT3310|集成锂电池充电 + 三档电机驱动控制芯片 SOT23‑6

一、产品概述 HT3310 是高度集成锂电充电管理 电机驱动控制芯片&#xff0c;内置充电模块、电机功率管、马达续流二极管、按键档位逻辑与全套保护电路&#xff0c;待机电流仅 5μA。USB 5V 输入充电&#xff0c;恒流充电电流 0.5A&#xff0c;浮充 4.2V&#xff0c;支持涓流‑…

作者头像 李华
网站建设 2026/10/23 6:21:43

Modbus数据模拟实战:从站模拟工具选型与调试技巧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/30 15:27:17

高效六级备考:5分钟间隔学习法与多维词库构建

1. 项目概述&#xff1a;碎片化学习法的价值重构"每天5分钟——六级轻松过"这个标题背后&#xff0c;隐藏着对传统英语学习模式的颠覆性思考。作为经历过四六级洗礼的过来人&#xff0c;我深刻理解大学生在专业课和社团活动夹缝中备考的困境。这个方案的核心不在于时…

作者头像 李华
网站建设 2026/10/22 8:12:54

Linux命令手册PDF高效速查:从排版逻辑到终端闭环

简介&#xff1a;这是一份Linux命令手册PDF&#xff0c;主要面向Linux系统用户、运维人员以及希望系统学习命令行操作的初学者&#xff0c;用于解决日常操作中命令记不住、参数不清楚、排错无依据等问题。资源为1个PDF文件&#xff0c;整体大小1.46MB&#xff0c;内容按系统管理…

作者头像 李华