数据层是应用的脊柱。本文逐方法解析 E-Brufen 核心数据仓库 MoodStorage 的完整实现,涵盖架构设计、边界处理、性能考量与常见陷阱。


目录

  1. 架构总览
  2. 模型层:MoodType 与 MoodEntry
  3. 初始化与 ID 恢复
  4. Insert:创建记录
  5. GetAll:全量读取与类型安全
  6. 条件查询:getByDate 与 getByWeek
  7. Update:更新记录
  8. Delete:删除记录
  9. 统计聚合:getWeeklyMoodCounts
  10. 辅助方法:_parse 与 _mondayOf
  11. UI 集成:MoodPicker 与 MoodChart
  12. 性能考量
  13. 常见陷阱与最佳实践
  14. 小结

一、架构总览

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 层做枚举到字符串的映射。
  • fromValueorElse: () => 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 序列化,原因有三:

  1. 可调试性:Hive 文件可以用任何文本编辑器打开查看,出问题时直接读 .hive 文件即可定位。
  2. 迁移灵活:JSON 字段增删只需修改 toJson/fromJson,无需重新生成 Adapter。
  3. 鸿蒙兼容: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,这意味着它可以被 ListenableBuildercontext.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 的空安全考量

_boxBox?,因为它在 init() 之后才赋值。使用 _box?.put(...) 而非 _box!.put(...) 是一种静默容错:如果有人在 init() 之前误调用 insert(),不会崩溃——只是数据丢失。

更严格的做法是使用 assert(isReady) 或抛出异常,让调用方明确知道错误。但本项目选择静默容错,因为:

  1. insert 总是由 UI 触发,UI 在 isReady 为 true 前不会显示交互控件。
  2. init() 的 Future 回调链中调用是安全的。
  3. 避免在发布版本中因时序问题崩溃。

五、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?

因为 mondayDateTime(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 是纯键值存储,不支持按字段值查询。所有条件过滤都在内存中完成。对于情绪日记的数据量级(周级几十条,年级数百条),这是完全合理的。

如果数据量增长到数万条级别,可以考虑:

  1. 按日期建立多级 Box(如 moods_2026_07
  2. 使用 Hive 的 HiveList(已废弃)
  3. 切换到 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 已存在则覆盖,否则新增。这意味着 insertupdate 在底层是同一个操作——区别仅在于 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 原则。但实际上,_parsefromJson 多了一层保护——它在调用 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_yearjiffy 等包提供 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() 都执行一次排序。getByDategetByWeek 内部调用 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 行代码,但它展示了数据层设计的多个关键原则:

  1. 关注点分离:模型层(MoodEntry/MoodType)只管数据结构,数据层(MoodStorage)只管存取逻辑,存储层(Hive)只管持久化。三层各司其职,互不越界。
  2. 防御性编程:从 _box == null 的空安全检查,到 key is int / raw is String 的类型守卫,到 MoodType.fromValue 的 orElse 回退——每一层都有容错机制。在本地存储这种"不可控"的环境中,数据损坏是迟早的事,防御性代码决定了应用是优雅降级还是直接崩溃。
  3. 边界处理是区分"能用"与"好用"的分水岭subtract(Duration(seconds: 1)) 只花了 30 个字符,但它解决了 isAfter 边界排斥的问题。大多数 Bug 都藏在边界里。
  4. 简洁胜过复杂:Hive 只有 put/get/delete 三个方法,所有条件查询都在内存中完成。对于情绪日记的数据规模,这种设计完美够用。过度设计(如引入 SQLite 只是为了写 3 条 WHERE 子句)只会增加维护负担和跨平台障碍。
  5. ChangeNotifier 是最简单的响应式方案:一行 notifyListeners() 无需任何状态管理框架,就能让 UI 随数据变化自动更新。在小型应用中,这比 Provider/Riverpod/Bloc 更务实。

作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