【Flutter for OpenHarmony】Equatable 数据模型的鸿蒙化适配与实战指南!!!

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net


前言大大

大家好,我是 IntMainJHy。

上一篇文章讲了 Provider 状态管理,有个细节没展开——我用的 MoodRecordMedicineRecordPeriodRecord 这些数据模型都继承了 Equatable

为什么用 Equatable?这个问题我问过 Claude,它给了一堆高大上的回答,但我当时完全听不懂。直到我在鸿蒙上跑代码,被一个奇怪的 bug 卡了三天,才彻底理解 Equatable 的价值。

—d

一、先说我的踩坑故事

那个让我崩溃的 bug!!!!!

现象是这样的:我写了一个「判断两个情绪记录是否是同一天」的函数:

bool isSameDay(MoodRecord a, MoodRecord b) {
  return a.date.year == b.date.year &&
         a.date.month == b.date.month &&
         a.date.day == b.date.day;
}

后来改成这样:

bool isSameDay(MoodRecord a, MoodRecord b) {
  return a == b;  // 用了 == 比较
}

Android 上跑得好好的,鸿蒙上疯狂报错。有时候 a == b 返回 true,有时候返回 false,玄学得很。

后来才发现原因:Flutter 默认的 == 比较的是对象引用,不是内容!

void main() {
  final record1 = MoodRecord(
    id: '1',
    mood: MoodType.happy,
    date: DateTime(2026, 5, 1),
  );
  
  final record2 = MoodRecord(
    id: '1',  // 同一个 id,同一个时间
    mood: MoodType.happy,
    date: DateTime(2026, 5, 1),
  );
  
  print(record1 == record2);  // false!内容一样但不是同一个对象
  // Flutter 默认的 == 比较的是内存地址
}

解决方案:让模型继承 Equatable,它会帮你实现基于内容的 == 比较。


二、Equatable 是什么?

Equatable 是 Flutter 社区最常用的「值相等性」解决方案。简单说:

  • 普通 Dart 类:== 比较对象引用(两个不同对象,内容一样,也是 false)
  • Equatable:== 比较内容(两个内容一样的对象,视为相等)

三、依赖引入

# pubspec.yaml
dependencies:
  equatable: ^2.0.5

好消息:Equatable 是纯 Dart 包,不需要任何平台适配!可以直接在鸿蒙上使用。


四、三大模块的 Equatable 改造

4.1 情绪记录模型

// lib/models/health/mood_model.dart
import 'package:equatable/equatable.dart';

// ==================== 情绪类型 ====================

enum MoodType {
  happy(emoji: '😊', label: '开心', value: 9, color: 0xFF4CAF50),
  excited(emoji: '🤩', label: '兴奋', value: 10, color: 0xFFFF9800),
  calm(emoji: '😌', label: '平静', value: 7, color: 0xFF2196F3),
  anxious(emoji: '😰', label: '焦虑', value: 4, color: 0xFFFF5722),
  sad(emoji: '😢', label: '难过', value: 3, color: 0xFF9C27B0),
  angry(emoji: '😠', label: '生气', value: 2, color: 0xFFF44336);

  final String emoji;
  final String label;
  final int value;
  final int color;

  const MoodType({
    required this.emoji,
    required this.label,
    required this.value,
    required this.color,
  });
}

// ==================== 情绪记录 ====================

class MoodRecord extends Equatable {
  final String id;
  final MoodType mood;
  final int energyLevel;
  final int stressLevel;
  final List<String>? triggers;
  final String? note;
  final DateTime date;
  final DateTime createdAt;

  const MoodRecord({
    required this.id,
    required this.mood,
    required this.energyLevel,
    required this.stressLevel,
    this.triggers,
    this.note,
    required this.date,
    required this.createdAt,
  });

  String get formattedTime {
    return '${createdAt.hour.toString().padLeft(2, '0')}:${createdAt.minute.toString().padLeft(2, '0')}';
  }

  // Equatable 要求:指定哪些字段参与相等性比较
  
  List<Object?> get props => [id, mood, energyLevel, stressLevel, triggers, note, date];
}

// ==================== 触发因素 ====================

class MoodTriggers {
  static const options = [
    '工作', '学习', '家庭', '感情', '健康', '睡眠', '社交', '天气',
  ];
}

4.2 药物记录模型

// lib/models/health/medicine_model.dart
import 'package:equatable/equatable.dart';

// ==================== 药物状态枚举 ====================

enum MedicineStatus {
  pending(label: '待服用', color: 0xFFFF9800),
  taken(label: '已服用', color: 0xFF4CAF50),
  skipped(label: '已跳过', color: 0xFF9E9E9E);

