AtomGit Flutter 鸿蒙客户端:MoodStorage 的 CRUD 设计与 Hive JSON 序列化
数据层是应用的脊柱。本文逐方法解析 E-Brufen 核心数据仓库 MoodStorage 的完整实现,涵盖架构设计、边界处理、性能考量与常见陷阱。
目录
- 架构总览
- 模型层:MoodType 与 MoodEntry
- 初始化与 ID 恢复
- Insert:创建记录
- GetAll:全量读取与类型安全
- 条件查询:getByDate 与 getByWeek
- Update:更新记录
- Delete:删除记录
- 统计聚合:getWeeklyMoodCounts
- 辅助方法:_parse 与 _mondayOf
- UI 集成:MoodPicker 与 MoodChart
- 性能考量
- 常见陷阱与最佳实践
- 小结
一、架构总览
E-Brufen 采用三层数据架构。MoodStorage 作为数据仓库(Repository),向上暴露响应式查询接口,向下屏蔽 Hive 存储细节。
┌──────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ MoodPicker │ │ MoodChart │ │ HomePage │ │
│ │ (表情选择器) │ │ (周柱状图) │ │ (主页面) │ │
│ └──────┬───────┘ └──────┬───────┘ └─────┬──────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ listen / notifyListeners │
├───────────────────────────┼──────────────────────────┤
│ Data Layer │
│ ┌────────────┴────────────┐ │
│ │ MoodStorage │ │
│ │ extends ChangeNotifier │ │
│ │ │ │
│ │ init() / insert() │ │
│ │ getAll() / getByDate() │ │
│ │ getByWeek() │ │
│ │ update() / delete() │ │
│ │ getWeeklyMoodCounts() │ │
│ └────────────┬────────────┘ │
│ │ │
├───────────────────────────┼──────────────────────────┤
│ Model Layer │
│ ┌────────────────────────┴───────────────────────┐ │
│ │ MoodType (enum) MoodEntry (data class) │ │
│ │ - angry/sad/tired - id / moodType / note │ │
│ │ - calm/happy - createdAt / updatedAt │ │
│ │ - fromValue() - toJson() / fromJson() │ │
│ └────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────┤
│ Storage Layer │
│ ┌────────────────────────────────────────────────┐ │
│ │ Hive Box ("moods") │ │
│ │ Key (int) ──► Value (String: JSON) │ │
│ │ 1 ──► '{"id":1,"mood_type":5,...}' │ │
│ │ 2 ──► '{"id":2,"mood_type":3,...}' │ │
│ │ ... │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
设计决策:为什么用 Hive 而非 SQLite?
| 维度 | Hive (hive_ce) | SQLite (sqflite) |
|---|---|---|
| 鸿蒙兼容 | 纯 Dart,天然支持 | 需要原生插件(鸿蒙不兼容) |
| 依赖 | 零原生依赖 | 需各平台 .so / .dll |
| 性能 | 键值对,极快 | 结构化查询,需解析 SQL |
| 迁移 | 添加字段无需 ALTER | Schema 变更需 migration |
| 查询能力 | 仅键值,需内存过滤 | 完整 SQL |
对于情绪日记这种小规模本地数据(日记录量个位数),Hive 的简洁性远胜 SQLite 的复杂性。
二、模型层:MoodType 与 MoodEntry

