AtomGit Flutter 鸿蒙客户端:情绪日记的时间线实现

📅 2026/7/9 18:33:10
AtomGit Flutter 鸿蒙客户端:情绪日记的时间线实现
时间线是情绪日记最核心的浏览方式。本文将深入剖析如何用纯 Dart 实现按月分组的列表视图覆盖数据层到渲染层的完整链路。一、整体架构概览E-Brufen 的情绪日记模块采用了清晰的分层架构lib/ ├── models/mood_entry.dart # 数据模型MoodType 枚举 MoodEntry 类 ├── data/mood_storage.dart # 持久层Hive 键值存储鸿蒙兼容无需 SQLite ├── widgets/mood_picker.dart # 输入组件5 表情选择器 ├── widgets/mood_chart.dart # 统计组件本周心情折线图 └── pages/diary/diary_page.dart # 页面编排TabBarView 三页签DiaryPage内部使用TabBarView承载三个页签——记录、时间线、统计。其中时间线是本文的主角对应_buildTimelineTab()方法diary_page.dart 第 202-251 行。状态管理上页面通过MoodStorage继承ChangeNotifier获取数据在其addListener回调中触发setState重建 UI。每次数据变更增删改都会自动刷新时间线无需手动管理局部状态。二、数据模型MoodType 与 MoodEntry时间线的每条记录来源于MoodEntry它承载了渲染所需的所有字段// mood_entry.dartenumMoodType{angry(1,,生气),sad(2,,难过),tired(3,,疲惫),calm(4,,平静),happy(5,,开心);finalint value;// 数字编码用于统计图表 Y 轴映射finalStringemoji;// 列表渲染的视觉锚点32pxfinalStringlabel;// 中文标签ListTile 的 titleconstMoodType(this.value,this.emoji,this.label);}classMoodEntry{finalint?id;finalMoodTypemoodType;finalString?note;// 可选备注最多 500 字finalDateTimecreatedAt;finalDateTimeupdatedAt;// … copyWith / toJson / fromJson 省略}设计要点MoodType的value字段赋予每种情绪一个 1-5 的数值为图表统计本周心情曲线提供了数值映射基础MoodEntry实现copyWith模式保证编辑操作不可变更新避免直接修改原始对象导致状态不可追踪note为可空字段——用户可以不写备注此时 ListTile 的 subtitle 自动隐藏三、存储层Hive 键值存储// mood_storage.dartclassMoodStorageextendsChangeNotifier{staticconst_boxNamemoods;Box?_box;int _nextId1;Futurevoidinit()async{_boxawaitHive.openBox(_boxName);if(_box!.isNotEmpty){_nextId_box!.keys.foldint(0,(max,k)kisint?(kmax?k:max):max)1;}}ListMoodEntrygetAll(){if(_boxnull)return[];finalentriesMoodEntry[];for(finalkeyin_box!.keys){if(keyisint){finalraw_box!.get(key);if(rawisString)entries.add(_parse(key,raw));}}entries.sort((a,b)b.createdAt.compareTo(a.createdAt));returnentries;}}为什么选 Hive 而非 sqfliteE-Brufen 同时面向鸿蒙平台而 sqflite 依赖原生 SQLite在 HarmonyOS 上兼容性存疑。Hive 是纯 Dart 实现零原生依赖天然跨平台兼容。每条记录以 JSON 字符串存入 Box键为自增整数 ID。getAll()每次从 Box 全量读取并排序——这不是最优方案但在日记场景用户通常记录数在数百到数千条下完全可行。如果未来需要分页可以在存储层增加getPage(offset, limit)方法配合时间线中的上拉加载更多。⚠️重要前提getAll()返回的列表已经按createdAt倒序排列最新的在前。这个顺序是整个分组算法的基石——Map 的插入顺序决定了月分组在 UI 中的展示顺序。四、核心算法O(n) Map-based 月分组分组逻辑位于_buildTimelineTab()内部仅 5 行代码// diary_page.dart 第 216-220 行finalgroupedString,ListMoodEntry{};for(finalmin_allMoods){finalkey${m.createdAt.year}年${m.createdAt.month}月;grouped.putIfAbsent(key,()[]).add(m);}算法分析时间复杂度O(n)每条记录仅遍历一次。putIfAbsent是 O(1) 均摊add是 O(1)。空间复杂度O(n)额外存储一份分组后的引用MoodEntry 对象本身不复制仅复制引用。保持顺序Dart 的LinkedHashMap{}的默认实现保持插入顺序。因为_allMoods已按时间倒序所以首个遇到的条目决定了该月 key 的插入顺序——即月份从最新到最旧。分组后的数据结构示意{ 2026年7月: [MoodEntry(happy, 07-06), MoodEntry(calm, 07-05), ...], 2026年6月: [MoodEntry(sad, 06-28), ...], 2025年12月: [MoodEntry(tired, 12-31), ...] }为什么不用第三方 SectionListFlutter 生态中虽然有sticky_headers、grouped_list等包但引入依赖会增加维护成本版本冲突、鸿蒙兼容性未知、API 变更风险。5 行手写分组代码零依赖、完全可控符合 “够用就好” 的原则。五、ListView.builder 渲染策略// diary_page.dart 第 222-251 行returnListView.builder(padding:constEdgeInsets.all(16),itemCount:grouped.length,// 以月份数作为 itemCountitemBuilder:(context,index){finalmonthgrouped.keys.elementAt(index);finalmoodsgrouped[month]!;returnColumn(crossAxisAlignment:CrossAxisAlignment.start,children:[// ── 月份标题 ──Padding(padding:constEdgeInsets.symmetric(vertical:12),child:Text(month,style:constTextStyle(fontSize:16,fontWeight:FontWeight.bold,color:Colors.grey,)),),// ── 本月所有条目展开映射为 Card 列表──...moods.map((mood)Card(margin:constEdgeInsets.only(bottom:8),child:ListTile(leading:Text(mood.moodType.emoji,style:constTextStyle(fontSize:32)),title:Text(mood.moodType.label),subtitle:mood.note!nullmood.note!.isNotEmpty?Text(mood.note!,maxLines:1,overflow:TextOverflow.ellipsis):null,trailing:Text(_formatDate(mood.createdAt),style:constTextStyle(fontSize:12,color:Colors.grey)),onTap:()_showMoodDetail(mood),),)),],);},);渲染结构可视化┌─ 2026年7月 ───────────────────┐ │ ┌────────────────────────────┐ │ │ │ 开心 07-06 │ │ ← Card / ListTile │ └────────────────────────────┘ │ │ ┌────────────────────────────┐ │ │ │ 平静 07-05 │ │ │ └────────────────────────────┘ │ ├─ 2026年6月 ───────────────────┤ │ ┌────────────────────────────┐ │ │ │ 难过 今天下雨... 06-28│ │ ← 备注超出截断 │ └────────────────────────────┘ │ │ ┌────────────────────────────┐ │ │ │ 疲惫 06-25 │ │ ← 无备注时 subtitle 为 null │ └────────────────────────────┘ │ └─────────────────────────────────┘ListTile 字段分工字段内容样式作用leadingmoodType.emojifontSize: 32视觉锚点一眼识别情绪titlemoodType.label默认中文情绪名称subtitlenote(首行截断)maxLines: 1备注预览超长用TextOverflow.ellipsistrailing格式化日期fontSize: 12, grey右上角辅助信息Column 展开 vs 嵌套 ListView这是关键架构决策外层用ListView.builder每个 item 内部用Column 展开的children。// ❌ 错误做法嵌套 ListView → 滚动冲突、性能差// ListView.builder(// itemBuilder: (_, i) Column(children: [// MonthHeader(...),// ListView.builder(...) // 嵌套滚动// ]),// )// ✅ 正确做法展开为 Widget 列表// ListView.builder(// itemBuilder: (_, i) Column(children: [// MonthHeader(...),// ...moods.map((m) Card(...)), // 展开到同一 Column// ]),// )展开...moods.map(...)意味着每月的条目在 build 时一次性构造。对于每月通常不超过几十条的记录这完全可接受。如果单月有上千条记录可以改用二级ListView.builder并将itemCount设为总条目数在 items 中穿插月份标题。但 E-Brufen 的日记场景不需要这种过度优化——简单方案的可维护性远胜于为假想的极端情况做防御性设计。六、空状态设计// diary_page.dart 第 203-213 行if(_allMoods.isEmpty){returnCenter(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[constText(,style:TextStyle(fontSize:64)),constSizedBox(height:16),Text(还没有记录今天心情怎么样,style:AppTheme.greetingStyle),],),);}设计思路大号 emoji64px作为视觉焦点降低空白页的冷感。选用 而非 ——强调记录行为而非默认情绪引导性文案使用AppTheme.greetingStyle22px, weight 600, 棕色#5D4037与首页问候保持视觉一致性暗示行动文案是今天心情怎么样“而非暂无数据”——引导用户去记录页签到而非停留在空白时间线空状态判断放在分组之前if (_allMoods.isEmpty)避免对空列表执行无意义的分组计算七、日期格式化// diary_page.dart 第 108-109 行String_formatDate(DateTimed)${d.year}-${d.month.toString().padLeft(2, 0)}-${d.day.toString().padLeft(2, 0)};// 输出示例2026-07-06不过度依赖intl包避免引入国际化依赖直接用字符串拼接。padLeft(2, 0)保证月和日始终为两位数生成整齐的 ISO 风格日期。八、详情弹窗与编辑删除时间线中的每条记录可点击触发showModalBottomSheet// diary_page.dart 第 253-299 行void_showMoodDetail(MoodEntrymood){showModalBottomSheet(context:context,shape:constRoundedRectangleBorder(borderRadius:BorderRadius.vertical(top:Radius.circular(20)),),builder:(_)Padding(padding:constEdgeInsets.all(20),child:Column(mainAxisSize:MainAxisSize.min,crossAxisAlignment:CrossAxisAlignment.start,children:[Text(${mood.moodType.emoji}${mood.moodType.label},style:constTextStyle(fontSize:24)),constSizedBox(height:8),Text(_formatDate(mood.createdAt),style:constTextStyle(color:Colors.grey)),if(mood.note!nullmood.note!.isNotEmpty)...[constSizedBox(height:12),Text(mood.note!),],constSizedBox(height:16),Row(mainAxisAlignment:MainAxisAlignment.end,children:[TextButton.icon(onPressed:(){Navigator.pop(context);_editMood(mood);// 切换到记录 Tab预填数据},icon:constIcon(Icons.edit),label:constText(编辑),),TextButton.icon(onPressed:(){Navigator.pop(context);if(mood.id!null)_deleteMood(mood.id!);},icon:constIcon(Icons.delete,color:Colors.red),label:constText(删除,style:TextStyle(color:Colors.red)),),],),],),),);}编辑操作会关闭弹窗后自动切换到记录页签_tabController.animateTo(0)并预填选中日期的数据进行修改提供流畅的编辑体验。九、性能总结与优化方向维度当前方案适用场景优化方向如需分组算法O(n) Map 10,000 条无需优化渲染方式Column 展开每月 100 条二级 ListView.builder数据读取全量 getAll() 5,000 条分页查询 游标状态更新ChangeNotifier单页签更细粒度的通知当前方案的核心理念为一个清晰的边界场景日记记录通常几百到几千条选择最简单的实现而非为所有可能的极端情况做架构。这并非懒惰——恰恰是有经验的工程判断。十、完整代码清单时间线的完整实现仅 49 行_buildTimelineTab方法 202-251 行加上空状态判断和详情弹窗总计约 100 行 Dart 代码。代码集中在一个文件diary_page.dart阅读和维护成本极低。分组算法 5 行 渲染逻辑 30 行 空状态 10 行 详情弹窗 46 行 一个完整可用的情绪时间线。作者简介E-Brufen DevFlutter 鸿蒙开发者专注于跨平台移动应用开发与心理健康数字化。本项目开源在 AtomGit使用 MIT 协议项目地址AtomGit - E-Brufen。