Flutter for OpenHarmony:家具购买记录日历视图实战

发布时间:2026/10/21 3:01:16
Flutter for OpenHarmony:家具购买记录日历视图实战 最近在做 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 状态管理与月份数据聚合状态管理我选了ChangeNotifierInheritedWidget没有上重的状态管理框架。原因和存储类似少一层依赖少一分 OpenHarmony 适配风险。ChangeNotifier是 Flutter 框架自带的任何平台都不会出兼容问题。页面结构上我拆分了三层CalendarViewModel负责加载记录、保存记录、对外暴露状态。CalendarGridWidget纯展示组件接收月份和记录映射关系渲染日历格子。RecordDetailPage当天记录列表页。月份数据聚合是最容易写错的地方也是最影响性能的地方。日历是一次显示 6 行 x 7 列 42 个格子但其中会有上个月和下个月的补位日期。如果每次 build 都全表扫一遍记录页面会明显卡顿。我的做法是在ViewModel里维护一个MapString, ListPurchaseRecordkey 是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 会正确处理进位不需要自己判断大小月和闰年。ListDateTime 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 搭建日历网格UI42个格子怎么排网格布局直接用GridView.builder设置SliverGridDelegateWithFixedCrossAxisCount交叉轴数量固定为 7代表一周 7 天。CalendarGridWidget 的 build 方法class CalendarGridWidget extends StatelessWidget { final DateTime month; final MapString, ListPurchaseRecord recordMap; final DateTime selectedDate; final ValueChangedDateTime 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 左右在手机竖屏下比较合适。每个格子的内容结构是最上层显示“今天”的红色描边圆框或选中态的实心圆中间显示日期数字底部显示标记条。我用了ColumnStack来实现。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 记录标记与点击联动标记的状态有四种组合hasRecordhasUrgent显示效果falsefalse无标记truefalse蓝色圆点truetrue橙色圆点falsetrue不可能出现渲染逻辑如下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 取数据映射。关键代码Futurevoid _openRecordEditor([PurchaseRecord? existing]) async { final changed await Navigator.pushbool( 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 识别不准确最终月份显示拼音格式。我的做法是直接用中文映射自己写个月份常量数组不依赖系统 localeconst _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 里实时计算全部记录改为提前建立MapDateTime, ListRecordO(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 日、月初周一、月末周日、跨年把所有边界情况用真机跑一遍日历的坑基本能提前排掉九成。后续这个项目还可以扩展几个方向把保修临期的家具做成系统通知提醒、把消费数据按月汇总成图表、支持导出到表格文件。日历视图作为数据入口已经稳定了往上做场景扩展只是水到渠成的事。如果你手头也在做类似的记录类应用希望这篇文章能帮你少踩几个坑。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询