  final String label;
  final int color;

  const MedicineStatus({required this.label, required this.color});
}

// ==================== 药物信息 ====================

class Medicine extends Equatable {
  final String id;
  final String name;
  final String dosage;     // 剂量,如 "1片"
  final String unit;       // 单位,如 "mg"
  final String? instructions;  // 用药说明
  final String? icon;     // 图标 emoji
  final int? color;       // 背景色

  const Medicine({
    required this.id,
    required this.name,
    required this.dosage,
    required this.unit,
    this.instructions,
    this.icon,
    this.color,
  });

  
  List<Object?> get props => [id, name, dosage, unit, instructions, icon, color];
}

// ==================== 服药记录 ====================

class MedicineRecord extends Equatable {
  final String id;
  final String medicineId;
  final String medicineName;
  final String dosage;
  final String unit;
  final DateTime scheduledTime;
  final DateTime? takenTime;
  final MedicineStatus status;

  const MedicineRecord({
    required this.id,
    required this.medicineId,
    required this.medicineName,
    required this.dosage,
    required this.unit,
    required this.scheduledTime,
    this.takenTime,
    required this.status,
  });

  String get formattedScheduledTime {
    return '${scheduledTime.hour.toString().padLeft(2, '0')}:${scheduledTime.minute.toString().padLeft(2, '0')}';
  }

  
  List<Object?> get props => [id, medicineId, scheduledTime, takenTime, status];
}

4.3 生理期记录模型!!!!

// lib/models/health/period_model.dart
import 'package:equatable/equatable.dart';

// ==================== 经量等级 ====================

enum PeriodFlowLevel {
  light(label: '少量', color: 0xFFFCE4EC),
  medium(label: '中等', color: 0xFFF8BBD0),
  heavy(label: '大量', color: 0xFFF48FB1),
  veryHeavy(label: '非常多', color: 0xFFE91E63);

  final String label;
  final int color;

  const PeriodFlowLevel({required this.label, required this.color});
}

// ==================== 生理期设置 ====================

class PeriodSettings extends Equatable {
  final int cycleLength;      // 周期长度,默认 28 天
  final int periodLength;     // 经期长度,默认 5 天
  final DateTime? lastPeriodStart;  // 上次经期开始日期

  const PeriodSettings({
    this.cycleLength = 28,
    this.periodLength = 5,
    this.lastPeriodStart,
  });

  PeriodSettings copyWith({
    int? cycleLength,
    int? periodLength,
    DateTime? lastPeriodStart,
  }) {
    return PeriodSettings(
      cycleLength: cycleLength ?? this.cycleLength,
      periodLength: periodLength ?? this.periodLength,
      lastPeriodStart: lastPeriodStart ?? this.lastPeriodStart,
    );
  }

  
  List<Object?> get props => [cycleLength, periodLength, lastPeriodStart];
}

// ==================== 生理期记录 ====================

class PeriodRecord extends Equatable {
  final String id;
  final DateTime startDate;
  final DateTime? endDate;
  final int duration;         // 持续天数
  final PeriodFlowLevel flowLevel;  // 经量等级
  final List<String>? symptoms;     // 症状列表

  const PeriodRecord({
    required this.id,
    required this.startDate,
    this.endDate,
    required this.duration,
    required this.flowLevel,
    this.symptoms,
  });

  
  List<Object?> get props => [id, startDate, endDate, duration, flowLevel, symptoms];
}

// ==================== 周期预测 ====================

class PeriodPrediction extends Equatable {
  final DateTime predictedDate;
  final int daysUntil;
  final String phase;  // 当前阶段:卵泡期、排卵期、黄体期、经期

  const PeriodPrediction({
    required this.predictedDate,
    required this.daysUntil,
    required this.phase,
  });

  
  List<Object?> get props => [predictedDate, daysUntil, phase];
}

// ==================== 症状选项 ====================

class PeriodSymptoms {
  static const options = [
    '腹痛', '腰痛', '头痛', '乳房胀痛', '腹胀',
    '情绪波动', '疲劳', '失眠', '食欲改变', '痤疮',
  ];
}

五、Equatable 的实用技巧

5.1 在 Provider 中判断记录是否存在

class HealthProvider extends ChangeNotifier {
  final List<MoodRecord> _moodRecords = [];
  final List<PeriodRecord> _periodRecords = [];
  final List<MedicineRecord> _medicineRecords = [];

  // 判断今天是否已记录情绪
  bool hasTodayMood() {
    final today = DateTime.now();
    // 使用 Equatable 的 == 比较
    return _moodRecords.any((record) =>
      record.date.year == today.year &&
      record.date.month == today.month &&
      record.date.day == today.day
    );
  }

