做移动端开发这些年,日历组件一直是我觉得最绕不开又最不好伺候的一块。尤其当项目从 React Native 要跨到鸿蒙,市面上能直接用的日历库不是体积大到影响启动速度,就是对鸿蒙的兼容还停留在“能跑”阶段,真机上一堆样式错位和交互失灵。后来我自己动手,用 MonthGrid 组件配合 6×7 总共 42 个单元的固定网格策略,在 React Native 鸿蒙跨平台工程里完整落地了一个月历体验,踩了不少坑,也积累了一套可以复用的思路。
这篇东西就把整个设计和实现过程掰开揉碎讲清楚:为什么用 6×7 而不是动态行数、MonthGrid 的核心网格算法怎么写、鸿蒙适配有哪些避不开的改动点,以及完整的实操步骤和问题排查记录。不管你是刚接触 RN 鸿蒙开发,还是已经在做跨平台应用想自己掌控日历组件,这篇应该都能给你省下几天摸索时间。
1. 为什么月历要用 6×7 的 42 单元策略
1.1 月历组件在移动端的普遍痛点
日历或者说月历组件,在移动应用里属于典型的高频需求但低满意度的模块。电商应用的签到日历、工具类应用的计划排期、学习类应用的打卡统计,全都离不开一个能看、能点、能标记的月视图。很多团队的第一反应是找现成的第三方日历库,真用起来才发现问题一堆:包体积动不动几百 KB,影响应用首包加载;自定义样式能力弱,想改个圆角选中态、节假日标签得覆写一层又一层;更麻烦的是性能,一次性渲染整个月的内容没做优化,快速滑动切换月份时明显卡顿。
在 React Native 生态里能选的日历库本来就没几个,维护活跃的更是少。加上鸿蒙这个新平台,情况会更尴尬。我实际调研过,很多知名的 RN 日历组件在鸿蒙上会出现阴影失效、触摸事件命中不准、滚动容器高度计算错误等问题,有些甚至直接把 JS 线程跑挂。与其在第三方库上打补丁,不如把这个基础组件攥在自己手里。这就是我做 MonthGrid 的起点。
1.2 6×7 策略的数学原理与选型考量
先解释一下 6×7 到底是什么意思。一个月最多有 31 天,最少 28 天,而一周固定 7 天。要在一个矩形网格里完整展示任何月份的日历,最朴素的做法是算出行数:如果当月 1 号是周五,那一行只能放下周四周五周六三天,剩余的天数会延伸到后面几行,整个月最多会占 6 行(例如某月 1 号是周六,且该月有 30 天)。所以平铺的月视图,行数在 4 到 6 之间浮动。
6×7 的策略就是不管当月实际有几行,一律渲染 6 行乘以 7 列,总共 42 个单元格。多的格子用上一个月的末尾几天和下一个月的开头几天填充。这么做的好处非常直接:
- 网格高度固定,不管翻到哪个月,容器高度都不会跳动,用户体验稳定;
- 行列位置可以简单用索引公式定位(
row = index / 7,col = index % 7),渲染逻辑不用动态计算行数; - 便于实现滑动切换月份时的无缝过渡,因为网格结构始终不变。
相比之下,动态行数的方案虽然视觉上更“紧凑”,但会在 4 行和 5 行之间切换时造成高度跳动,而且每个月的单元格布局逻辑都不一致,代码复杂度明显上升。在跨平台场景下,固定结构永远是更稳的选择。
1.3 为什么选择自研 MonthGrid 而不是改第三方库
我在决定自研之前,做了一个简单的对比评估,结论是三个方案里半自研的收益最高。
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 第三方库直接使用 | 接入快,功能全 | 鸿蒙兼容差,样式难定制,体积大 | 不推荐 |
| 第三方库二次封装 | 保留部分功能 | 底层问题无法根治,维护成本高 | 不推荐 |
| 自研 MonthGrid | 轻量可控,完全适配鸿蒙 | 需要写核心逻辑并自行测试 | 推荐 |
这里有个很关键的点:MonthGrid 不是一个完整的日历应用,而是一个只负责“按 6×7 网格生成日期数据 + 渲染单元格 + 管理选中状态”的轻量组件。它不关心你的业务是签到还是打卡,也不内置节假日信息,这些全部通过外部传入的配置和插槽来实现。这种设计让组件既小又快,又能在不同项目里复用时保持灵活。我曾经见过有人把日历组件写到几千行,业务逻辑和渲染逻辑全揉一起,后面想加个农历显示差点把代码推倒重来,这种坑能避开就避开。
2. MonthGrid 核心机制拆解
2.1 组件定位与数据结构设计
MonthGrid 的输入参数设计得很克制,核心就四个:目标年份、目标月份、一周从哪一天开始、以及每一天的渲染函数。渲染函数是重中之重,它决定了 MonthGrid 只是个“骨架”,而具体的日期外观(选中态、今天标记、事件小圆点、节日角标)全部由使用者自由发挥。
interface MonthGridProps { year: number; month: number; // 1-12 startOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0=周日,1=周一... renderDay?: (dayInfo: DayInfo, index: number) => React.ReactNode; onSelectDate?: (date: Date) => void; selectedDate?: Date | null; markedDates?: Record<string, boolean>; }数据结构上,42 个单元格对应一个长度为 42 的数组,每个元素是一个DayInfo对象,包含日期对象、属于当月还是前后月、在网格中的索引。这样一个数组就完整描述了一个月的网格形态,渲染层拿到它直接铺到网格里就行,逻辑干净。
interface DayInfo { date: Date; // 当天零点的 Date 对象 day: number; // 几号 isCurrentMonth: boolean; index: number; // 0-41 isToday: boolean; isSelected: boolean; }把“日期数据生成”和“单元格渲染”彻底拆开,还有一个额外的好处:单元测试可以直接针对数据层写,不需要挂载 React 组件。我后面在踩坑阶段发现日期偏移问题时,就是靠数据层的单测快速定位的,这个设计帮了大忙。
2.2 日期网格生成算法:42 个格子如何填满
这是 MonthGrid 的核心中的核心。我第一次写的时候以为很简单,不就是算个第一天是星期几嘛,结果真做起来在“前置位补上个月天数”和“跨年边界”上连踩了两个坑。先从推导讲起。
已知条件:某年某月,以及一周从哪天开始算。第一步要算出当月第一天是星期几。JavaScript 的Date.prototype.getDay()返回 0(周日)到 6(周六),这个值和startOfWeek一比较,就能得出这个月第一天在网格里的偏移量。
偏移公式是offset = (firstDayOfWeek - startOfWeek + 7) % 7。比如 2025 年 6 月 1 号是周日,如果一周从周一开始,那firstDayOfWeek = 0,startOfWeek = 1,偏移量就是(0 - 1 + 7) % 7 = 6,也就是网格第一行从最后一个位置(周日)才是 6 月 1 号,前面 6 格都是 5 月的尾巴。这个公式我一开始总想写成Math.abs取绝对值,结果周末起算的一月就错位,后来改成模运算一次搞定。
确定偏移量之后,填充逻辑就清晰了:
- 生成目标月份所有日期对象;
- 在目标月份日期数组前面插入
offset个上个月的日期; - 填充到最后不足 42 个时,从下个月开头补足;
- 每个日期通过
new Date(year, month - 1, day)来构造,注意月份从 0 开始。
我自己写的时候封装了一个getMonthGrid函数,返回长度为 42 的DayInfo[]数组,这段代码现在可以直接抄走用:
export function getMonthGrid( year: number, month: number, startOfWeek: number = 1 ): DayInfo[] { const firstDay = new Date(year, month - 1, 1); const offset = (firstDay.getDay() - startOfWeek + 7) % 7; // 上个月需要补位的日期 const prevMonthDays = new Date(year, month - 1, 0).getDate(); // 上个月总天数 const prevDays: DayInfo[] = []; for (let i = offset - 1; i >= 0; i--) { const d = new Date(year, month - 1, prevMonthDays - i); prevDays.push(createDayInfo(d, year, month, prevDays.length)); } // 当月日期 const daysInMonth = new Date(year, month, 0).getDate(); const currentDays: DayInfo[] = []; for (let i = 1; i <= daysInMonth; i++) { const d = new Date(year, month - 1, i); currentDays.push(createDayInfo(d, year, month, prevDays.length + currentDays.length)); } // 下个月补足 const total = prevDays.length + currentDays.length; const nextDays: DayInfo[] = []; for (let i = 1; i <= 42 - total; i++) { const d = new Date(year, month, i); nextDays.push(createDayInfo(d, year, month, prevDays.length + currentDays.length + nextDays.length)); } return [...prevDays, ...currentDays, ...nextDays]; }这里有一个细节很多人容易忽略:new Date(year, month, 0)拿到的是这个月最后一天,new Date(year, month, 1)的month如果越界(比如传 12),JavaScript 会自动进位到下一年的 1 月。利用这个特性,补下个月天数变得非常安全,不用手动判断 12 月跨年。
2.3 状态管理与选中交互
网格数据生成后,组件内部只需要维护两个核心状态:当前展示的月份(viewYear和viewMonth)和选中的日期(selectedDate)。我没有把选中状态放到 MonthGrid 内部强管理,而是通过selectedDate属性和onSelectDate回调做成受控组件。这么设计的原因是:日历的选中态往往只是业务逻辑的一部分,比如选中某个日期后,页面下方要同步展示当天的日程列表,这必须由外层组件处理。
点击某个单元格时,核心逻辑是判断这个日期是不是当前月,如果是就直接选中,如果不是就先把视图切换到对应月份再选中。这个“点击非当月日期自动翻月”的交互,是很多日历应用都在用但实现细节差别很大的点,我选择让 MonthGrid 只向外抛出点击事件,翻月动作由外层调用组件的方法或者修改viewYear/viewMonth完成,避免组件内部偷偷改属性造成渲染不同步。
const handlePressDay = (dayInfo: DayInfo) => { if (!dayInfo.isCurrentMonth) { // 切换视图月份到点击的日期所在月份 onChangeMonth(dayInfo.date.getFullYear(), dayInfo.date.getMonth() + 1); } onSelectDate?.(dayInfo.date); };网格的选中态样式,由前面提到的renderDay插槽自己决定。组件内部只负责计算isSelected、isToday这些布尔值并传给插槽。这样 MonthGrid 本身没有任何内建样式,完美适配不同设计稿下的主题风格,我在鸿蒙和 iOS 两端共用同一套逻辑,只是样式文件分别适配。
3. React Native 在鸿蒙上的跨平台适配
3.1 鸿蒙在 RN 生态的真实位置
先说个现状:React Native 官方对鸿蒙的支持一直是由 OpenHarmony 社区在推进的,如果你去 RN 官网看平台列表,里面暂时没有鸿蒙。实际工程里用的是社区维护的 React Native for OpenHarmony(也叫 react-native-harmony),当前版本已经能支持到 React Native 0.72 以上的大部分核心组件,但和 Android/iOS 相比,差距主要集中在部分原生模块、组件属性兼容性和第三方库的鸿蒙版本上。
这不代表不能做,而是意味着你在选型时需要额外确认每一个依赖包是否在鸿蒙上有对应实现。比如@react-native-community/datetimepicker这类强依赖原生能力的库,鸿蒙版本就需要单独找或者在工程里做条件编译跳过。但 MonthGrid 这种基于基础 View 和 Touchable 的纯 JS 组件,在鸿蒙上的兼容成本其实很低,这也是我坚持把它做成纯 JS 实现的重要原因。
3.2 适配 MonthGrid 的关键改造点
我拿开发过程中实际遇到的几个问题说明白鸿蒙适配要干什么。
第一个是gap属性。React Native 0.71 才开始支持gap,鸿蒙的版本跟进相对滞后。我在布局月份网格时喜欢用gap: 8来统一控制格子间距,结果在鸿蒙真机上间距完全不生效,所有格子挤在一起。排查后确认是鸿蒙版本对gap解析有兼容问题,解决方案改成在renderDay里给每个单元格加margin,或者直接用justifyContent: 'space-between'配合固定宽度百分比,我最后选择了margin方案,最简单直接。
第二个是滚动容器高度。MonthGrid 本身高度固定,因为 42 个格子乘以固定行高就是确定高度,但把它放进一个外部可滚动 ScrollView 时,鸿蒙对 ScrollView 内部内容高度和嵌套 FlatList 的测量存在误差。遇到的现象是切换到 6 行月份时底部被截断,切换到 4 行月份时下方出现大片空白。解决思路是用onLayout获取容器实际高度,在渲染前后对比,强制把外层 ScrollView 的contentContainerStyle高度设置为固定值。
第三个是触摸事件的延迟。鸿蒙版本的 RN 在低端设备上点击事件有明显的 100ms 左右延迟感,排查后发现是鸿蒙 WebView 底层的点击延迟策略影响到了部分 Touchable 组件。MonthGrid 的每个格子我用的是Pressable而不是TouchableOpacity,并设置了android_ripple为null,同时给根容器加了collapsable={false},实测点击反馈速度快了不少。
3.3 鸿蒙真机与模拟器验证
开发阶段我主要在 DevEco Studio 自带的模拟器上跑,但真机验证是必须做的,模拟器检测不出来的问题太多了。鸿蒙调试和 Android 不同,RN 工程跑鸿蒙包需要先用 DevEco 打开harmony目录下的工程,编译出 hap 包装到设备上,然后在 Metro 端做端口映射才能热更新调试。第一回操作不熟悉的人很容易卡在这一步,我把流程在一个专门的章节里详细讲(见 4.2 节)。
模拟器上最需要注意的一点是:鸿蒙模拟器的 CPU 架构是 x86_64,但很多真机是 ARM 架构,如果工程里集成了带 so 库的三方 SDK,模拟器上可能加载失败。MonthGrid 这类纯 JS 组件没有这个问题,但你在完整应用里集成时就得多留个心眼。
真机上我最深刻的一个教训是字体渲染差异。鸿蒙的默认字体是 HarmonyOS Sans,和 Android 的 Roboto 在小字号下的字重渲染效果差别明显。日历格子里的日期数字我设的是 14px,在 Android 上清晰锐利,到鸿蒙真机上显得偏细偏淡。后来对所有日期文字统一加了fontWeight: '500',才把观感拉齐。这些细节虽然不影响功能,但一个日历组件如果数字看起来糊的,用户对应用的信任感会大打折扣。
4. 完整实操:从空项目到月历落地
4.1 环境准备与工程初始化
要跑通 React Native 鸿蒙项目,需要的工具链比纯 RN 工程多一环。我的环境清单如下:
- Node.js 18 及以上、npm/yarn 任意包管理器;
- DevEco Studio 5.x(对应 API 12 及以上);
- OpenHarmony SDK,在 DevEco 里配置好;
@react-native/virtualized-lists等 RN 基础依赖(社区鸿蒙版要求)。
初始化工程时,直接用官方脚手架。最稳的方式是:
npx @react-native-community/cli@latest init MonthGridDemo然后安装鸿蒙适配包。社区方案里,react-native-harmony是以 patch 形式集成到 RN 工程里的,具体版本需要和你的 RN 主版本匹配。我用的组合是 React Native 0.72 + OpenHarmony 5.0.0 API 12,这个组合社区测试比较充分,踩坑少。
安装完后,工程里会多出一个harmony目录,这就是鸿蒙原生工程壳子。用 DevEco Studio 打开这个目录,同步 Gradle 依赖,就能编译出鸿蒙 hap 包。注意首次同步会下载大量依赖,国内网络环境下建议把 Gradle 镜像源和 ohpm 源都配好,不然光是下载就能耗掉半天。
4.2 接入 MonthGrid 依赖
因为 MonthGrid 是我自己维护的轻量组件,接入直接就是源码引用,很方便。如果你要把 MonthGrid 分享给团队内部其他项目用,建议打成 npm 私有包。在工程里新建src/month-grid目录,把组件代码放进去,然后在页面里正常 import 就行。
import React, { useState } from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { MonthGrid, getMonthGrid } from './src/month-grid';如果使用 npm 包则正常安装和链接:
npm install month-grid --save npx react-native link month-grid // RN 0.72 后这步一般可省略4.3 月历页面完整代码实现
下面的代码是一个可直接运行的月历页面,包含切换月份、选中日期、标记今天的完整功能。数据层用getMonthGrid,渲染层用MonthGrid组件,样式我这边针对鸿蒙做了兼容处理。
import React, { useMemo, useState } from 'react'; import { View, Text, StyleSheet, Pressable, TouchableOpacity, SafeAreaView, } from 'react-native'; import { MonthGrid, DayInfo } from './src/month-grid'; const WEEK_DAYS = ['一', '二', '三', '四', '五', '六', '日']; const CalendarScreen: React.FC = () => { const [viewYear, setViewYear] = useState(() => new Date().getFullYear()); const [viewMonth, setViewMonth] = useState(() => new Date().getMonth() + 1); const [selectedDate, setSelectedDate] = useState<Date | null>(null); const gridData: DayInfo[] = useMemo( () => getMonthGrid(viewYear, viewMonth, 1), [viewYear, viewMonth] ); const handlePrevMonth = () => { if (viewMonth === 1) { setViewYear(viewYear - 1); setViewMonth(12); } else { setViewMonth(viewMonth - 1); } }; const handleNextMonth = () => { if (viewMonth === 12) { setViewYear(viewYear + 1); setViewMonth(1); } else { setViewMonth(viewMonth + 1); } }; const renderDay = (dayInfo: DayInfo) => { const isSelected = selectedDate && selectedDate.getFullYear() === dayInfo.date.getFullYear() && selectedDate.getMonth() === dayInfo.date.getMonth() && selectedDate.getDate() === dayInfo.date.getDate(); const dayStyle = [ styles.dayCell, dayInfo.isCurrentMonth ? styles.currentMonthDay : styles.otherMonthDay, isSelected && styles.selectedDay, dayInfo.isToday && styles.todayDay, ]; return ( <Pressable key={dayInfo.index} style={dayStyle} onPress={() => { if (!dayInfo.isCurrentMonth) { setViewYear(dayInfo.date.getFullYear()); setViewMonth(dayInfo.date.getMonth() + 1); } setSelectedDate(dayInfo.date); }} > <Text style={[styles.dayText, isSelected && styles.selectedText]}> {dayInfo.date.getDate()} </Text> {dayInfo.isToday && <View style={styles.todayDot} />} </Pressable> ); }; return ( <SafeAreaView style={styles.container}> <View style={styles.header}> <TouchableOpacity onPress={handlePrevMonth}> <Text style={styles.navText}>上一月</Text> </TouchableOpacity> <Text style={styles.title}> {viewYear}年{viewMonth}月 </Text> <TouchableOpacity onPress={handleNextMonth}> <Text style={styles.navText}>下一月</Text> </TouchableOpacity> </View> <View style={styles.weekRow}> {WEEK_DAYS.map((day) => ( <View key={day} style={styles.weekCell}> <Text style={styles.weekText}>{day}</Text> </View> ))} </View> <MonthGrid year={viewYear} month={viewMonth} startOfWeek={1} renderDay={renderDay} /> </SafeAreaView> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', paddingHorizontal: 8, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 16, marginTop: 8, }, title: { fontSize: 16, fontWeight: '600', color: '#333', }, navText: { fontSize: 14, color: '#3478F6', fontWeight: '500', }, weekRow: { flexDirection: 'row', marginBottom: 4, }, weekCell: { flex: 1, alignItems: 'center', paddingVertical: 4, }, weekText: { fontSize: 12, color: '#8A8A8A', fontWeight: '500', }, dayCell: { width: `${100 / 7}%`, aspectRatio: 1, justifyContent: 'center', alignItems: 'center', borderRadius: 8, marginVertical: 1, }, currentMonthDay: { backgroundColor: '#fff', }, otherMonthDay: { opacity: 0.4, }, selectedDay: { backgroundColor: '#3478F6', borderRadius: 8, }, todayDay: { borderWidth: 1, borderColor: '#3478F6', }, dayText: { fontSize: 14, color: '#222', fontWeight: '500', }, selectedText: { color: '#fff', fontWeight: '600', }, todayDot: { width: 4, height: 4, borderRadius: 2, backgroundColor: '#3478F6', marginTop: 2, }, }); export default CalendarScreen;运行这段代码后,你就能看到完整月历:顶部有年份月份标题和切换按钮,中间是星期表头,下面是 6 行 7 列共 42 个格子。当月日期实显示,非当月日期半透明显示。点击非当月日期会自动翻到对应月份并选中,今天会有一个蓝色描边和圆点标记。整个组件高度固定,不会因为月份不同而跳动。
4.4 在鸿蒙工程中运行与调试
拿到上面代码后,在纯 RN 工程里npm run android就能看到效果。鸿蒙工程则是另一套流程,我第一次跑通时整理了很细致的步骤:
- 用 DevEco Studio 打开工程里的
harmony目录,等待 Gradle 同步完成; - 配置鸿蒙应用签名:DevEco 里选择自动签名,这需要登录华为账号,没账号的话可以用调试签名临时跑;
- 编译 hap 包:构建菜单里选择 Build Hap(s) / APP(s),产出的是
entry-default-signed.hap; - 安装到真机或者模拟器,在 DevEco 的 Device Manager 里启动模拟器后直接运行即可;
- 启动 Metro:在工程根目录执行
npm start; - 关键一步:鸿蒙设备的 Metro 端口默认是 8081,真机调试时需要在 DevEco 的
entry模块的配置里设置DebugServer为本机 IP。模拟器可以直接访问宿主机localhost,真机则要填局域网 IP。
# 确保 Metro 端口不被占用 lsof -i :8081 npm start -- --reset-cache如果你的手机和电脑不在同一局域网,真机上的 Metro 连接会失败,表现为页面白屏或输出Unable to load script。这个排查点是鸿蒙调试和 Android 最不一样的地方,Android 可以adb reverse做端口反向转发,鸿蒙的调试工具链目前没有直接等价物,提前配好网络最省心。
5. 常见问题与排查技巧实录
5.1 月历组件高频问题速查表
自己开发一段时间后,我把遇到的典型问题整理成了一张速查表。这些问题在鸿蒙和 Android/iOS 上的表现略有差异,但根因大多是相同的。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 启动白屏,Metro 报错 | 鸿蒙真机无法访问 Metro 地址 | 检查 DebugServer 是否配置为可访问的局域网 IP |
| 月初第一天显示在错误位置 | startOfWeek不统一,偏移公式算错 | 用(getDay() - startOfWeek + 7) % 7计算偏移量 |
| 格子间距在鸿蒙上失效 | gap属性兼容问题 | 改用margin或justifyContent实现间距 |
| 点击日期响应慢 | Pressable/Touchable 在鸿蒙下的兼容问题 | 使用 Pressable 并设置android_ripple={null} |
| 切换月份后滚动位置跳变 | 网格高度动态变化导致容器重排 | 统一 6×7 固定高度,避免高度跳动 |
| 12 月切 1 月日期错乱 | 跨年边界处理遗漏 | 复用new Date(year, month, day)的自动进位特性 |
| 日期对象出现 UTC 时区偏移 | getDate()在 UTC+8 以外地区不一致 | 统一用本地时间构造函数,避免toISOString() |
| 真机显示字体偏细 | 鸿蒙默认字体渲染不同 | 增加fontWeight: '500',统一观感 |
| 热更新不生效 | 鸿蒙 DebugServer 连接未持久保持 | 重新打开 DevEco 并连接 Metro,清理缓存 |
5.2 时区与日期对象的那些坑
日期处理是日历组件最容易翻车的地方,说展开写都够单独一篇。这里分享一个我印象最深刻的真实案例:测试同学在模拟器上手动改时区到 UTC-8(美国西部时间),结果日历整体错位到前一天。
根因是new Date(year, month - 1, day)构造的 Date 对象基于本地时区,但如果后面调用了toISOString()或者从其他地方拿到的是 UTC 字符串,再通过getUTCDate()取日期,就会出现一天偏差。加上鸿蒙模拟器的时区默认跟随宿主机,测试在不同时区下跑就会出现随机性错位。
我的解决方案是立了一条铁律:日历内部任何环节都不用toISOString()和getUTCDate(),一律用本地时间的getFullYear()/getMonth()/getDate()。生成日期对象时也只用new Date(year, monthIndex, day)这个三参数构造函数,坚决不传时间字符串。这样无论设备在哪个时区,网格数据都不会漂移。
5.3 性能优化与渲染体验的独家技巧
日历组件展示的数据量不大(42 个格子),但如果用了复杂的renderDay插槽(比如每个格子里有签到图标、节日角标、日程指示点),在低端鸿蒙设备上依旧会出现掉帧。我总结的优化手段按性价比排序:
第一,给每个单元格的渲染函数套React.memo,让无关状态的变更不触发全部格子重渲染。状态管理上把selectedDate作为核心状态,只有它变化时才重新渲染对应的格子。
第二,用getItemLayout固定行高和列宽。在 FlatList 或者 ScrollView 中渲染 42 个格子时,预先告知精确尺寸能显著减少布局计算量。鸿蒙设备上这个优化带来的体感提升比 Android 更明显。
第三,减少阴影和透明度的使用。鸿蒙的开花式渲染管线对shadow*系属性的性能消耗较大,尤其在格子数量多时。设计稿允许的前提下,尽量用边框和纯色背景替代阴影效果。
5.4 验证清单与测试建议
最后说说我的实测验证清单。日历这种基础组件,一旦出 Bug 影响面很大,而它本身又不难测试,强烈建议在交付前过一遍完整的自查清单:
- 连续翻 24 个月,验证所有月份的天数、偏移、补位是否正确;
- 跨年切换(12 月到次年 1 月)连续测 3 次以上;
- 设置
startOfWeek为 0 和 1 各测一轮,确保表头和网格对齐; - 分别在 Android 模拟器、鸿蒙模拟器和鸿蒙真机跑一遍,对比布局差异;
- 检查非当月日期的半透明样式在深浅色主题下是否都清晰;
- 快速点击切换按钮,确认没有内存泄漏和卡死。
这套清单我每次发版都会跑一遍,加起来不到半小时,但已经救回过好几次潜在的线上事故。
一点真实体会
回到开头说的那个判断:日历组件真的值得自己做。市场上那么多的第三方库,随便一搜就是“功能强大、支持多种模式、高度可定制”的鼓吹,但当平台变成鸿蒙,当设计稿里有一些独特交互时,真正好使的还是自己手里这套能看透每一行代码的方案。
MonthGrid 加上 6×7 这个看似死板的矩形策略,反而让整个实现变成了一件确定性强、边界清晰的事。42 个格子不会多也不会少,不用兜底异常行数,也不需要对动态高度做过渡动画,剩下的精力全部花在日期计算和交互体验上。
如果你也打算做类似的东西,我特别建议关注三个方向:把数据层做成纯函数、把渲染层做成插槽化、把 6×7 结构焊死不要动态变化。沿着这个路子走,你会省掉很多不必要的麻烦。后续如果有精力,我还会在这个网格基础上扩展周视图、日程条、课程表之类的应用场景,毕竟网格逻辑是通用的,换汤不换药而已。