时间线是情绪日记最核心的浏览方式。本文将深入剖析如何用纯 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,它承载了渲染所需的所有字段:
dart
// mood_entry.dart
enum MoodType {
angry(1, '😡', '生气'),
sad(2, '😢', '难过'),
tired(3, '😴', '疲惫'),
calm(4, '😐', '平静'),
happy(5, '😊', '开心');
final int value; // 数字编码,用于统计图表 Y 轴映射
final String emoji; // 列表渲染的视觉锚点(32px)
final String label; // 中文标签,ListTile 的 title
const MoodType(this.value, this.emoji, this.label);
}
class MoodEntry {
final int? id;
final MoodType moodType;
final String? note; // 可选备注,最多 500 字
final DateTime createdAt;
final DateTime updatedAt;
// ... copyWith / toJson / fromJson 省略
}
设计要点:
MoodType的value字段赋予每种情绪一个 1-5 的数值,为图表统计(本周心情曲线)提供了数值映射基础MoodEntry实现copyWith模式,保证编辑操作不可变更新,避免直接修改原始对象导致状态不可追踪note为可空字段------用户可以不写备注,此时 ListTile 的 subtitle 自动隐藏
三、存储层:Hive 键值存储
dart
// mood_storage.dart
class MoodStorage extends ChangeNotifier {
static const _boxName = 'moods';
Box? _box;
int _nextId = 1;
Future<void> init() async {
_box = await Hive.openBox(_boxName);
if (_box!.isNotEmpty) {
_nextId = _box!.keys.fold<int>(0,
(max, k) => k is int ? (k > max ? k : max) : max) + 1;
}
}
List<MoodEntry> getAll() {
if (_box == null) return [];
final entries = <MoodEntry>[];
for (final key in _box!.keys) {
if (key is int) {
final raw = _box!.get(key);
if (raw is String) entries.add(_parse(key, raw));
}
}
entries.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return entries;
}
}
为什么选 Hive 而非 sqflite? E-Brufen 同时面向鸿蒙平台,而 sqflite 依赖原生 SQLite,在 HarmonyOS 上兼容性存疑。Hive 是纯 Dart 实现,零原生依赖,天然跨平台兼容。每条记录以 JSON 字符串存入 Box,键为自增整数 ID。
getAll() 每次从 Box 全量读取并排序------这不是最优方案,但在日记场景(用户通常记录数在数百到数千条)下完全可行。如果未来需要分页,可以在存储层增加 getPage(offset, limit) 方法,配合时间线中的上拉加载更多。
⚠️ 重要前提 :
getAll()返回的列表已经按createdAt倒序排列(最新的在前)。这个顺序是整个分组算法的基石------Map 的插入顺序决定了月分组在 UI 中的展示顺序。
四、核心算法:O(n) Map-based 月分组
分组逻辑位于 _buildTimelineTab() 内部,仅 5 行代码:
dart
// diary_page.dart 第 216-220 行
final grouped = <String, List<MoodEntry>>{};
for (final m in _allMoods) {
final key = '${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), ...]
}
为什么不用第三方 SectionList? Flutter 生态中虽然有 sticky_headers、grouped_list 等包,但引入依赖会增加维护成本(版本冲突、鸿蒙兼容性未知、API 变更风险)。5 行手写分组代码零依赖、完全可控,符合 "够用就好" 的原则。
五、ListView.builder 渲染策略
dart
// diary_page.dart 第 222-251 行
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: grouped.length, // 以月份数作为 itemCount
itemBuilder: (context, index) {
final month = grouped.keys.elementAt(index);
final moods = grouped[month]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── 月份标题 ──
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(month, style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey,
)),
),
// ── 本月所有条目(展开映射为 Card 列表)──
...moods.map((mood) => Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Text(mood.moodType.emoji,
style: const TextStyle(fontSize: 32)),
title: Text(mood.moodType.label),
subtitle: mood.note != null && mood.note!.isNotEmpty
? Text(mood.note!,
maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
trailing: Text(
_formatDate(mood.createdAt),
style: const TextStyle(
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 字段分工
| 字段 | 内容 | 样式 | 作用 |
|---|---|---|---|
leading |
moodType.emoji |
fontSize: 32 |
视觉锚点,一眼识别情绪 |
title |
moodType.label |
默认 | 中文情绪名称 |
subtitle |
note (首行截断) |
maxLines: 1 |
备注预览,超长用 TextOverflow.ellipsis |
trailing |
格式化日期 | fontSize: 12, grey |
右上角辅助信息 |
Column 展开 vs 嵌套 ListView
这是关键架构决策:外层用 ListView.builder,每个 item 内部用 Column + 展开的 children。
dart
// ❌ 错误做法:嵌套 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 的日记场景不需要这种过度优化------简单方案的可维护性远胜于为假想的极端情况做防御性设计。
六、空状态设计
dart
// diary_page.dart 第 203-213 行
if (_allMoods.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('📝', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
Text('还没有记录,今天心情怎么样?',
style: AppTheme.greetingStyle),
],
),
);
}
设计思路:
- 大号 emoji(64px) 作为视觉焦点,降低空白页的冷感。选用 📝 而非 😊------强调"记录"行为而非默认情绪
- 引导性文案 使用
AppTheme.greetingStyle(22px, weight 600, 棕色#5D4037),与首页问候保持视觉一致性 - 暗示行动:文案是"今天心情怎么样?"而非"暂无数据"------引导用户去记录页签到,而非停留在空白时间线
- 空状态判断放在分组之前(
if (_allMoods.isEmpty)),避免对空列表执行无意义的分组计算
七、日期格式化
dart
// diary_page.dart 第 108-109 行
String _formatDate(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
// 输出示例:2026-07-06
不过度依赖 intl 包(避免引入国际化依赖),直接用字符串拼接。padLeft(2, '0') 保证月和日始终为两位数,生成整齐的 ISO 风格日期。
八、详情弹窗与编辑删除
时间线中的每条记录可点击,触发 showModalBottomSheet:
dart
// diary_page.dart 第 253-299 行
void _showMoodDetail(MoodEntry mood) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${mood.moodType.emoji} ${mood.moodType.label}',
style: const TextStyle(fontSize: 24)),
const SizedBox(height: 8),
Text(_formatDate(mood.createdAt),
style: const TextStyle(color: Colors.grey)),
if (mood.note != null && mood.note!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(mood.note!),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_editMood(mood); // 切换到记录 Tab,预填数据
},
icon: const Icon(Icons.edit),
label: const Text('编辑'),
),
TextButton.icon(
onPressed: () {
Navigator.pop(context);
if (mood.id != null) _deleteMood(mood.id!);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text('删除', 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 Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化。本项目开源在 AtomGit,使用 MIT 协议,项目地址:AtomGit - E-Brufen。