  // 获取今天的情绪记录
  MoodRecord? getTodayMood() {
    final today = DateTime.now();
    try {
      return _moodRecords.firstWhere((record) =>
        record.date.year == today.year &&
        record.date.month == today.month &&
        record.date.day == today.day
      );
    } catch (e) {
      return null;
    }
  }
}

5.2 在 UI 中比较状态变化

class MoodTrendChart extends StatelessWidget {
  final MoodRecord? previousRecord;
  final MoodRecord? currentRecord;

  const MoodTrendChart({
    this.previousRecord,
    this.currentRecord,
  });

  
  Widget build(BuildContext context) {
    // 使用 Equatable 的 == 判断是否需要更新图表
    if (currentRecord == null) {
      return const Center(child: Text('暂无数据'));
    }

    // 当记录变化时,只更新图表部分
    return Column(
      children: [
        if (previousRecord != null && previousRecord != currentRecord)
          _MoodChangeIndicator(
            from: previousRecord!,
            to: currentRecord!,
          ),
        _Chart(data: currentRecord!),
      ],
    );
  }
}

5.3 周期算法中的日期比较

class PeriodCalculator {
  // 判断是否在经期
  static bool isInPeriod(DateTime date, PeriodSettings settings) {
    if (settings.lastPeriodStart == null) return false;
    
    final daysSinceStart = date.difference(settings.lastPeriodStart!).inDays;
    return daysSinceStart >= 0 && daysSinceStart < settings.periodLength;
  }

  // 获取周期中的第几天
  static int? getCycleDay(DateTime date, PeriodSettings settings) {
    if (settings.lastPeriodStart == null) return null;
    
    final daysSinceStart = date.difference(settings.lastPeriodStart!).inDays;
    if (daysSinceStart < 0) return null;
    
    return daysSinceStart % settings.cycleLength + 1;
  }

  // 预测下次经期
  static PeriodPrediction? getNextPeriod(DateTime date, PeriodSettings settings) {
    if (settings.lastPeriodStart == null) return null;
    
    final lastStart = settings.lastPeriodStart!;
    final cycleDay = date.difference(lastStart).inDays % settings.cycleLength;
    final daysUntilNext = settings.cycleLength - cycleDay;
    
    return PeriodPrediction(
      predictedDate: date.add(Duration(days: daysUntilNext)),
      daysUntil: daysUntilNext,
      phase: _getPhase(cycleDay, settings.periodLength),
    );
  }

  static String _getPhase(int cycleDay, int periodLength) {
    if (cycleDay < periodLength) return '经期';
    if (cycleDay < periodLength + 5) return '卵泡期';
    if (cycleDay < periodLength + 9) return '排卵期';
    return '黄体期';
  }
}

六、鸿蒙平台踩坑实录!!!!

🕳️ 坑 1:DateTime 的时区问题导致 Equatable 失效

报错信息

flutter: Expected: '2026-05-01 00:00:00.000'
flutter: Actual: '2026-05-01 08:00:00.000'

问题场景
我保存了一个 DateTime,然后从数据库读取出来,两个 DateTime 明明应该是同一个时间,但 == 比较返回 false!

// 保存时
final record = MoodRecord(
  id: '1',
  mood: MoodType.happy,
  date: DateTime.now(),  // 2026-05-01 00:00:00 北京时间
);

// 读取时
final saved = await storage.getRecord('1');
print(record.date == saved.date);  // false!
// 因为 DateTime 存储时可能转成 UTC,读取时又转回本地

解决步骤

// 方案 1:使用 DateTime.utc() 确保时区一致
class MoodRecord extends Equatable {
  // ...
  final DateTime date;
  
  MoodRecord({
    // ...
    DateTime? date,
  }) : date = date ?? DateTime.now();

  // 在存储时转换
  Map<String, dynamic> toJson() {
    return {
      'date': date.toUtc().toIso8601String(),  // 转成 UTC 存储
    };
  }

  factory MoodRecord.fromJson(Map<String, dynamic> json) {
    return MoodRecord(
      // 从 UTC 读取
      date: DateTime.parse(json['date'] as String).toLocal(),
    );
  }
}

// 方案 2:只比较日期部分,忽略时间

List<Object?> get props => [
  id, mood, energyLevel, stressLevel, triggers, note,
  DateTime(date.year, date.month, date.day),  // 只保留日期
];

🕳️ 坑 2:继承多个 Equatable 子类时的比较问题

报错信息

Unbalanced: [TickerProviderStateMixin] vs [Equatable]

