做Flutter for OpenHarmony的家具购买记录App,是我最近一个月推进的一个业余项目。说实话,日历视图这种界面,在很多人的眼里就是“画个格子、放几个数字”,可真把它塞进一个需要实际记录购买数据的App里,涉及的日期计算、数据聚合、滚动复用、平台适配,每一环都能给你挖坑。这篇就把我从环境配置到日历落地、再到问题排查的完整过程写下来,给想在这个生态里做Flutter开发的同学一条可以直接参考的路径。这个App解决的是很现实的问题:家里装修或者添置家具时,购买记录散落在聊天记录、订单截图和一张张收据里,想查保修期、想统计某一时段花了多少钱,常常对不上账。日历视图在这里承担的,就是“时间索引”的角色——用户不需要翻列表,看日历上的标记就能知道哪天买过东西,点下去又能看到当天明细。这个需求听起来简单,但要把流畅交互、数据持久化和平台差异都处理好,工作量一点都不小。如果你正准备在一个非传统Flutter平台上落地自己的项目,或者正在纠结“日历到底自绘还是用现成库”,这篇能帮你少走不少弯路。我把方案选型、数据模型、网格计算、交互实现、存储查询、坑点排查全部摊开来讲,代码层面的关键节点也贴出来了,可以照着改。1. 项目背景与日历视图的设计思考1.1 家具购买记录管理的真实痛点家具购买和日常网购的区别在于:单个物品价值高、决策周期长、后续维护需求强。一张床垫能用十年,一台电视的保修可能就两年,用户真正需要的不是“买了什么东西”的流水账,而是“什么时候买的、花了多少钱、保修到什么时候、找谁售后”这样一套完整档案。我最初想的是做一个普通的列表页,按时间倒序展示记录,字段包含家具名称、类别、价格、购买日期、购买地点、备注等。但列表页有一个天然的问题:当用户有五十条乃至上百条记录时,时间维度很难快速感知。用户常常会问:上个月我到底买了什么?6月份那批沙发总价多少?这种问题用列表回答很吃力,但日历视图可以非常直观地回答——一眼看到哪些日期有购买记录,点一下就是当天明细,滑动一下就是下一个月的聚合。所以这个App里日历不是“装饰”,它是整个购买记录模块的入口之一。设计上我坚持两点:日历必须和记录数据双向联动,新增记录后日历标记要即时刷新;日历本身要轻,不能让用户觉得“看个日历还要等半天”。1.2 日历视图的核心设计目标在动手写代码前,我先把日历视图的需求拆成了四个目标:以月为单位展示自然日期,包含上月末补位和下月初补位;有购买记录的日期显示圆点标记,多记录不重复计数;点击某一天可以查看并快捷新增当天的购买明细;月份切换流畅,不能有卡片卡顿或数据错乱。这四个目标看着简单,实际做的时候会发现:每个目标背后都有一堆细节。比如“有记录的日期显示圆点”,这个“哪一天”到底按什么时区算?用户在美国凌晨买的东西,切换到中国时区会不会变成昨天?再比如月份切换流畅,如果每次翻页都把所有Widget重建一遍,在低端设备上必定掉帧。所以我在写第一行界面代码之前,先把数据层和日历计算层的关系理清了。理清关系之后,我就放弃了“照着其他项目抄一个日历”的想法,决定自己实现一套。原因很简单:第三方日历库确实成熟,但大多数是针对Android/iOS的Widget体系设计的,在OpenHarmony这套平台上,我验证后发现部分渲染效果和触摸事件处理跟预期有差异;而自绘方案虽然前期代码多一点,但所有行为都是可控的,后续加“周维度聚合”“保修提醒”这些功能时,扩展空间也大。2. 环境搭建与日历方案选型2.1 Flutter for OpenHarmony 的环境准备要点先说说环境。Flutter for OpenHarmony并不是官方分支直接能跑的,需要切换到社区维护的OpenHarmony适配版本。我的做法是在一台Linux构建机上,拉取社区适配分支的源码,然后手动编译出可用的Flutter SDK。编译本身不复杂,但有几个点容易卡人:必须安装OpenHarmony的SDK,并配置好HarmonyOS相关的环境变量;项目的Gradle和AGP版本要与OpenHarmony工具链对齐,否则构建HAP包时会报各种版本冲突;构建命令不是常规的flutter build apk,而是用OpenHarmony适配分支提供的flutter build hap之类指令。我第一次构建时就是没注意版本对齐,卡在Gradle下载依赖上浪费了半天。后来我把整个构建环境固定成一套已知可用的组合,再不动它。这点很重要:在非主流Flutter平台上开发,环境一旦跑通,就尽量不要频繁升级工具链,否则很容易引入不可控的问题。2.2 日历组件选型:为什么不直接用现成的三方库在日历组件选型上,我专门做了一个小对比,说实话也纠结了很久。现成的三方日历库,比如知名的table_calendar,功能确实全面,支持多种日历格式、事件标记、手势拖拽选中。但它内部依赖了大量Material组件,并且对日期格式化、国际化、动画调度有自己的实现逻辑。在OpenHarmony上运行,我发现它带来的适配工作量比预期高:某些点击动画不跟随触摸位置,部分区域刷新也不彻底。如果项目是快速原型,用三方库没有问题;但作为需要长期维护、还要对接本地存储的实战项目,我宁愿把控制权拿在自己手里。自绘方案的优势也很明确:可以严格按设计稿输出视觉效果,不受库的默认样式约束;只实现当前需要的交互,代码量反而比引入一个通用库更小;数据模型完全由自己定义,日历组件和业务层之间通过接口交互,调试时不用翻库源码。所以我最终选择了自绘。但自绘不是什么都不用管,它需要你把日历计算逻辑写对,把复用策略想清楚,这也是本文后面要展开的重头戏。3. 家具购买记录的数据模型设计3.1 记录实体建模:字段怎么定日历视图只是表象,真正撑起它的是数据模型。家具购买记录不是简单的“名称+价格”,我设计了这样的一个实体:class PurchaseRecord { final int id; final String furnitureName; final String category; // 床、沙发、茶几、灯具等 final String brand; final double price; final DateTime purchaseDate; // 只保留年月日,见下方时区说明 final String storeName; final String notes; final int warrantyMonths; // 保修时长,方便做提醒 }字段里的purchaseDate是使用最多的查询条件,我强制只保留年月日,不保留时分秒。这背后的原因很简单:日期视图需要的是“日”粒度,时间戳带小时分钟反而容易引起边界问题。我在第7节会详细展开这个坑。记录和日历标记的关系是一对多的:一天可能买了两张椅子、一张书桌,那么在日历中这个日期只显示一个标记,但点击进去能看到多条记录。所以日历标记的数据结构我采用了“日期字符串 -> 记录列表”的聚合映射,而不是让日历直接遍历全部记录。3.2 按月聚合的查询边界日历翻页是按“月”为单位的,所以我需要高效的按月查询。SQL配合时间范围查询是最直接的方式:SELECT * FROM purchase_records WHERE purchase_date >= date(?) AND purchase_date < date(?, '+1 month') ORDER BY purchase_date DESC;这样做的好处是数据库只返回当前月份的数据,只要给purchase_date字段建好索引,查询压力很小。数据量就算到几千条,日历翻页也是毫秒级响应。在实际代码里,我会用一个CalendarRepository来封装查询逻辑。它对外暴露两个方法:queryByMonth(DateTime month)和queryByDay(DateTime day)。页面拿到当月数据后再构建标记映射,这样数据层和UI层解耦,后续换存储方案也不用改界面。4. 日历网格的实现细节4.1 每月首日星期与天数的动态计算日历网格的核心是计算逻辑。一个月的第一天是星期几、这个月有多少天,这两个值决定了网格的排布。在Dart里,计算某月的天数我习惯用这种方式:int daysInMonth(DateTime month) { return DateTime(month.year, month.month + 1, 0).day; } int firstWeekdayOfMonth(DateTime month) { // DateTime(month.year, month.month).weekday // 注意:Dart中weekday从周一到周日是1到7 return DateTime(month.year, month.month, 1).weekday; }DateTime(year, month + 1, 0)这个写法可以“往回借一天”,因为dart的DateTime允许month超范围自动进位/退位。这里有个细节:如果我需要周一到周日排列,首日星期值firstWeekday直接对应第几列起步;如果需要周日到周六排列,还要做一次取模转换。我自己的App里使用了国内常见的“周一作为一周第一天”,所以起步列就是firstWeekday - 1。除了知道第一天和总天数,还要计算上月末补位和下月初补位。假设某月1号是周三,按每周7天排列,从周一开始的话,网格前两格要放上个月倒数第一天和倒数第二天。这个补位数量就是firstWeekday - 1。末尾补位数量则是(firstWeekday - 1 + daysInMonth) % 7,如果结果不为0,就需要补充7 - 余数个下月初的日期。补位的数据虽然展示的是上个月或下个月的日期,但点击后同样可以跳转到对应日期并展示记录。这样用户在日历中可以无缝跨月查看,不用强制回到正确月份。4.2 连续月份模型的构建与复用日历做成左右滑动切换月份时,最容易踩的坑就是每次滑动都“重新计算一个新月份,然后重新构建一堆Widget”。滑动速度稍微快一点,界面就会出现空白或者错位。我用的是PageView + 一个连续的月份数据模型。思路是:不管用户往哪边滑,始终维护一个“当前可见月份前后各若干月”的数据列表,月份本身是线性递增的序列,不是每次从零生成。class MonthData { final int year; final int month; final int leadingCount; // 上月末补位天数 final int dayCount; // 当前月总天数 final int trailingCount; // 下月初补位天数 }初始时构建当前月数据,再额外生成左侧和右侧各一个月的MonthData。PageView的初始页设置为1,向左滑减一,向右滑加一。构建MonthData的成本极低,真正有消耗的是生成一个个日期单元格的Widget。对于单元格Widget,我用的是GridView.builder,让系统只构建当前视口内可见的单元。配合RepaintBoundary,每个日期格子被独立绘制,翻页时临近月份的格子可以复用,显著降低了重绘面积。4.3 事件标记与每日购买记录的关联标记逻辑是:把当日记录列表映射到一个布尔值,有记录就画一个圆点,没有就不画。这个映射在数据层加载当月记录完成后构建一次。我用的映射键是格式化的日期字符串,格式是yyyy-MM-dd。关键点在于,这个格式化必须和数据库查询、UI展示用同一个时间源,不能做隐式时区转换。Dart的DateTime如果带时区信息,toString()输出的字符串在跨时区传参会出问题,所以我干脆在构造时就统一成“本地日期的年月日零点”。final String key = '${date.year.toString().padLeft(4, '0')}-' '${date.month.toString().padLeft(2, '0')}-' '${date.day.toString().padLeft(2, '0')}';构建标记映射以后,每个格子只需要一个Set<String>的查找操作,O(1)复杂度,就算画面里有几百个格子,性能也完全撑得住。5. 交互与状态处理5.1 点击日期查看当天明细日历格子不只是展示,还需要响应点击。我在每个格子外面套了一个GestureDetector,点击后回调事件,把选中的日期传给父组件。状态管理上我没有引入特别重的框架,只用了ValueNotifier<DateTime?>来做选中日期的状态,配合ValueListenableBuilder刷新底部明细区域。这样做的好处是:日历翻页时不需要重建整个页面,选中的日期变化只会触发明细区域的重绘。点击日期后,底部会弹出一个明细面板,展示当天的所有购买记录。面板不是全屏,大约是屏幕的40%,可以上下拖拽调整高度。为了方便快速记录新家具,面板顶部还有一个“添加入口”,点击后弹出表单,填完保存后日历的标记立刻更新。这个交互流程我做了很多次手感调试:面板拖拽阻尼、阴影层级、背景遮罩透明度。Flutter默认的BottomSheet在OpenHarmony上表现基本可用,但手势冲突处理需要自己写。我的方案是明细面板使用DraggableScrollableSheet,但给它设置一个独立的ScrollController,避免它和外部PageView的横向滑动竞争。5.2 月份切换动画与惯性滑动月份切换我用PageView实现,这是Flutter里最自然的方式。为了让切换有“翻月”的视觉暗示,我在每个月份页的顶部显示“YYYY年M月”,中间是7列网格。这里有一个性能关键点:PageView默认会用setState触发重建当前页,如果你把月份页写成一个大的build方法,里面再嵌套多个GridView.builder,很容易导致整个页面重建。我的做法是把月份页拆成两个独立的Widget:一个是头部月份标签,一个是表格区域,各自内部的状态独立管理。月份标签跟随PageView控制器变化,表格区域则只负责展示当前页的数据。动画方面我加了一个透明度渐入渐出,幅度很小,避免用户觉得拖沓。实测下来,在中等配置的设备上滑动非常流畅,没有出现白屏或掉帧。5.3 多选状态与统计联动日历视图还有一个很实用的功能:按住某个日期后拖拽,可以连续选择多天,底部自动汇总这些天的购买总额。比如用户想统计“6月1日到6月10日买了多少钱”,直接拖选就能看到总和。这样比手动翻开每一天要高效得多。多选状态的维护我用了一个Set<DateTime>,选择逻辑是:按下时记录起点,拖动经过的每一天都加入集合,松手时停止。这个功能的实现依赖每个格子的handling事件,但要注意不能和PageView的横向滑动冲突。我通过手势竞技场(GestureArena)的配置,让纵向拖拽优先响应多选,横向滑动优先响应翻页,两者互不干扰。底部统计栏展示的是选中天数、记录条数和总金额,数据来源是之前构建的“日期字符串 -> 记录列表”映射。由于映射已在内存中,统计过程不需要重新查库,响应用户操作的速度非常快。6. 本地存储与查询优化6.1 本地存储选型:为什么用SQLite家具购买记录这种数据,量级通常就是几千条,SQLite绰绰有余。但OpenHarmony平台上,官方维护的一些插件版本可能没有直接适配,这时候就需要靠FFI方式运行SQLite。我选择的方案是用sqflite_common_ffi,它通过Dart FFI直接调用SQLite的C库,跨平台一致性很好,不依赖系统原生插件。在OpenHarmony上验证下来,整体稳定性和性能都不错。初始化逻辑:import 'package:sqflite_common_ffi/sqflite_ffi.dart'; void initDatabase() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; }然后创建数据库和表:CREATE TABLE purchase_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, furniture_name TEXT NOT NULL, category TEXT, brand TEXT, price REAL NOT NULL, purchase_date TEXT NOT NULL, store_name TEXT, notes TEXT, warranty_months INTEGER ); CREATE INDEX idx_purchase_date ON purchase_records(purchase_date);purchase_date字段存储的是yyyy-MM-dd格式的字符串,也就是我在3.1节说的日期键。这个设计直接和日历的标记映射键对齐,查询和映射之间的转换几乎为零。6.2 按月查询的性能优化细节按月查询最主要的挑战是:不要让SQL去全表扫描。解决办法是给purchase_date建索引,然后查询时用范围条件。Future<List<PurchaseRecord>> queryByMonth(DateTime month) async { final start = '${month.year.toString().padLeft(4, '0')}-' '${month.month.toString().padLeft(2, '0')}-01'; final end = DateTime(month.year, month.month + 1, 1); final endStr = '${end.year.toString().padLeft(4, '0')}-' '${end.month.toString().padLeft(2, '0')}-01'; final result = await db.query( 'purchase_records', where: 'purchase_date >= ? AND purchase_date < ?', whereArgs: [start, endStr], orderBy: 'purchase_date DESC', ); return result.map(PurchaseRecord.fromMap).toList(); }这里用>= start AND < end而不是BETWEEN,是因为日期字符串比较时,BETWEEN的边界容易出问题。比如2024-06-30和2024-07-01之间,用< 2024-07-01能精确取到整个6月,但用BETWEEN '2024-06-01' AND '2024-06-30'会把末尾日期漏掉的风险。因为30号这个字符串本身是包含在内的,所以理论上也可以,但为了统一,我习惯用左闭右开。实测数据量在两千条左右时,查询基本在10毫秒以内,日历翻页完全感觉不到延迟。6.3 记录增删改查的完整联动新增或修改购买记录后,日历标记需要立刻反映出来。我用的思路是:写入数据库成功后,把当前月份的记录重新加载一次,更新内存中的映射。这个“重新加载当月”的操作很轻,即便数据库在OpenHarmony上,也只是一次预编译查询。为了减少不必要的重建,我引入了ChangeNotifier来管理记录集合。日历页监听这个通知,只有当前月份数据发生变化时才重绘网格。如果一个记录跨月修改,比如把购买日期从6月改到7月,通知会触发两个月份的数据刷新,但界面层面只更新对应的月份页。删除记录同理,删除成功后从本地映射中移除对应条目,同时刷新UI。这里有个小细节:因为数据库外键关系很简单,我没有做级联删除,所有引用都是应用层维护的,所以删除记录后要确保日历的圆点标记和统计金额都同步更新,不能只删数据库里的行。7. 常见问题与排查实录7.1 日期偏移和时区陷阱这应该是整个项目里最典型的问题。用户在中国添加一条记录,选择的是“2024年6月10日”,但显示在日历上却变成了6月9日。排查后发现问题出在DateTime的构造方式上:如果使用DateTime.parse('2024-06-10'),它默认当作UTC时间解析,而后端或本地时区转换时偏移了8小时,导致显示成了前一天。解决方法是:所有业务层日期都用“构造本地日期”的方式,而不是字符串解析。即先用DateTime(2024, 6, 10)创建一个本地时间,再把它格式化为yyyy-MM-dd存储。读取时也用DateTime.parse加本地时区明确指定,或者干脆自己写一个字符串拆解方法,避免隐式转换。我后来在数据访问层写了一个统一的日期工具类:DateTime parseLocalDate(String yyyyMMdd) { final parts = yyyyMMdd.split('-'); return DateTime(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2])); }这个方法的含义很明确:我只关心年、月、日三个数字,不关心时分秒和时区。之后所有日期比较、日期键生成,都只用这个方法打底的DateTime,再没出现过偏移问题。7.2 日历网格滚动与内存问题日历在PageView中滑动时,如果一次构建几十个甚至上百个包含完整GridView的页面,内存会暴涨。我在调试中发现,滑动快速翻月时,内存增长非常明显,而且隔几页后界面就卡顿。原因是我一开始把月份页做成了“一个完整的GridView直接放在PageView里”,并没有做缓存限制。解决办法:使用GridView.builder,确保只有可见的格子才构建;在月份页外包一层RepaintBoundary,隔离重绘边界;限制PageView的allowImplicitScrolling为false,避免它为了平滑跳转而预加载太多页面。做了这三个优化后,内存占用趋于平稳,连续滑动几十个月也没有明显掉帧。7.3 OpenHarmony平台特有问题OpenHarmony和Android/iOS最大的不同在于渲染管线、字体管理和安全区处理。最明显的问题是:设备上方有状态栏或挖孔区域时,日历页面的顶部会被遮挡,导致月份标题看不见。这个我通过MediaQuery.paddingOf(context)读取系统安全区后,在顶部加了一个Padding。这一点在三方库上其实更容易踩坑,因为你不知道它内部有没有处理安全区。自绘方案下,我非常清楚地知道自己界面上哪个区域属于危险区,手动调整起来也更直接。另一个问题是某些字体在OpenHarmony上渲染异常,比如数字和中文字符混排时的宽度不一致。我给日历格子里的数字设置了固定字号和字体宽度约束,用FontFeature.tabularFigures()让所有数字宽度一致,这样日期数字对齐更美观,也避免选中背景圆环和数字的位置错乱。7.4 常见问题速查表问题主要原因解决方案日历显示的日期比录入日期早一天DateTime.parse默认按UTC解析统一用DateTime(y, m, d)构造本地日期快速翻月时页面空白或卡顿PageView预加载过多限制allowImplicitScrolling,使用RepaintBoundary页面顶部被状态栏遮挡未处理安全区用MediaQuery.paddingOf添加顶部Padding点击日期时触发翻页手势竞争配置手势竞技场,区分横向滑动和纵向点击统计金额和日历标记不一致删除记录后未同步内存映射数据变更后统一触发ChangeNotifier刷新数据库查询慢未建索引或全表扫描给purchase_date建索引,并坚持范围查询这个表格只是我实际遇到的几类问题,这里我多说一句:排查时养成“先看数据层再看UI层”的思维习惯,很多日期错位问题不在Widget代码,而在数据存取时的类型转换上。7.5 我最后补上的一个经验日历视图做完以后,我把整个页面放在OpenHarmony的真机上跑了几天,记录、翻页、跨月、统计这些操作都正常。但有一次我发现,隔了几天再次打开App,日历上的标记还是老数据,因为App重启后我忘了重新查询日历当前月份的数据。处理方式是在日历页的initState里主动加载一次当前月份记录,并且监听App生命周期,从前台恢复时再次刷新。这个东西说起来很小,但如果没有做,用户会觉得“保存了怎么不显示”,体验非常差。所以日历这种有“时间维度”的界面,初始化加载一定不能偷懒。最后分享一点心得:日历功能真正的难点从来不是画格子,而是把日期计算、数据存储、状态同步、平台差异这些底层逻辑理顺。只要这些基础扎实了,后续再加什么年视图、周视图、保修提醒,都是在一个稳定的框架上填新内容。家具购买记录这个场景我目前做完了日历与明细联动,后面还打算把“保修到期提醒”和“按品类统计报表”加上,到时候再写一篇完整的补充。
做Flutter for OpenHarmony的家具购买记录App,是我最近一个月推进的一个业余项目。说实话,日历视图这种界面,在很多人的眼里就是“画个格子、放几个数字”,可真把它塞进一个需要实际记录购买数据的App里,涉及的日期计算、数据聚合、滚动复用、平台适配,每一环都能给你挖坑。这篇就把我从环境配置到日历落地、再到问题排查的完整过程写下来,给想在这个生态里做Flutter开发的同学一条可以直接参考的路径。这个App解决的是很现实的问题:家里装修或者添置家具时,购买记录散落在聊天记录、订单截图和一张张收据里,想查保修期、想统计某一时段花了多少钱,常常对不上账。日历视图在这里承担的,就是“时间索引”的角色——用户不需要翻列表,看日历上的标记就能知道哪天买过东西,点下去又能看到当天明细。这个需求听起来简单,但要把流畅交互、数据持久化和平台差异都处理好,工作量一点都不小。如果你正准备在一个非传统Flutter平台上落地自己的项目,或者正在纠结“日历到底自绘还是用现成库”,这篇能帮你少走不少弯路。我把方案选型、数据模型、网格计算、交互实现、存储查询、坑点排查全部摊开来讲,代码层面的关键节点也贴出来了,可以照着改。1. 项目背景与日历视图的设计思考1.1 家具购买记录管理的真实痛点家具购买和日常网购的区别在于:单个物品价值高、决策周期长、后续维护需求强。一张床垫能用十年,一台电视的保修可能就两年,用户真正需要的不是“买了什么东西”的流水账,而是“什么时候买的、花了多少钱、保修到什么时候、找谁售后”这样一套完整档案。我最初想的是做一个普通的列表页,按时间倒序展示记录,字段包含家具名称、类别、价格、购买日期、购买地点、备注等。但列表页有一个天然的问题:当用户有五十条乃至上百条记录时,时间维度很难快速感知。用户常常会问:上个月我到底买了什么?6月份那批沙发总价多少?这种问题用列表回答很吃力,但日历视图可以非常直观地回答——一眼看到哪些日期有购买记录,点一下就是当天明细,滑动一下就是下一个月的聚合。所以这个App里日历不是“装饰”,它是整个购买记录模块的入口之一。设计上我坚持两点:日历必须和记录数据双向联动,新增记录后日历标记要即时刷新;日历本身要轻,不能让用户觉得“看个日历还要等半天”。1.2 日历视图的核心设计目标在动手写代码前,我先把日历视图的需求拆成了四个目标:以月为单位展示自然日期,包含上月末补位和下月初补位;有购买记录的日期显示圆点标记,多记录不重复计数;点击某一天可以查看并快捷新增当天的购买明细;月份切换流畅,不能有卡片卡顿或数据错乱。这四个目标看着简单,实际做的时候会发现:每个目标背后都有一堆细节。比如“有记录的日期显示圆点”,这个“哪一天”到底按什么时区算?用户在美国凌晨买的东西,切换到中国时区会不会变成昨天?再比如月份切换流畅,如果每次翻页都把所有Widget重建一遍,在低端设备上必定掉帧。所以我在写第一行界面代码之前,先把数据层和日历计算层的关系理清了。理清关系之后,我就放弃了“照着其他项目抄一个日历”的想法,决定自己实现一套。原因很简单:第三方日历库确实成熟,但大多数是针对Android/iOS的Widget体系设计的,在OpenHarmony这套平台上,我验证后发现部分渲染效果和触摸事件处理跟预期有差异;而自绘方案虽然前期代码多一点,但所有行为都是可控的,后续加“周维度聚合”“保修提醒”这些功能时,扩展空间也大。2. 环境搭建与日历方案选型2.1 Flutter for OpenHarmony 的环境准备要点先说说环境。Flutter for OpenHarmony并不是官方分支直接能跑的,需要切换到社区维护的OpenHarmony适配版本。我的做法是在一台Linux构建机上,拉取社区适配分支的源码,然后手动编译出可用的Flutter SDK。编译本身不复杂,但有几个点容易卡人:必须安装OpenHarmony的SDK,并配置好HarmonyOS相关的环境变量;项目的Gradle和AGP版本要与OpenHarmony工具链对齐,否则构建HAP包时会报各种版本冲突;构建命令不是常规的flutter build apk,而是用OpenHarmony适配分支提供的flutter build hap之类指令。我第一次构建时就是没注意版本对齐,卡在Gradle下载依赖上浪费了半天。后来我把整个构建环境固定成一套已知可用的组合,再不动它。这点很重要:在非主流Flutter平台上开发,环境一旦跑通,就尽量不要频繁升级工具链,否则很容易引入不可控的问题。2.2 日历组件选型:为什么不直接用现成的三方库在日历组件选型上,我专门做了一个小对比,说实话也纠结了很久。现成的三方日历库,比如知名的table_calendar,功能确实全面,支持多种日历格式、事件标记、手势拖拽选中。但它内部依赖了大量Material组件,并且对日期格式化、国际化、动画调度有自己的实现逻辑。在OpenHarmony上运行,我发现它带来的适配工作量比预期高:某些点击动画不跟随触摸位置,部分区域刷新也不彻底。如果项目是快速原型,用三方库没有问题;但作为需要长期维护、还要对接本地存储的实战项目,我宁愿把控制权拿在自己手里。自绘方案的优势也很明确:可以严格按设计稿输出视觉效果,不受库的默认样式约束;只实现当前需要的交互,代码量反而比引入一个通用库更小;数据模型完全由自己定义,日历组件和业务层之间通过接口交互,调试时不用翻库源码。所以我最终选择了自绘。但自绘不是什么都不用管,它需要你把日历计算逻辑写对,把复用策略想清楚,这也是本文后面要展开的重头戏。3. 家具购买记录的数据模型设计3.1 记录实体建模:字段怎么定日历视图只是表象,真正撑起它的是数据模型。家具购买记录不是简单的“名称+价格”,我设计了这样的一个实体:class PurchaseRecord { final int id; final String furnitureName; final String category; // 床、沙发、茶几、灯具等 final String brand; final double price; final DateTime purchaseDate; // 只保留年月日,见下方时区说明 final String storeName; final String notes; final int warrantyMonths; // 保修时长,方便做提醒 }字段里的purchaseDate是使用最多的查询条件,我强制只保留年月日,不保留时分秒。这背后的原因很简单:日期视图需要的是“日”粒度,时间戳带小时分钟反而容易引起边界问题。我在第7节会详细展开这个坑。记录和日历标记的关系是一对多的:一天可能买了两张椅子、一张书桌,那么在日历中这个日期只显示一个标记,但点击进去能看到多条记录。所以日历标记的数据结构我采用了“日期字符串 -> 记录列表”的聚合映射,而不是让日历直接遍历全部记录。3.2 按月聚合的查询边界日历翻页是按“月”为单位的,所以我需要高效的按月查询。SQL配合时间范围查询是最直接的方式:SELECT * FROM purchase_records WHERE purchase_date >= date(?) AND purchase_date < date(?, '+1 month') ORDER BY purchase_date DESC;这样做的好处是数据库只返回当前月份的数据,只要给purchase_date字段建好索引,查询压力很小。数据量就算到几千条,日历翻页也是毫秒级响应。在实际代码里,我会用一个CalendarRepository来封装查询逻辑。它对外暴露两个方法:queryByMonth(DateTime month)和queryByDay(DateTime day)。页面拿到当月数据后再构建标记映射,这样数据层和UI层解耦,后续换存储方案也不用改界面。4. 日历网格的实现细节4.1 每月首日星期与天数的动态计算日历网格的核心是计算逻辑。一个月的第一天是星期几、这个月有多少天,这两个值决定了网格的排布。在Dart里,计算某月的天数我习惯用这种方式:int daysInMonth(DateTime month) { return DateTime(month.year, month.month + 1, 0).day; } int firstWeekdayOfMonth(DateTime month) { // DateTime(month.year, month.month).weekday // 注意:Dart中weekday从周一到周日是1到7 return DateTime(month.year, month.month, 1).weekday; }DateTime(year, month + 1, 0)这个写法可以“往回借一天”,因为dart的DateTime允许month超范围自动进位/退位。这里有个细节:如果我需要周一到周日排列,首日星期值firstWeekday直接对应第几列起步;如果需要周日到周六排列,还要做一次取模转换。我自己的App里使用了国内常见的“周一作为一周第一天”,所以起步列就是firstWeekday - 1。除了知道第一天和总天数,还要计算上月末补位和下月初补位。假设某月1号是周三,按每周7天排列,从周一开始的话,网格前两格要放上个月倒数第一天和倒数第二天。这个补位数量就是firstWeekday - 1。末尾补位数量则是(firstWeekday - 1 + daysInMonth) % 7,如果结果不为0,就需要补充7 - 余数个下月初的日期。补位的数据虽然展示的是上个月或下个月的日期,但点击后同样可以跳转到对应日期并展示记录。这样用户在日历中可以无缝跨月查看,不用强制回到正确月份。4.2 连续月份模型的构建与复用日历做成左右滑动切换月份时,最容易踩的坑就是每次滑动都“重新计算一个新月份,然后重新构建一堆Widget”。滑动速度稍微快一点,界面就会出现空白或者错位。我用的是PageView + 一个连续的月份数据模型。思路是:不管用户往哪边滑,始终维护一个“当前可见月份前后各若干月”的数据列表,月份本身是线性递增的序列,不是每次从零生成。class MonthData { final int year; final int month; final int leadingCount; // 上月末补位天数 final int dayCount; // 当前月总天数 final int trailingCount; // 下月初补位天数 }初始时构建当前月数据,再额外生成左侧和右侧各一个月的MonthData。PageView的初始页设置为1,向左滑减一,向右滑加一。构建MonthData的成本极低,真正有消耗的是生成一个个日期单元格的Widget。对于单元格Widget,我用的是GridView.builder,让系统只构建当前视口内可见的单元。配合RepaintBoundary,每个日期格子被独立绘制,翻页时临近月份的格子可以复用,显著降低了重绘面积。4.3 事件标记与每日购买记录的关联标记逻辑是:把当日记录列表映射到一个布尔值,有记录就画一个圆点,没有就不画。这个映射在数据层加载当月记录完成后构建一次。我用的映射键是格式化的日期字符串,格式是yyyy-MM-dd。关键点在于,这个格式化必须和数据库查询、UI展示用同一个时间源,不能做隐式时区转换。Dart的DateTime如果带时区信息,toString()输出的字符串在跨时区传参会出问题,所以我干脆在构造时就统一成“本地日期的年月日零点”。final String key = '${date.year.toString().padLeft(4, '0')}-' '${date.month.toString().padLeft(2, '0')}-' '${date.day.toString().padLeft(2, '0')}';构建标记映射以后,每个格子只需要一个Set<String>的查找操作,O(1)复杂度,就算画面里有几百个格子,性能也完全撑得住。5. 交互与状态处理5.1 点击日期查看当天明细日历格子不只是展示,还需要响应点击。我在每个格子外面套了一个GestureDetector,点击后回调事件,把选中的日期传给父组件。状态管理上我没有引入特别重的框架,只用了ValueNotifier<DateTime?>来做选中日期的状态,配合ValueListenableBuilder刷新底部明细区域。这样做的好处是:日历翻页时不需要重建整个页面,选中的日期变化只会触发明细区域的重绘。点击日期后,底部会弹出一个明细面板,展示当天的所有购买记录。面板不是全屏,大约是屏幕的40%,可以上下拖拽调整高度。为了方便快速记录新家具,面板顶部还有一个“添加入口”,点击后弹出表单,填完保存后日历的标记立刻更新。这个交互流程我做了很多次手感调试:面板拖拽阻尼、阴影层级、背景遮罩透明度。Flutter默认的BottomSheet在OpenHarmony上表现基本可用,但手势冲突处理需要自己写。我的方案是明细面板使用DraggableScrollableSheet,但给它设置一个独立的ScrollController,避免它和外部PageView的横向滑动竞争。5.2 月份切换动画与惯性滑动月份切换我用PageView实现,这是Flutter里最自然的方式。为了让切换有“翻月”的视觉暗示,我在每个月份页的顶部显示“YYYY年M月”,中间是7列网格。这里有一个性能关键点:PageView默认会用setState触发重建当前页,如果你把月份页写成一个大的build方法,里面再嵌套多个GridView.builder,很容易导致整个页面重建。我的做法是把月份页拆成两个独立的Widget:一个是头部月份标签,一个是表格区域,各自内部的状态独立管理。月份标签跟随PageView控制器变化,表格区域则只负责展示当前页的数据。动画方面我加了一个透明度渐入渐出,幅度很小,避免用户觉得拖沓。实测下来,在中等配置的设备上滑动非常流畅,没有出现白屏或掉帧。5.3 多选状态与统计联动日历视图还有一个很实用的功能:按住某个日期后拖拽,可以连续选择多天,底部自动汇总这些天的购买总额。比如用户想统计“6月1日到6月10日买了多少钱”,直接拖选就能看到总和。这样比手动翻开每一天要高效得多。多选状态的维护我用了一个Set<DateTime>,选择逻辑是:按下时记录起点,拖动经过的每一天都加入集合,松手时停止。这个功能的实现依赖每个格子的handling事件,但要注意不能和PageView的横向滑动冲突。我通过手势竞技场(GestureArena)的配置,让纵向拖拽优先响应多选,横向滑动优先响应翻页,两者互不干扰。底部统计栏展示的是选中天数、记录条数和总金额,数据来源是之前构建的“日期字符串 -> 记录列表”映射。由于映射已在内存中,统计过程不需要重新查库,响应用户操作的速度非常快。6. 本地存储与查询优化6.1 本地存储选型:为什么用SQLite家具购买记录这种数据,量级通常就是几千条,SQLite绰绰有余。但OpenHarmony平台上,官方维护的一些插件版本可能没有直接适配,这时候就需要靠FFI方式运行SQLite。我选择的方案是用sqflite_common_ffi,它通过Dart FFI直接调用SQLite的C库,跨平台一致性很好,不依赖系统原生插件。在OpenHarmony上验证下来,整体稳定性和性能都不错。初始化逻辑:import 'package:sqflite_common_ffi/sqflite_ffi.dart'; void initDatabase() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; }然后创建数据库和表:CREATE TABLE purchase_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, furniture_name TEXT NOT NULL, category TEXT, brand TEXT, price REAL NOT NULL, purchase_date TEXT NOT NULL, store_name TEXT, notes TEXT, warranty_months INTEGER ); CREATE INDEX idx_purchase_date ON purchase_records(purchase_date);purchase_date字段存储的是yyyy-MM-dd格式的字符串,也就是我在3.1节说的日期键。这个设计直接和日历的标记映射键对齐,查询和映射之间的转换几乎为零。6.2 按月查询的性能优化细节按月查询最主要的挑战是:不要让SQL去全表扫描。解决办法是给purchase_date建索引,然后查询时用范围条件。Future<List<PurchaseRecord>> queryByMonth(DateTime month) async { final start = '${month.year.toString().padLeft(4, '0')}-' '${month.month.toString().padLeft(2, '0')}-01'; final end = DateTime(month.year, month.month + 1, 1); final endStr = '${end.year.toString().padLeft(4, '0')}-' '${end.month.toString().padLeft(2, '0')}-01'; final result = await db.query( 'purchase_records', where: 'purchase_date >= ? AND purchase_date < ?', whereArgs: [start, endStr], orderBy: 'purchase_date DESC', ); return result.map(PurchaseRecord.fromMap).toList(); }这里用>= start AND < end而不是BETWEEN,是因为日期字符串比较时,BETWEEN的边界容易出问题。比如2024-06-30和2024-07-01之间,用< 2024-07-01能精确取到整个6月,但用BETWEEN '2024-06-01' AND '2024-06-30'会把末尾日期漏掉的风险。因为30号这个字符串本身是包含在内的,所以理论上也可以,但为了统一,我习惯用左闭右开。实测数据量在两千条左右时,查询基本在10毫秒以内,日历翻页完全感觉不到延迟。6.3 记录增删改查的完整联动新增或修改购买记录后,日历标记需要立刻反映出来。我用的思路是:写入数据库成功后,把当前月份的记录重新加载一次,更新内存中的映射。这个“重新加载当月”的操作很轻,即便数据库在OpenHarmony上,也只是一次预编译查询。为了减少不必要的重建,我引入了ChangeNotifier来管理记录集合。日历页监听这个通知,只有当前月份数据发生变化时才重绘网格。如果一个记录跨月修改,比如把购买日期从6月改到7月,通知会触发两个月份的数据刷新,但界面层面只更新对应的月份页。删除记录同理,删除成功后从本地映射中移除对应条目,同时刷新UI。这里有个小细节:因为数据库外键关系很简单,我没有做级联删除,所有引用都是应用层维护的,所以删除记录后要确保日历的圆点标记和统计金额都同步更新,不能只删数据库里的行。7. 常见问题与排查实录7.1 日期偏移和时区陷阱这应该是整个项目里最典型的问题。用户在中国添加一条记录,选择的是“2024年6月10日”,但显示在日历上却变成了6月9日。排查后发现问题出在DateTime的构造方式上:如果使用DateTime.parse('2024-06-10'),它默认当作UTC时间解析,而后端或本地时区转换时偏移了8小时,导致显示成了前一天。解决方法是:所有业务层日期都用“构造本地日期”的方式,而不是字符串解析。即先用DateTime(2024, 6, 10)创建一个本地时间,再把它格式化为yyyy-MM-dd存储。读取时也用DateTime.parse加本地时区明确指定,或者干脆自己写一个字符串拆解方法,避免隐式转换。我后来在数据访问层写了一个统一的日期工具类:DateTime parseLocalDate(String yyyyMMdd) { final parts = yyyyMMdd.split('-'); return DateTime(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2])); }这个方法的含义很明确:我只关心年、月、日三个数字,不关心时分秒和时区。之后所有日期比较、日期键生成,都只用这个方法打底的DateTime,再没出现过偏移问题。7.2 日历网格滚动与内存问题日历在PageView中滑动时,如果一次构建几十个甚至上百个包含完整GridView的页面,内存会暴涨。我在调试中发现,滑动快速翻月时,内存增长非常明显,而且隔几页后界面就卡顿。原因是我一开始把月份页做成了“一个完整的GridView直接放在PageView里”,并没有做缓存限制。解决办法:使用GridView.builder,确保只有可见的格子才构建;在月份页外包一层RepaintBoundary,隔离重绘边界;限制PageView的allowImplicitScrolling为false,避免它为了平滑跳转而预加载太多页面。做了这三个优化后,内存占用趋于平稳,连续滑动几十个月也没有明显掉帧。7.3 OpenHarmony平台特有问题OpenHarmony和Android/iOS最大的不同在于渲染管线、字体管理和安全区处理。最明显的问题是:设备上方有状态栏或挖孔区域时,日历页面的顶部会被遮挡,导致月份标题看不见。这个我通过MediaQuery.paddingOf(context)读取系统安全区后,在顶部加了一个Padding。这一点在三方库上其实更容易踩坑,因为你不知道它内部有没有处理安全区。自绘方案下,我非常清楚地知道自己界面上哪个区域属于危险区,手动调整起来也更直接。另一个问题是某些字体在OpenHarmony上渲染异常,比如数字和中文字符混排时的宽度不一致。我给日历格子里的数字设置了固定字号和字体宽度约束,用FontFeature.tabularFigures()让所有数字宽度一致,这样日期数字对齐更美观,也避免选中背景圆环和数字的位置错乱。7.4 常见问题速查表问题主要原因解决方案日历显示的日期比录入日期早一天DateTime.parse默认按UTC解析统一用DateTime(y, m, d)构造本地日期快速翻月时页面空白或卡顿PageView预加载过多限制allowImplicitScrolling,使用RepaintBoundary页面顶部被状态栏遮挡未处理安全区用MediaQuery.paddingOf添加顶部Padding点击日期时触发翻页手势竞争配置手势竞技场,区分横向滑动和纵向点击统计金额和日历标记不一致删除记录后未同步内存映射数据变更后统一触发ChangeNotifier刷新数据库查询慢未建索引或全表扫描给purchase_date建索引,并坚持范围查询这个表格只是我实际遇到的几类问题,这里我多说一句:排查时养成“先看数据层再看UI层”的思维习惯,很多日期错位问题不在Widget代码,而在数据存取时的类型转换上。7.5 我最后补上的一个经验日历视图做完以后,我把整个页面放在OpenHarmony的真机上跑了几天,记录、翻页、跨月、统计这些操作都正常。但有一次我发现,隔了几天再次打开App,日历上的标记还是老数据,因为App重启后我忘了重新查询日历当前月份的数据。处理方式是在日历页的initState里主动加载一次当前月份记录,并且监听App生命周期,从前台恢复时再次刷新。这个东西说起来很小,但如果没有做,用户会觉得“保存了怎么不显示”,体验非常差。所以日历这种有“时间维度”的界面,初始化加载一定不能偷懒。最后分享一点心得:日历功能真正的难点从来不是画格子,而是把日期计算、数据存储、状态同步、平台差异这些底层逻辑理顺。只要这些基础扎实了,后续再加什么年视图、周视图、保修提醒,都是在一个稳定的框架上填新内容。家具购买记录这个场景我目前做完了日历与明细联动,后面还打算把“保修到期提醒”和“按品类统计报表”加上,到时候再写一篇完整的补充。