在深入数据仓库之前,先理解它操作的数据结构。
2.1 MoodType 枚举
/// 五种情绪类型,数值用于统计图 Y 轴映射
enum MoodType {
angry(1, '😡', '生气'),
sad(2, '😢', '难过'),
tired(3, '😴', '疲惫'),
calm(4, '😐', '平静'),
happy(5, '😊', '开心');
final int value;
final String emoji;
final String label;
const MoodType(this.value, this.emoji, this.label);
static MoodType fromValue(int v) => MoodType.values.firstWhere(
(m) => m.value == v,
orElse: () => MoodType.calm,
);
}
设计要点:
value(1—5):数值越大情绪越积极,可直接用于柱状图的 Y 轴映射,无需额外转换。emoji/label:UI 直接消费,避免在 Widget 层做枚举到字符串的映射。fromValue的orElse: () => MoodType.calm:当存储数据损坏时(如 value=99),回退到"平静",不会崩溃。这是一种防御性反序列化策略。
2.2 MoodEntry 数据类
class MoodEntry {
final int? id;
final MoodType moodType;
final String? note;
final DateTime createdAt;
final DateTime updatedAt;
const MoodEntry({
this.id,
required this.moodType,
this.note,
required this.createdAt,
required this.updatedAt,
});
// copyWith — 不可变更新模式
MoodEntry copyWith({...}) => MoodEntry(...);
// 序列化
Map<String, dynamic> toJson() => {
'id': id,
'mood_type': moodType.value,
'note': note,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
// 反序列化
factory MoodEntry.fromJson(Map<String, dynamic> json) => MoodEntry(
id: json['id'] as int?,
moodType: MoodType.fromValue(json['mood_type'] as int),
note: json['note'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
为什么 id 是可空的(int?)?
新建 MoodEntry 时,ID 尚未分配——这条记录还没有写入数据库。插入操作由 MoodStorage 分配 ID 后再注入 JSON。如果使用 int id(不可空),新建对象时就需要一个"虚拟 ID"如 -1,这会在代码中埋下隐患。
创建流程:
MoodEntry(id: null, moodType: happy, ...) ← UI 层创建,无 ID
│
▼
MoodStorage.insert(entry)
│
▼
id = _nextId++ ← 数据层分配 ID
data = entry.toJson() ← 序列化
data['id'] = id ← 注入 ID
box.put(id, jsonEncode(data))
│
▼
MoodEntry(id: 42, ...) ← 下次读取时,ID 已持久化
2.3 序列化策略:JSON 字符串
Hive 原生支持存储 Dart 对象(通过 TypeAdapter),但本项目选择手动 JSON 序列化,原因有三:
- 可调试性:Hive 文件可以用任何文本编辑器打开查看,出问题时直接读
.hive文件即可定位。 - 迁移灵活:JSON 字段增删只需修改
toJson/fromJson,无需重新生成 Adapter。 - 鸿蒙兼容:Hive 的代码生成(
hive_generator)依赖build_runner,在鸿蒙开发环境中可能遇到依赖链问题。手动序列化完全避开。
存储格式:
Key: 1 (int)
Value: '{"id":1,"mood_type":5,"note":"今天天气真好","created_at":"2026-07-06T09:30:00.000","updated_at":"2026-07-06T09:30:00.000"}'
三、初始化与 ID 恢复
MoodStorage 继承 ChangeNotifier,这意味着它可以被 ListenableBuilder 或 context.watch 监听。初始化是第一个关键步骤。
class MoodStorage extends ChangeNotifier {
static const _boxName = 'moods';
Box? _box;
int _nextId = 1;
bool get isReady => _box != null && _box!.isOpen;
Future<void> init() async {
_box = await Hive.openBox(_boxName);
// 恢复最大 ID
if (_box!.isNotEmpty) {
_nextId = _box!.keys.fold<int>(
0,
(max, k) => k is int ? (k > max ? k : max) : max,
) + 1;
}
}
}
3.1 为什么 _nextId = 1 而非 0?
因为 Hive Box 的 key 是 int 类型。如果 _nextId 从 0 开始,第一条记录的 key 就是 0。虽然技术上合法,但在某些边界场景(如 fold 的初始值 0)下容易产生歧义。从 1 开始是更安全的惯例。
潜在问题: 如果用户删除了 ID=5 的记录后又插入新记录,新记录会得到 ID=6(而不是复用 5)。这是有意为之——自增 ID 不应复用,否则外部引用(如通知、分享链接)会指向错误的数据。
3.2 ID 恢复算法详解
假设 Box 中存在 keys: [1, 3, 7, 2]
fold 执行过程:
Step 1: max=0, k=1 → k is int && 1>0 → max=1
Step 2: max=1, k=3 → k is int && 3>1 → max=3
Step 3: max=3, k=7 → k is int && 7>3 → max=7
Step 4: max=7, k=2 → k is int && 2>7 → max=7 (不变)
结果: _nextId = 7 + 1 = 8
3.3 健壮性分析
| 场景 | 行为 |
|---|---|
| 空 Box | _nextId 保持 1,正确 |
| 正常数据 | 遍历找最大值 +1,正确 |
| key 中包含非 int 脏数据 | k is int 过滤跳过 |
| 最大 ID=2^31-1 | 理论上溢出,实际不可能 |
| 删除所有记录后 | _nextId 不变,但 Box 为空不会再触发恢复 |
为什么不存一个"全局 max_id"元数据?
方案 A(当前):启动时遍历所有 key 取 max + 1
方案 B(备选):在 Box 中存一个 "__meta__" key,记录 max_id
方案 A 更健壮:
- 没有"元数据与实际数据不同步"的风险
- 即使 Hive 文件被手动编辑,也能正确恢复
- 代码更简单,少一个需要维护的状态
方案 A 的代价:
- 启动时需遍历所有 key → 对于情绪日记场景(数百条记录),O(n) 遍历可忽略不计
3.4 isReady getter 的用途
bool get isReady => _box != null && _box!.isOpen;
UI 层在 init() 完成前不应该尝试读写。通过这个 getter,Widget 可以显示加载状态或禁用交互:
// 在 HomePage 中
if (!storage.isReady)
return const Center(child: CircularProgressIndicator());
四、Insert:创建记录
/// 插入一条记录,返回 ID
Future<int> insert(MoodEntry entry) async {
final id = _nextId++;
final data = entry.toJson();
data['id'] = id;
await _box?.put(id, jsonEncode(data));
notifyListeners();
return id;
}
4.1 逐步拆解
┌─────────────────────────────────────────────────┐
│ Step 1: id = _nextId++ │
│ 原子地读取当前值并递增。单线程 Dart 无竞态。 │
├─────────────────────────────────────────────────┤
│ Step 2: data = entry.toJson() │
│ 调用模型层的序列化方法,得到 Map<String, dynamic>│
│ {"mood_type":5,"note":"...","created_at":"..."}│
├─────────────────────────────────────────────────┤
│ Step 3: data['id'] = id │
│ 将分配的 ID 注入 Map。注意:toJson() 中的 id │
│ 此时是 null(新建 entry 无 ID),需要覆盖。 │
├─────────────────────────────────────────────────┤
│ Step 4: _box?.put(id, jsonEncode(data)) │
│ key = id (int), value = JSON 字符串 │
│ _box? 是空安全操作——如果 init 未完成则静默跳过。 │
├─────────────────────────────────────────────────┤
│ Step 5: notifyListeners() │
│ 通知所有监听的 Widget 重新构建。 │
├─────────────────────────────────────────────────┤
│ Step 6: return id │
│ 调用方拿到 ID 后可以做后续操作(如跳转到详情页)。│
└─────────────────────────────────────────────────┘
4.2 为什么返回 Future<int>?
返回 ID 让调用方可以:
- 立即导航到详情页
- 在 SnackBar 中显示 “已保存(#42)”
- 传递给下一个异步操作(如关联照片)
4.3 _box?.put 的空安全考量
_box 是 Box?,因为它在 init() 之后才赋值。使用 _box?.put(...) 而非 _box!.put(...) 是一种静默容错:如果有人在 init() 之前误调用 insert(),不会崩溃——只是数据丢失。
更严格的做法是使用 assert(isReady) 或抛出异常,让调用方明确知道错误。但本项目选择静默容错,因为:
insert总是由 UI 触发,UI 在isReady为 true 前不会显示交互控件。- 在
init()的 Future 回调链中调用是安全的。 - 避免在发布版本中因时序问题崩溃。
五、GetAll:全量读取与类型安全
/// 获取所有记录,按时间倒序
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;
}
5.1 三层防御网
┌──────────────┐
│ _box == null │
│ return [] │ ← 第一层:Box 未初始化
└──────┬───────┘
│ Box 已就绪
┌──────▼───────┐
│ key is int │
│ 否则 skip │ ← 第二层:过滤非整数 key
└──────┬───────┘
│ key 确认为 int
┌──────▼───────┐
│ raw is String│
│ 否则 skip │ ← 第三层:过滤非字符串 value
└──────┬───────┘
│ 数据校验通过
┌──────▼───────┐
│ _parse() │ ← JSON 反序列化
│ (内部也有容错) │
└──────────────┘
为什么需要类型检查?
Hive 是一个无 schema 的键值存储。理论上 Box 可以包含任何类型的 key 和 value。如果一个 Bug(或手动编辑)导致存储了非预期类型的数据,类型检查能防止 _parse 收到垃圾数据而崩溃。
5.2 排序策略
entries.sort((a, b) => b.createdAt.compareTo(a.createdAt));
按 createdAt 降序排列(最新的在前),符合日记应用的直觉——用户最关心的是最近的记录。
时间复杂度:每次调用 getAll() 都会重新排序,复杂度 O(n log n)。详见性能考量。
5.3 为什么是同步方法?
getAll() 是同步的——它直接从内存中的 Hive Box 读取,不涉及 I/O。Hive 在 openBox 时将全部数据加载到内存(对于情绪日记这种小数据集完全可以接受),后续读写都是内存操作。
六、条件查询:getByDate 与 getByWeek
这是整个设计中最精妙的部分。
6.1 getByDate:按日查询
/// 获取指定日期的记录
List<MoodEntry> getByDate(DateTime date) {
final start = DateTime(date.year, date.month, date.day);
final end = start.add(const Duration(days: 1));
return getAll().where((e) =>
e.createdAt.isAfter(start.subtract(const Duration(seconds: 1))) &&
e.createdAt.isBefore(end)
).toList();
}
6.1.1 区间定义
时间轴 ────────────────────────────────────────────────────>
start - 1s start (00:00:00) end (次日 00:00:00)
│ │ │
├──────────────┼────────────────────────┤
│ │ ← 匹配区间 → │
│ │ │
▼ ▼ ▼
isAfter(...) records with isBefore(end)
createdAt in
this range
6.1.2 边界处理的精妙之处
e.createdAt.isAfter(start.subtract(const Duration(seconds: 1)))
为什么不直接用 isAtSameMomentAs 或 >=?
Dart 的 DateTime 没有 >= 运算符。比较方法只有:
isAfter(other)→ 严格大于isBefore(other)→ 严格小于isAtSameMomentAs(other)→ 严格等于compareTo(other)→ 返回 -1/0/1
要实现 >= 语义(包含边界),两种常见做法:
// 方案 A:用 compareTo
e.createdAt.compareTo(start) >= 0
// 方案 B:减 1 秒 + isAfter(本项目采用)
e.createdAt.isAfter(start.subtract(const Duration(seconds: 1)))
方案 B 的优势:
| 维度 | 方案 A (compareTo) | 方案 B (subtract 1s) |
|---|---|---|
| 可读性 | 需要理解 compareTo 返回值 | 直观:“在 start 之后(含边界)” |
| 与 isBefore 对称 | 不对称,一个用 compareTo 一个用 isBefore | 对称,都用 Duration 方法 |
| 精度风险 | 无(精确到微秒) | 极微 — 1 秒内的记录会重复 |
对于情绪日记场景,用户不会在同一秒内创建两条记录,1 秒的偏移完全安全。
6.2 getByWeek:按周查询
/// 获取指定周(周一~周日)的记录
List<MoodEntry> getByWeek(DateTime anyDay) {
final monday = _mondayOf(anyDay);
final sunday = monday.add(const Duration(days: 7));
return getAll().where((e) =>
e.createdAt.isAfter(monday.subtract(const Duration(seconds: 1))) &&
e.createdAt.isBefore(sunday)
).toList();
}
6.2.1 周的语义
传入 anyDay(如周三)
│
▼
Mon Tue Wed Thu Fri Sat Sun Mon(下周)
│ │
└──────────── 匹配区间 ────────────────────┘
monday sunday
(00:00:00) (00:00:00)
为什么 sunday = monday.add(Duration(days: 7)) 而不是 days: 6?
因为 monday 是 DateTime(2026, 7, 6)(假设 7 月 6 日是周一),monday.add(days: 7) 得到的是 7 月 13 日 00:00:00,正好是下周一零点。用它作为 isBefore 的上界,覆盖了周日的全部 24 小时。
monday (7/6 00:00:00) ──► sunday = monday + 7d = 7/13 00:00:00
匹配条件: isAfter(monday - 1s) && isBefore(sunday)
7/6 00:00:00 ✅ 匹配(isAfter 6/5 23:59:59)
7/12 23:59:59 ✅ 匹配(isBefore 7/13 00:00:00)
7/13 00:00:00 ❌ 不匹配(isBefore 失败)
6.3 为什么是在内存中过滤而非 Hive 查询?
Hive 是纯键值存储,不支持按字段值查询。所有条件过滤都在内存中完成。对于情绪日记的数据量级(周级几十条,年级数百条),这是完全合理的。
如果数据量增长到数万条级别,可以考虑:
- 按日期建立多级 Box(如
moods_2026_07) - 使用 Hive 的
HiveList(已废弃) - 切换到 Isar(不支持鸿蒙)
七、Update:更新记录
/// 更新记录
Future<void> update(int id, MoodEntry updated) async {
final data = updated.toJson();
data['id'] = id;
await _box?.put(id, jsonEncode(data));
notifyListeners();
}
7.1 与 Insert 的对比
Insert Update
────── ──────
id = _nextId++ id 由调用方提供
box.put(新 key, 新 value) box.put(已有 key, 新 value)
Hive: 新增记录 Hive: 覆盖记录
返回 id 返回 void
Hive 的 box.put(key, value) 是幂等的:如果 key 已存在则覆盖,否则新增。这意味着 insert 和 update 在底层是同一个操作——区别仅在于 key 的来源和 _nextId 的处理。
7.2 调用模式
// 在 UI 中编辑后更新
final updated = existingEntry.copyWith(
moodType: newMood,
note: newNote,
updatedAt: DateTime.now(),
);
await storage.update(existingEntry.id!, updated);
copyWith + update 的组合遵循不可变数据模式:不修改原对象,而是创建新对象并替换。这确保 UI 的状态管理是可预测的(Provider / Riverpod 可以正确检测到引用变化)。
7.3 notifyListeners 的时机
notifyListeners() 在 await _box?.put(...) 之后调用。这确保了:
- 如果写入失败(异常),UI 不会被错误刷新
- 写入成功后,监听者重新调用
getAll()获取的数据已包含更新
八、Delete:删除记录
/// 删除记录
Future<void> delete(int id) async {
await _box?.delete(id);
notifyListeners();
}
8.1 删除后的 ID 空洞
初始: [1, 2, 3, 4, 5]
删除 3: [1, 2, 4, 5] ← ID=3 永久消失
新增: [1, 2, 4, 5, 6] ← 新记录获得 ID=6,不复用 3
这是自增 ID 的标准行为。不复用已删除的 ID 可以避免引用歧义。
8.2 级联考虑
当前设计中,MoodEntry 没有关联其他实体(如照片、标签)。如果未来需要级联删除,可以在此方法中扩展:
Future<void> delete(int id) async {
await _box?.delete(id);
// 未来扩展:
// await photoBox?.deleteWhere((k, v) => v['mood_id'] == id);
notifyListeners();
}
九、统计聚合:getWeeklyMoodCounts
/// 本周各心情计数
Map<int, int> getWeeklyMoodCounts(DateTime anyDay) {
final rows = getByWeek(anyDay);
final counts = <int, int>{1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
for (final r in rows) {
counts[r.moodType.value] = (counts[r.moodType.value] ?? 0) + 1;
}
return counts;
}
9.1 预初始化所有键
final counts = <int, int>{1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
为什么不用空 Map?
如果某类情绪本周没有记录,它应该在结果中显示为 0。用空 Map 的话需要调用方自己处理缺失键:
// 不良:调用方需要 ?? 0
final angryCount = counts[1] ?? 0;
// 良好:counts 保证所有键存在
final angryCount = counts[1]!; // 安全,一定存在
9.2 数据流
getByWeek(anyDay)
│
▼
List<MoodEntry> rows ← 本周所有记录
│
▼
for each row:
counts[row.moodType.value]++
│
▼
Map<int, int> ← {1: 0, 2: 1, 3: 2, 4: 3, 5: 1}
│
▼
MoodChart widget ← 柱状图消费
十、辅助方法:_parse 与 _mondayOf
10.1 _parse:JSON 反序列化
MoodEntry _parse(int id, String raw) {
final map = jsonDecode(raw) as Map<String, dynamic>;
return MoodEntry(
id: map['id'] as int?,
moodType: MoodType.fromValue(map['mood_type'] as int),
note: map['note'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
}
为什么是私有方法(_parse)?
这个方法仅供 MoodStorage 内部使用。外部调用者不应该直接处理 JSON 字符串——他们总是通过 getAll()、getByDate() 等公共 API 获取已经解析好的 MoodEntry 对象。这是封装原则的体现。
为什么传 id 参数而非仅从 map['id'] 读取?
id 参数来自 _box!.keys(即 Hive 的 key),而 map['id'] 来自存储的 JSON 数据(value 内部)。理论上两者应该一致,但为了防御数据不一致(如手动编辑 Hive 文件),传入外部 key 作为权威来源:
// 如果 Hive key=5,但 JSON 内 'id': 3(数据损坏)
// MoodEntry.id 会使用 map['id'](因为 JSON 内的 id 字段优先)
// 但参数 id 提供了交叉验证的可能性
// 更严格的写法(如果要强制一致):
return MoodEntry(
id: id, // 使用 Hive key 而非 JSON 内的 id
...
);
当前实现选择了 JSON 内的 id,这在大多数情况下是正确的,且保持了与 MoodEntry.fromJson 的一致性。
为什么不用 MoodEntry.fromJson?
_parse 的逻辑和 MoodEntry.fromJson 几乎完全相同,这似乎违反了 DRY 原则。但实际上,_parse 比 fromJson 多了一层保护——它在调用 jsonDecode 之后才创建对象,而且有 id 参数可用于交叉验证。保持它们独立可以让各自的演进不影响对方。
10.2 _mondayOf:周一计算器
DateTime _mondayOf(DateTime d) {
return DateTime(d.year, d.month, d.day - (d.weekday - 1));
}
10.2.1 算法推导
Dart 的 DateTime.weekday 遵循 ISO 8601:
- Monday = 1
- Tuesday = 2
- …
- Sunday = 7
d.weekday - 1 = 从周一偏移的天数
示例(周三,d.weekday = 3):
d.day - (3 - 1) = d.day - 2 → 周一
示例(周一,d.weekday = 1):
d.day - (1 - 1) = d.day - 0 → 周一(当天)
示例(周日,d.weekday = 7):
d.day - (7 - 1) = d.day - 6 → 周一
示例(跨月,7月1日是周三,求周一的日期):
1 - (3 - 1) = -1 → DateTime(2026, 7, -1)
Dart 的 DateTime 构造函数自动处理负数天:
-1 天 = 6月30日 → 完全正确!
10.2.2 Dart DateTime 的跨月/跨年处理
DateTime(2026, 7, 0) → 2026-06-30 (第0天=上月最后一天)
DateTime(2026, 7, -1) → 2026-06-29 (负数天=上月倒数第N天)
DateTime(2026, 1, 1 - (4 - 1)) → DateTime(2026, 1, -2) → 2025-12-29 (跨年!)
Dart 的 DateTime 构造函数自动处理这些边界情况,无需手动计算月份和年份的进位/借位。这是一个被低估的特性。
10.2.3 为什么不用第三方库?
在 pub.dev 上有 week_of_year、jiffy 等包提供 startOfWeek() 方法。但在本项目中引入依赖仅为一行的日期计算是不合理的——这会增加构建时间、潜在的版本冲突和鸿蒙兼容性问题。
十一、UI 集成:MoodPicker 与 MoodChart
理解 MoodStorage 如何被 UI 消费,有助于把握整体数据流。
11.1 MoodPicker:写入端
// MoodPicker 的核心交互
GestureDetector(
onTap: () => onChanged(mood), // 回调传递 MoodType
child: AnimatedScale(
scale: isSelected ? 1.3 : 1.0,
child: Column(
children: [
Text(mood.emoji, style: TextStyle(fontSize: isSelected ? 44 : 36)),
Text(mood.label, ...),
],
),
),
)
数据流:
用户点击 emoji
│
▼
MoodPicker.onChanged(moodType)
│
▼
HomePage._onMoodSelected(moodType)
│
▼
storage.insert(MoodEntry(
moodType: moodType,
note: controller.text,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
))
│
▼
notifyListeners() → UI 自动重建
11.2 MoodChart:读取端
// MoodChart 消费 getByWeek/getAll 的数据
final dayCounts = <int, List<int>>{for (var i = 0; i < 7; i++) i: []};
for (final m in weekMoods) {
final dayIndex = m.createdAt.weekday - 1;
dayCounts[dayIndex]?.add(m.moodType.value);
}
MoodChart 接收 List<MoodEntry>(由 getByWeek 获得),计算每日平均情绪值,渲染彩色柱状图。颜色映射由 _colorForAvg 决定:
平均值 < 1.0 → 灰色 (无记录)
平均值 < 1.5 → 红色 (生气)
平均值 < 2.5 → 橙色 (难过)
平均值 < 3.5 → 琥珀色 (疲惫)
平均值 < 4.5 → 浅绿 (平静)
平均值 >=4.5 → 绿色 (开心)
11.3 响应式更新链路
┌──────────┐ insert/update/delete ┌──────────────┐
│ 用户操作 │ ──────────────────────► │ MoodStorage │
└──────────┘ │ (ChangeNot.) │
└──────┬───────┘
│ notifyListeners()
▼
┌──────────────┐
│ ListenableBu- │
│ ilder / watch │
└──────┬───────┘
│ rebuild
▼
┌──────────────┐
│ storage.get- │
│ All / getBy- │
│ Week(...) │
└──────┬───────┘
│ 最新数据
▼
┌──────────────┐
│ MoodChart │
│ (自动重绘) │
└──────────────┘
十二、性能考量
12.1 getAll() 的全量排序开销
List<MoodEntry> getAll() {
...
entries.sort((a, b) => b.createdAt.compareTo(a.createdAt)); // O(n log n)
return entries;
}
每次调用 getAll() 都执行一次排序。getByDate 和 getByWeek 内部调用 getAll() 后做内存过滤,这意味着每次查询都触发一次 O(n log n) 排序。
对于情绪日记场景的影响:
| 数据量 | 排序耗时(估算) | 影响 |
|---|---|---|
| 100 条 | < 0.1 ms | 不可感知 |
| 1,000 条 | ~1 ms | 几乎不可感知 |
| 10,000 条 | ~10 ms | 轻微延迟 |
| 100,000 条 | ~100 ms | 可感知卡顿 |
对于典型用户(每天 1-3 条记录,一年 365-1095 条),性能完全不是问题。
12.2 优化方向(如需扩展)
如果未来数据量增长,有几个优化方向:
方向 A:缓存排序结果
List<MoodEntry>? _cachedAll;
bool _dirty = true;
List<MoodEntry> getAll() {
if (!_dirty && _cachedAll != null) return _cachedAll!;
// ... 原有逻辑 ...
_cachedAll = entries;
_dirty = false;
return entries;
}
// 在 insert/update/delete 中设置 _dirty = true
方向 B:按日期分 Box
moods → 本周记录(热数据,快速访问)
moods_2026_06 → 2026年6月归档
moods_2026_05 → 2026年5月归档
...
查询时只加载需要的 Box,减少单次排序的数据量。
方向 C:预排序插入
维护一个按时间排序的 key 列表,插入时执行二分查找定位,查询时直接遍历。但 Hive 的 key 是无序的(LazyBox 遍历顺序不保证),这种方案在 Hive 上不可行。
12.3 Hive 内存模型
Hive 将所有数据加载到内存中。对于情绪日记应用这是优点——读操作是纯内存访问,零 I/O 延迟。但需要注意:
典型内存占用:
1000 条记录 × ~200 bytes/条(JSON 字符串 + Dart 对象)= ~200 KB
10000 条 → ~2 MB
足够安全
12.4 notifyListeners 的频率
当前设计中,每次 insert/update/delete 都调用 notifyListeners()。对于单条操作的场景这是合适的。如果未来支持批量导入,应注意:
// 不良:批量导入时每条都 notify
for (final entry in importedEntries) {
await storage.insert(entry); // 每次都 notify → UI 抖动
}
// 良好:批量导入后一次性 notify
// 需要添加一个 bulkInsert 方法
Future<void> bulkInsert(List<MoodEntry> entries) async {
for (final entry in entries) {
final id = _nextId++;
final data = entry.toJson();
data['id'] = id;
await _box?.put(id, jsonEncode(data));
}
notifyListeners(); // 只通知一次
}
十三、常见陷阱与最佳实践
13.1 陷阱一:忘记 notifyListeners
// ❌ Bug:数据写入了但 UI 不更新
Future<void> update(int id, MoodEntry updated) async {
await _box?.put(id, jsonEncode(data));
// 忘记调用 notifyListeners()!
}
// ✅ 正确
Future<void> update(int id, MoodEntry updated) async {
await _box?.put(id, jsonEncode(data));
notifyListeners();
}
调试技巧:如果 UI 在操作后没有更新,第一步就检查是否调用了 notifyListeners()。
13.2 陷阱二:DateTime 时区问题
// DateTime.now() 返回本地时间
// DateTime.parse("2026-07-06T09:30:00.000") 解析为 UTC 时间
// toIso8601String() 输出带时区的字符串
// ✅ 始终使用本地时间
final now = DateTime.now(); // 本地时间
final iso = now.toIso8601String(); // "2026-07-06T09:30:00.000" (本地时间)
// 跨时区使用时要注意一致性
E-Brufen 是离线应用,不涉及服务端交互,所以时区问题影响较小。但如果未来需要云同步,所有 DateTime 应统一为 UTC。
13.3 陷阱三:Hive Box 未关闭
void dispose() {
_box?.close();
super.dispose();
}
忘记关闭 Box 会导致:
- 内存泄漏(Box 持有的数据不会被 GC)
- 在部分平台上,Hive 文件可能未正确 flush 到磁盘
最佳实践:始终在 dispose() 中关闭 Box。
13.4 陷阱四:并发写入
// 同时发起两个 insert
await Future.wait([
storage.insert(entry1), // _nextId=5 → 得到 id=5
storage.insert(entry2), // _nextId=5 → 也得到 id=5???
]);
Dart 是单线程事件循环,_nextId++ 是原子操作,不会出现上述竞态。但如果使用 Isolate 进行并发操作,需要注意同步。
13.5 陷阱五:DateTime 构造函数在边界月份的行为
// _mondayOf 依赖 DateTime 的自动进位/借位
DateTime(2026, 3, 1 - (1 - 1)) = DateTime(2026, 3, 1) ✅
DateTime(2026, 3, 1 - (7 - 1)) = DateTime(2026, 3, -5) → DateTime(2026, 2, 23) ✅
DateTime(2026, 1, 1 - (7 - 1)) = DateTime(2026, 1, -5) → DateTime(2025, 12, 26) ✅
虽然 Dart 处理得很好,但保持这种意识——不是所有语言的日期库都有这个行为。
13.6 最佳实践清单
- ChangeNotifier 继承:使数据层可被 Widget 监听
- 防御性类型检查:
key is int/raw is String - 空安全导航:
_box?.put(...)而非_box!.put(...) - 自增 ID 不复用:删除记录的 ID 不分配给新记录
- 边界处理:
subtract(Duration(seconds: 1))处理 isAfter 边界 - 预初始化 Map 键:确保所有情绪类型在统计结果中都有值
- 资源释放:
dispose()中关闭 Box - 不可变模型:
copyWith而非直接修改字段 - 异步写入 + 同步读取:匹配 Hive 的 API 设计
- 手动 JSON 序列化:避免代码生成依赖,最大化跨平台兼容
十四、小结
MoodStorage 虽然只有 114 行代码,但它展示了数据层设计的多个关键原则:
- 关注点分离:模型层(MoodEntry/MoodType)只管数据结构,数据层(MoodStorage)只管存取逻辑,存储层(Hive)只管持久化。三层各司其职,互不越界。
- 防御性编程:从
_box == null的空安全检查,到key is int/raw is String的类型守卫,到MoodType.fromValue的 orElse 回退——每一层都有容错机制。在本地存储这种"不可控"的环境中,数据损坏是迟早的事,防御性代码决定了应用是优雅降级还是直接崩溃。 - 边界处理是区分"能用"与"好用"的分水岭:
subtract(Duration(seconds: 1))只花了 30 个字符,但它解决了isAfter边界排斥的问题。大多数 Bug 都藏在边界里。 - 简洁胜过复杂:Hive 只有
put/get/delete三个方法,所有条件查询都在内存中完成。对于情绪日记的数据规模,这种设计完美够用。过度设计(如引入 SQLite 只是为了写 3 条 WHERE 子句)只会增加维护负担和跨平台障碍。 - ChangeNotifier 是最简单的响应式方案:一行
notifyListeners()无需任何状态管理框架,就能让 UI 随数据变化自动更新。在小型应用中,这比 Provider/Riverpod/Bloc 更务实。
作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen。
更多推荐


所有评论(0)