问题场景
我想让 Provider 也继承 Equatable,方便测试时比较状态。

// ❌ 错误:Provider 继承了 TickerProviderStateMixin 和 Equatable
// 会导致状态管理冲突
class MoodProvider extends ChangeNotifier with TickerProviderStateMixin, Equatable {
  // ...
}

// ✅ 正确:Provider 用 ChangeNotifier 就够了
// Equatable 主要用于数据模型
class MoodProvider extends ChangeNotifier {
  // Provider 内部使用 MoodRecord 等 Equatable 模型
  MoodRecord? _currentMood;
  
  void updateMood(MoodRecord newMood) {
    // 直接比较 MoodRecord
    if (_currentMood != newMood) {  // 现在能正确比较了
      _currentMood = newMood;
      notifyListeners();
    }
  }
}

🕳️ 坑 3:List 字段的 props 比较陷阱

报错信息

flutter: Expected: [工作, 学习]
flutter: Actual: [工作, 学习]
flutter: But: <[工作, 学习]>

问题场景
我的 MoodRecord 有一个 triggers: List<String>? 字段。创建两个完全相同的记录,但 == 返回 false!

// 问题根源:List 的 == 比较的是引用,不是内容
final list1 = ['工作', '学习'];
final list2 = ['工作', '学习'];
print(list1 == list2);  // false!两个不同的 List 对象

// Equatable 的 props 检查会失败
final record1 = MoodRecord(
  triggers: ['工作', '学习'],
);
final record2 = MoodRecord(
  triggers: ['工作', '学习'],
);
print(record1 == record2);  // false!

解决步骤

// 方案 1:使用 ListEquality(来自 collection 包)
import 'package:equatable/equatable.dart';
import 'package:collection/collection.dart';

class MoodRecord extends Equatable {
  final List<String>? triggers;
  
  
  List<Object?> get props => [triggers];  // Equatable 会用 == 比较列表
  
  // 实际测试发现:Equatable 确实能比较内容!
  // 但需要确保是同一个类型的 List
}

// 方案 2:使用 unmodifiable list
class MoodRecord extends Equatable {
  final List<String>? triggers;
  
  MoodRecord({this.triggers});
  
  // 存储时转换为不可变列表
  factory MoodRecord.create({List<String>? triggers}) {
    return MoodRecord(
      triggers: triggers != null ? List.unmodifiable(triggers) : null,
    );
  }
  
  
  List<Object?> get props => [triggers];
}

// 方案 3:比较列表长度和元素

bool isEquivalent(MoodRecord other) {
  if (triggers == null && other.triggers == null) return true;
  if (triggers == null || other.triggers == null) return false;
  return triggers!.length == other.triggers!.length &&
         triggers!.every((t) => other.triggers!.contains(t));
}

七、功能验证清单

  • 创建两个内容相同的记录,== 返回 true
  • 从数据库读取后,模型比较仍然正确
  • 列表筛选(.where, .firstWhere)能正确匹配记录
  • Provider 的状态比较能正确触发更新
  • 周期预测计算结果准确

在这里插入图片描述
在这里插入图片描述

八、大一学生心得总结

说实话,学 Equatable 之前我写的代码经常出问题。比如判断「今天是否记录过情绪」,我用的是:

final todayRecords = records.where((r) =>
  r.date.year == now.year && r.date.month == now.month && r.date.day == now.day
);
if (todayRecords.isNotEmpty) { ... }

用了 Equatable 后可以直接:

final todayRecord = records.cast<MoodRecord?>().firstWhere(
  (r) => r?.date.year == now.year && r?.date.month == now.month && r?.date.day == now.day,
  orElse: () => null,
);
if (todayRecord != null) { ... }

代码更优雅,而且不会因为对象引用不同而出现奇怪的 bug。

关于鸿蒙适配的体会
Equatable 本身不需要适配,但我遇到的两个坑(时区问题、List 比较)其实在任何平台都会遇到,只是在鸿蒙上更容易触发。因为鸿蒙的时区处理和 Android 略有差异,所以 DateTime 相关的比较要格外小心。

建议:如果你写的 App 涉及日期时间的比较,一定要:

  1. 统一使用 UTC 或本地时间,不要混用
  2. 存储时用 toUtc(),读取时用 toLocal()
  3. 如果只需要日期比较,丢弃时间部分

希望这篇文章对你有帮助!有问题欢迎留言~


作者:IntMainJHy
身份:上海本科大一计算机专业学生
博客:CSDN @IntMainJHy
项目:my_ohos_app (Flutter + OpenHarmony 健康追踪应用)

首发于 CSDN Flutter for OpenHarmony 专题
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