最近在做 Flutter 跨端 APP 的时候,接了一个在 OpenHarmony 上跑家具购买记录的需求。功能不复杂,但做到日历视图那一层时,很多细节比预想中多。这篇就完整复盘一下这个“Flutter for OpenHarmony 家具购买记录App实战:日历视图实现”的过程,从方案选型到代码落地,把踩过的坑和整理好的思路一次说清。
日历视图本身是个很成熟的功能,但放到家具购买记录这个场景里,它承担的不只是“看日期”。用户买完沙发、床垫、冰箱之后,什么时候买的、保修期什么时候到期、每个月花了多少,这些信息散落在列表里很难感知。日历能把时间维度直接呈现出来,一眼看到哪个月买得多、哪件家具快到保期了。这个项目最终实现的效果就是:按月份切换日历,有购买记录的日期显示标记,点击某天能看到当天记录,点击右下角按钮可以快速新增,编辑或删除后日历实时刷新。
1. 项目背景:为什么给家具记录App做日历视图
1.1 在OpenHarmony生态里选Flutter的考虑
先聊一下技术选型的背景。OpenHarmony 是一个开源的操作系统生态,应用形态和现有移动端不完全一样。但业务上又需要覆盖 OpenHarmony 和 Android 两套平台,如果两边各写一套原生 UI,维护成本会翻倍。当时对比过几个方案,最后选了 Flutter 做跨端层。
选 Flutter 的核心原因有三个:
- 自绘渲染引擎,UI 一致性好,哪怕是日历这种密集网格控件,在 OpenHarmony 和 Android 上渲染效果能保持基本一致。
- Dart 语言的开发效率确实高,热重载对调试 UI 帮助很大,尤其是做日历格子这种需要反复调的组件。
- OpenHarmony 生态有团队在维护 Flutter 的适配层,虽然现在还不够成熟,但跑通常规业务是没问题的。
当然,前提是做好心理准备:OpenHarmony 上跑 Flutter 不是零成本。适配层对部分原生插件的支持还不完整,比如直接依赖系统能力的定位、传感器、某些数据库插件,都需要单独验证。这个项目里我刻意避免了重依赖,存储方式也选了轻量方案,后面细说。
1.2 家具购买记录的痛点与日历视图的价值
家具购买记录和一般记账不太一样。一般记账记的是流水,家具记录更关心三件事:买了什么、花了多少、什么时候需要再花钱(保修到期换新)。
做需求分析的时候,用户反馈最典型的一个问题是:想不起来某件家具是哪年买的。冰箱坏了想找保修单,翻半天聊天记录;想看看过去一年在家具上花了多少钱,只能一条条翻列表。这个痛点用列表页很难解决,但用日历视图就非常直观。
日历视图的价值可以拆成三层:
- 时间回溯:某年某月买了哪些家具,点开当天就能看到完整记录。
- 保修提醒:购买日期加上保修期,通过日历标记提醒用户哪些家具即将或已经过保。
- 消费统计:一个月内多个购买记录集中在日历上,消费密度一目了然。
所以这个项目虽然叫“家具购买记录App”,但日历视图不是附加功能,而是核心功能。整个 App 的主路径就是围绕日历展开:查看、定位、新增、编辑记录。
1.3 最终功能清单与目标读者
这个项目做完后,功能清单如下:
- 日历月视图切换(支持前后翻月、回到今天)
- 有购买记录的日期显示标记圆点
- 保修期剩余不足30天的显示紧急标记
- 点击日期查看当天购买记录列表
- 记录列表支持查看详情、编辑、删除
- 右下角悬浮按钮快速新增记录
- 本地存储,离线可用
如果你也在做 Flutter 或 OpenHarmony 上的时间线类应用,或者单纯想把日历视图这种通用组件做扎实,这篇的代码和思路可以直接抄作业。下面先聊方案选型,这部分决定了后面所有代码怎么写。
2. 方案选型与架构设计:日历视图最容易踩坑的地方
2.1 通用日历组件还是自绘网格
很多朋友上手就会找一个现成的日历组件。Pub 上确实有不错的日历库,功能齐全,但在这个项目里我一眼就看出会有麻烦。
最大的问题是定制成本。家具记录的日历标记不只是“有记录”和“没记录”两种状态,还需要区分“今天”、“选中日期”、“保修临期”、“有记录”、“未来日期”等状态。通用组件的默认样式往往固定,要改标记样式,得深入源码去override,每升级一个版本都可能冲突。
第二个问题是OpenHarmony 兼容风险。很多日历组件依赖底层时间库和手势系统,适配层跑起来不一定稳定。与其用一个黑盒,不如自绘网格,把逻辑完全掌握在自己手里。
所以最终方案是:用 GridView 自绘日历网格,自己做日期计算和状态管理。这个方案的优点很明显:
- 渲染逻辑完全可控,每个格子都是独立的 Widget,想画什么标记都行。
- 不依赖第三方日历库,OpenHarmony 适配风险降到最低。
- 代码量其实不多,核心网格生成逻辑 100 行以内能搞定。
2.2 数据模型与存储策略设计
数据模型是整个功能的地基。日历视图要展示什么,取决于数据模型怎么定义。
这个项目里我定义了一个PurchaseRecord类,核心字段如下:
class PurchaseRecord { final String id; final String name; // 家具名称,如:三人位布艺沙发 final String category; // 分类:沙发/床/冰箱/桌椅... final double price; // 价格 final DateTime purchaseDate; // 购买日期,日历视图的key final int warrantyMonths; // 保修时长(月) final String storeName; // 购买门店或渠道 final String notes; // 备注 }存储策略上,我放弃了sqflite+ 数据库的方案。原因是 OpenHarmony 适配层对sqflite这类依赖原生能力的插件支持参差不齐。考虑到家具记录的数据量——个人家庭场景一年也就几十条——用本地 JSON 文件存储完全够用。
通过PathProvider拿应用文档目录,读写一个 JSON 文件,启动时加载,变更时保存。好处是不用处理数据库迁移,也不怕插件适配问题。坏处是并发写入需要自己加锁,但这个体量下完全不是问题。
2.3 状态管理与月份数据聚合
状态管理我选了ChangeNotifier+InheritedWidget,没有上重的状态管理框架。原因和存储类似:少一层依赖,少一分 OpenHarmony 适配风险。ChangeNotifier是 Flutter 框架自带的,任何平台都不会出兼容问题。
页面结构上我拆分了三层:
CalendarViewModel:负责加载记录、保存记录、对外暴露状态。CalendarGridWidget:纯展示组件,接收月份和记录映射关系,渲染日历格子。RecordDetailPage:当天记录列表页。
月份数据聚合是最容易写错的地方,也是最影响性能的地方。日历是一次显示 6 行 x 7 列 = 42 个格子,但其中会有上个月和下个月的补位日期。如果每次 build 都全表扫一遍记录,页面会明显卡顿。
我的做法是在ViewModel里维护一个Map<String, List<PurchaseRecord>>,key 是"2025-03-15"这种日期字符串,value 是对应当天的记录列表。每次加载数据后就构建一次这个 Map,日历渲染时直接按 key 查找,时间复杂度是 O(1)。
2.4 日历网格的生成原理与日期计算细节
这是整个功能的核心知识点。生成一个月份网格,计算逻辑是这样的:
- 拿到当前月份的第一天
DateTime(now.year, now.month, 1)。 - 获取第一天是星期几,
weekday收盘:1 表示周一,7 表示周日。 - 按中国习惯周一作为每周第一天,所以需要向前补
firstWeekday - 1天,填上个月末尾几天。 - 从第一天开始,依次填充 42 个格子,直到填满整个网格。
- 如果 42 格里还有剩余,继续填下个月开头几天。
关键点在于跨月、跨年的日期计算。很多人直接用d.add(Duration(days: 1))来循环加一天,这在大月份没问题,但跨年的时候容易出 bug。我推荐直接用DateTime(year, month, day + 1),Dart 会正确处理进位,不需要自己判断大小月和闰年。
List<DateTime> generateCalendarDays(DateTime month) { final firstDay = DateTime(month.year, month.month, 1); final leadingEmpty = firstDay.weekday - 1; final startDay = DateTime(month.year, month.month, 1 - leadingEmpty); return List.generate(42, (index) { return DateTime(startDay.year, startDay.month, startDay.day + index); }); }这段代码是整篇的基石,后面的 GridView 渲染和标记逻辑全部依赖它。
3. 逐步实现:日历视图核心代码与联动逻辑
3.1 创建项目并配置依赖
开始写代码之前,先把项目结构搭好。直接用flutter create创建项目,然后按 OpenHarmony 适配要求调整目录结构。
flutter create furniture_calendar_app因为要跑在 OpenHarmony 上,需要在项目里配置适配层。这一步不同版本的接入方式略有差异,整体来说就是把 OpenHarmony 的工程目录加到项目中,并在 pubspec.yaml 里配置对应依赖。
pubspec.yaml里我加的东西不多:
dependencies: flutter: sdk: flutter intl: ^0.19.0 # 日期格式化,中文本地化 path_provider: ^2.1.0 # 获取应用文档目录 uuid: ^4.0.0 # 生成记录ID这里特别提醒一下,intl的版本要和 Flutter SDK 内置版本保持一致,否则会报找不到DateSymbols之类的错误。path_provider在 OpenHarmony 上通过适配层能正常工作,但我建议只在启动时调用一次缓存路径,不要频繁调用。
3.2 搭建日历网格UI:42个格子怎么排
网格布局直接用GridView.builder,设置SliverGridDelegateWithFixedCrossAxisCount,交叉轴数量固定为 7,代表一周 7 天。
CalendarGridWidget 的 build 方法:
class CalendarGridWidget extends StatelessWidget { final DateTime month; final Map<String, List<PurchaseRecord>> recordMap; final DateTime selectedDate; final ValueChanged<DateTime> onDaySelected; @override Widget build(BuildContext context) { final days = generateCalendarDays(month); return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 7, childAspectRatio: 0.85, ), itemCount: days.length, itemBuilder: (context, index) { final day = days[index]; return _buildDayCell(day); }, ); } }注意三个关键点:
shrinkWrap: true和NeverScrollableScrollPhysics是为了让日历网格在页面中固定高度,不参与外层滚动,否则会出现滑动冲突。itemCount固定为 42,不要用days.length以外的东西,保证网格每月都是 6 行完整展示,切换月份时高度不跳。有朋友为了省空间做成 5 行,会导致视觉抖动,不建议。- 每个格子的
childAspectRatio需要根据屏幕微调,要保证格子不会太高或太扁。我测试下来 0.85 左右在手机竖屏下比较合适。
每个格子的内容结构是:最上层显示“今天”的红色描边圆框或选中态的实心圆,中间显示日期数字,底部显示标记条。我用了Column+Stack来实现。
Widget _buildDayCell(DateTime day) { final isToday = _isSameDay(day, DateTime.now()); final isSelected = _isSameDay(day, selectedDate); final dayRecords = recordMap[dateKey(day)] ?? []; final hasRecord = dayRecords.isNotEmpty; final hasUrgent = dayRecords.any((r) => _isWarrantyUrgent(r)); return InkWell( key: ValueKey(dateKey(day)), onTap: () => onDaySelected(day), borderRadius: BorderRadius.circular(8), child: Stack( children: [ Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 32, height: 32, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, color: isSelected ? theme.primaryColor : (isToday ? Colors.transparent : null), border: isToday ? Border.all(color: theme.primaryColor, width: 1.5) : null, ), child: Text( '${day.day}', style: TextStyle( color: isSelected || isToday ? theme.primaryColor : _textColorForDay(day), fontWeight: isSelected || isToday ? bold : normal, ), ), ), SizedBox(height: 4), SizedBox( height: 8, child: _buildMarker(hasRecord, hasUrgent), ), ], ), ], ), ); }这里有个容易忽略的点:格子一定要加ValueKey。因为 GridView 复用了元素,如果不加 key,上个月的标记状态可能残留到这个月的格子上。这个问题排查起来很隐蔽,我第一次做的时候就吃了亏。
3.3 记录标记与点击联动
标记的状态有四种组合:
| hasRecord | hasUrgent | 显示效果 |
|---|---|---|
| false | false | 无标记 |
| true | false | 蓝色圆点 |
| true | true | 橙色圆点 |
| false | true | 不可能出现 |
渲染逻辑如下:
Widget _buildMarker(bool hasRecord, bool hasUrgent) { if (!hasRecord) return SizedBox.shrink(); return Container( width: 6, height: 6, decoration: BoxDecoration( shape: BoxShape.circle, color: hasUrgent ? Colors.orange : Colors.blueAccent, ), ); }保修临期判断逻辑要注意,不是简单地purchaseDate.add(Duration(days: 30 * warrantyMonths))。Duration是按绝对毫秒算的,遇到闰年或大月会出现偏差。正确做法是按月为单位做日期运算:
DateTime _warrantyEndDate(PurchaseRecord record) { final d = record.purchaseDate; return DateTime(d.year, d.month + record.warrantyMonths, d.day); } bool _isWarrantyUrgent(PurchaseRecord record) { final end = _warrantyEndDate(record); final remainDays = end.difference(DateTime.now()).inDays; return remainDays >= 0 && remainDays <= 30; }这里用DateTime(year, month + warrantyMonths, day)而不是add(Duration),Dart 会自动进位,如 2024-01-31 加上 2 个月会变成 2024-03-31,加 3 个月会变成 2024-04-30 这种合理的结果。如果purchaseDate是月末日期,不同月份末会有差异,但实际家具记录里购买日期都是真实日期,所以这个方案足够可靠。
点击日期后的联动逻辑是:选中日期更新 -> 加载当天记录列表 -> 底部弹出记录面板。我用的不是路由跳转,而是showModalBottomSheet,这样用户能保持日历上下文,方便连续查看不同日期。
void _onDaySelected(DateTime day) { setState(() => _selectedDate = day); final records = viewModel.recordsForDay(day); if (records.isEmpty) return; showModalBottomSheet( context: context, builder: (ctx) => RecordBottomSheet( date: day, records: records, onDelete: (record) => viewModel.deleteRecord(record.id), ), ); }3.4 新增和编辑记录后的刷新链路
这个看似简单,实际却是最容易出问题的环节。编辑完记录回到日历页,如果页面不刷新,用户会以为保存失败了。
刷新链路我设计成:记录页操作完成 -> 返回时携带最新数据 -> 日历页setState触发重建 -> 重新从 ViewModel 取数据映射。
关键代码:
Future<void> _openRecordEditor([PurchaseRecord? existing]) async { final changed = await Navigator.push<bool>( context, MaterialPageRoute( builder: (_) => RecordEditorPage(initial: existing), ), ); if (changed == true) { setState(() { _recordMap = viewModel.buildRecordMap(); }); } }View 层不需要直接操作数据库,ViewModel 内部完成增删改并保存到 JSON 文件。这里有一个小细节:buildRecordMap应该在后台 isolate 完成吗?我个人建议不要。个人数据量太小,直接同步构建反而简单可靠,async 引入的竞态问题比性能瓶颈更麻烦。
4. 性能优化与OpenHarmony适配
4.1 渲染性能:避免滑动掉帧的3个手段
日历页面一个月 42 个格子,每个格子结构不算重,但如果每个格子都做复杂的 build,遇到低端设备还是会有掉帧风险。这个项目里我用了三个手段保证流畅。
第一个手段是用 RepaintBoundary 隔离重绘区域。日历网格本身放在一个RepaintBoundary里,当天记录列表放在另一个RepaintBoundary里。这样点击日期时,只有下面的记录面板 rebuild,上面的日历网格不会跟着整体重绘。
第二个手段是避免在 build 方法里做耗时计算。日历构建依赖的所有数据——当月天数列表、记录 Map、保修临期判断——都提前在 ViewModel 里算好。Widget 只做纯展示,遇到复杂计算直接查缓存。
第三个手段是记录列表用 ListView.builder 懒加载。点击某天弹出底部面板,如果当天有 30 条记录,懒加载能保证打开时不会一次性渲染 30 个 item。虽然数据量不大,但这个习惯养成了,以后做其他功能也能用上。
这三个手段做完后,在 OpenHarmony 的测试设备上日历切换月份基本能保持在 60 帧,没有明显卡顿感。如果设备规格更低,可以把整天记录的 item 再轻量化,比如减少阴影和滤镜的使用。
4.2 设备适配:横竖屏、深浅色与中文本地化
OpenHarmony 生态的终端形态比手机更多样,有竖屏手机、有横屏平板、还有带触摸大屏的设备。日历视图在横屏下 6 行会显得太矮,格子宽高比要动态调整。
我用MediaQuery.of(context).orientation判断方向:
final aspectRatio = MediaQuery.of(context).orientation == Orientation.portrait ? 0.85 : 1.4;竖屏时格子偏窄高,横屏时格子偏矮宽,保证视觉比例合理。
深色模式适配必须做。系统切深色后,日历格子里白色背景的格子会很刺眼。我用 Flutter 的Theme.of(context)来获取当前主题色,而不是写死颜色。日期数字颜色我做了个柔和处理:
Color _textColorForDay(DateTime day) { final inCurrentMonth = day.month == month.month; final isWeekend = day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; if (!inCurrentMonth) return Theme.of(context).disabledColor; if (isWeekend) return Colors.redAccent; return Theme.of(context).colorScheme.onSurface; }中文本地化这里有一个坑。直接用intl的DateFormat('yyyy年M月')格式化日期,在纯 Flutter 环境下没问题,但在 OpenHarmony 适配层上可能出现系统 locale 识别不准确,最终月份显示拼音格式。我的做法是直接用中文映射,自己写个月份常量数组,不依赖系统 locale:
const _months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; String getMonthTitle(DateTime month) => '${month.year}年${_months[month.month - 1]}';这样虽然少了国际化扩展性,但在中文本地化场景下是稳定可靠的,而且代码一眼就能看明白。
5. 常见问题排查实录
5.1 问题速查表
实际开发过程中踩了不少坑,整理成一张速查表,方便你直接对照:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 日历格子错位,月初不是从周一开始 | weekday从周日算还是周一开始没统一 | 确认weekday取值:周一=1,周日=7,前补weekday - 1个空位 |
| 上个月的记录标记残留在本月 | GridView 复用了 item,旧状态没清 | 给每个格子设置ValueKey(dateKey(day)),强制重建 |
| 切换月份后原地等待时间过长 | build 里实时计算全部记录 | 改为提前建立Map<DateTime, List<Record>>,O(1) 查询 |
| 新增记录返回后日历没显示标记 | 返回数据没触发setState | 用Navigator.push的返回值作为信号,强制刷新 |
| 保修期计算提前半个月到期 | 用Duration(days: 90)计算三个月,跨月不准 | 改用DateTime(year, month + n, day)做月份进位运算 |
| 跨年切换时日历跳到错误月份 | 处理 12 月 + 1 月时直接用month + 1,没有处理 year 进位 | 统一用DateTime(year, month + offset, 1),Dart 会自动进位 |
| 深色模式下日期文字看不清 | 写死了黑色文字 | 使用Theme.of(context)的颜色体系,写死颜色的地方全换掉 |
| OpenHarmony 上字体显示异常偏小 | 系统字号适配不同,Flutter 默认字号在某些设备上偏低 | 页面根 widget 用MediaQuery校正textScaleFactor,控制在 0.85~1.15 之间 |
5.2 容易忽略的日期边界细节
第 1 个细节是2024 年 2 月 29 日这种闰日。如果用户某天买了家具恰好是 2 月 29 日,保修期按月份加会在平年三月没有 29 日,Dart 会自动进位到 3 月 1 日或 3 月 2 日。这在真实场景里完全合理,但如果你在测试用例里断言2024-02-29 + 1 month == 2024-03-29,就会失败。测试里要覆盖这种边界。
第 2 个细节是"日期字符串作为 Map key" 必须用固定格式。别用DateTime.toString(),这个结果是"2025-03-15 14:30:00.000",包含时分秒,时间变了 key 就变了。我用的是这个格式:
String dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';统一用这个函数生成 key,查询时也用这个函数,就不会出现漏查错查。
第 3 个细节是时区与会话。OpenHarmony 设备上如果用户改了时区,DateTime.now()会变化,但已存储记录里的日期字符串是固定的。建议所有存储、展示都基于本地日期,不要转 UTC,否则会出现跨时区后日历标记偏移一天。个人记录类 App 完全没有必要存 UTC。
6. 我的实操体会与后续扩展思路
这个项目做完,最大的体会是:日历视图真正难的从来不是画格子,而是数据在时间维度上的组织方式。很多人一开始就盯着 GridView 和样式调优,忽略了一个核心问题——怎么把业务数据以 O(1) 的时间复杂度挂在每一个日期上面。这个想清楚了,日历视图实现起来反而很快。
第二个体会是 OpenHarmony 适配并不可怕,但要有意识地减少对原生能力的依赖。我整个项目没有用一个需要原生平台特殊适配的第三方插件,JSON 存储 + 自绘 UI 覆盖了全部需求。这种思路在生态还不够成熟的时候特别重要。
最后再分享一个小技巧:调试日历功能的时候,不要只测当前月份。建议你从 2024-01 一直翻到 2025-12,中间经过 2 月 29 日、月初周一、月末周日、跨年,把所有边界情况用真机跑一遍,日历的坑基本能提前排掉九成。
后续这个项目还可以扩展几个方向:把保修临期的家具做成系统通知提醒、把消费数据按月汇总成图表、支持导出到表格文件。日历视图作为数据入口已经稳定了,往上做场景扩展只是水到渠成的事。如果你手头也在做类似的记录类应用,希望这篇文章能帮你少踩几个坑。