让用户拥有自己的数据——导出、分享与数据可移植性


目录

  1. 为什么用户需要数据导出
  2. 导出格式的选择:CSV vs JSON vs PDF
  3. 架构设计:导出功能的模块划分与数据流
  4. 数据源设计:从 Hive CE 中高效读取情绪记录
  5. CSV 导出:BOM 头处理与中文 Excel 兼容
  6. JSON 导出:结构化数据与格式化选项
  7. 日期范围筛选:按需导出的核心逻辑
  8. 系统分享面板:share_plus 的集成与鸿蒙适配
  9. E-Brufen 实战:完整导出功能实现
  10. UI 交互设计:导出入口与用户引导
  11. 隐私与安全考量
  12. 测试策略与边界条件
  13. 鸿蒙平台兼容性说明
  14. 小结

一、为什么用户需要数据导出

在这里插入图片描述

让我们从一个真实的用户故事开始。

小张是 E-Brufen 的早期用户,已经连续记录了 3 个月的情绪日记——120 多条记录,涵盖开心、焦虑、平静、疲惫等各种状态。有一天,她想把这些数据发给自己的心理咨询师,作为治疗参考。她打开应用,翻遍了所有页面,发现——没有导出按钮

这个场景揭示了一个深刻的命题:数据虽然存储在用户的设备上,但如果用户无法以可移植的格式获取这些数据,那么"拥有数据"就只是一句空话

1.1 数据导出的三个核心价值

数据导出功能不是可有可无的"锦上添花",而是现代应用的基础能力。它服务于三个核心需求:

价值维度 具体场景 用户诉求 技术挑战
数据备份 用户换手机、系统重置、应用卸载重装 保留历史数据,不丢失记录 文件格式需自描述,脱离应用也能读取
数据分析 用户想用 Excel 制作情绪趋势图表 导出为表格工具可打开的格式 CSV 需处理编码问题(中文 BOM)
数据迁移 用户想将数据导入到其他心理健康应用 跨应用的数据互操作 JSON 需要设计合理的 Schema,支持被其他应用解析

在 GDPR(欧盟通用数据保护条例)和《个人信息保护法》的框架下,数据可移植性(Data Portability)是用户的法定权利。作为应用开发者,我们有义务让用户能够以结构化、常用、机器可读的格式获取自己的数据。

1.2 E-Brufen 的数据特征

在设计导出方案之前,我们需要理解 E-Brufen 存储了什么数据:

字段 类型 示例 敏感程度
id int 42
mood_type int (1-5) 5 (开心) 中——反映情绪状态
mood_label String 开心
mood_emoji String 😊
note String? “今天完成了鸿蒙适配,心情不错” ——可能包含个人隐私
created_at DateTime 2026-07-15T14:30:00.000
updated_at DateTime 2026-07-15T14:35:00.000

可以看到,note 字段是隐私风险的核心来源——用户在笔记中可能记录任何内容,包括姓名、地点、健康状况等。这意味着我们在设计导出功能时,必须在便利性和安全性之间找到平衡


二、导出格式的选择:CSV vs JSON vs PDF

格式选择不是拍脑袋决定的。我们需要从用户场景、技术实现、文件体积、可读性四个维度进行综合评估。

2.1 三种格式的全面对比

维度 CSV JSON PDF
适合场景 Excel/WPS 数据分析 程序处理、API 导入 打印、分享给非技术人员
人类可读性 中等(需表格工具) 较好(文本编辑器即可) 优秀(任何 PDF 阅读器)
机器可读性 优秀(所有数据分析工具) 优秀(所有编程语言) 差(需 OCR 或专门解析)
数据结构支持 仅扁平表格 支持嵌套、数组、对象 无结构(视觉排版)
文件体积 小(纯文本) 中等(含结构标记) 大(含字体、布局信息)
中文兼容性 需 BOM 头处理 原生 UTF-8,无问题 需嵌入中文字体
流式生成 支持(逐行写入) 需完整构建后输出 需完整构建后输出
增量追加 支持 困难(需重写整个文件) 不可能
实现复杂度 低(字符串拼接) 中(需要 dart:convert) 高(需要 PDF 生成库)

2.2 我们的选择:CSV + JSON

基于以上分析,我们为 E-Brufen 选择了 CSV + JSON 双格式导出策略:

  • CSV:面向"用表格分析数据"的用户。例如,用户想用 Excel 画一张情绪波动折线图,CSV 是最直接的选择。每条情绪记录的 7 个字段展开为一行,日期、评分、标签一目了然。
  • JSON:面向"数据可移植性"。JSON 保留了完整的数据结构——包括 MoodType 的枚举值、emoji、标签三位一体的映射关系。当用户想将数据迁移到另一个支持情绪记录的应用时,JSON 提供了最完整的 Schema。

为什么不做 PDF:PDF 适合"漂亮的报告"场景(比如生成一份《2026 年 7 月情绪健康报告》),但它把数据"锁死"在了视觉格式里。接收方无法直接解析数据、无法导入自己的系统。在数据可移植性这个目标下,PDF 是反模式。如果未来需要 PDF 导出,它应该是一个独立的报告生成模块,而不是数据导出的一部分。


三、架构设计:导出功能的模块划分与数据流

在动手写代码之前,先设计清晰的数据流和模块边界。

3.1 整体数据流

┌─────────────────────────────────────────────────────────┐
│                       UI Layer                           │
│  ┌─────────────────────┐   ┌──────────────────────────┐ │
│  │  ExportSettingsSheet │   │   ExportProgressDialog   │ │
│  │  (格式选择 / 日期范围) │   │   (进度指示 / 取消)      │ │
│  └─────────┬───────────┘   └────────────┬─────────────┘ │
│            │ 用户选择                      │ 状态反馈      │
├────────────┼──────────────────────────────┼─────────────┤
│            ▼                              ▼              │
│                  Service Layer                           │
│  ┌──────────────────────────────────────────────────┐   │
│  │              DataExportService                    │   │
│  │  ┌──────────┐  ┌──────────┐  ┌───────────────┐  │   │
│  │  │DateFilter│  │CsvWriter │  │ JsonWriter    │  │   │
│  │  │模块      │  │模块      │  │ 模块          │  │   │
│  │  └──────────┘  └──────────┘  └───────────────┘  │   │
│  └──────────────────────┬───────────────────────────┘   │
│                         │                                │
├─────────────────────────┼────────────────────────────────┤
│                         ▼              Data Layer        │
│  ┌──────────────────────────────────────────────────┐   │
│  │              MoodStorage.getAll()                  │   │
│  │              (Hive CE Box)                         │   │
│  └──────────────────────────────────────────────────┘   │
│                         │                                │
├─────────────────────────┼────────────────────────────────┤
│                         ▼              Platform Layer    │
│  ┌──────────────────────────────────────────────────┐   │
│  │  path_provider          share_plus                │   │
│  │  (临时文件目录)          (系统分享面板)              │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

3.2 模块职责

模块 职责 输入 输出
ExportConfig 封装用户的导出配置 UI 交互结果 格式类型、日期范围、文件选项
DateRangeFilter 按日期范围筛选记录 List<MoodEntry> + 起止日期 筛选后的 List<MoodEntry>
CsvExporter 生成 CSV 文件 List<MoodEntry> File (CSV)
JsonExporter 生成 JSON 文件 List<MoodEntry> File (JSON)
FileSharer 调起系统分享面板 File 无(副作用:打开系统 UI)

这种模块划分遵循单一职责原则:每个类只做一件事。筛选逻辑不关心文件格式,文件写入不关心分享机制。这种设计在测试时也非常友好——每个模块可以独立进行单元测试。


四、数据源设计:从 Hive CE 中高效读取情绪记录

导出功能的基础是从存储层高效读取数据。E-Brufen 使用 Hive CE 作为本地数据库,所有的 MoodEntry 以 JSON 字符串的形式存储在 Box 中。

4.1 理解现有的数据模型

先回顾一下 E-Brufen 的数据模型:

/// 五种情绪类型,数值用于统计图 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);
}

/// 单条情绪日记记录
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,
  });
}

4.2 读取全部记录的性能考量

MoodStorage.getAll() 会遍历整个 Hive Box,对每条记录执行 jsonDecode。以 500 条记录为例,这个操作在主线程上的耗时约为 15-25ms(Hive CE 在鸿蒙设备上的实测数据),完全在用户可感知的延迟阈值(100ms)以内。

但如果用户的记录量达到 5000 条以上,单次 getAll() 的耗时可能上升到 150ms+。此时有两种优化策略:

  • 方案 A:将数据读取移到 Isolate 中执行(参考本系列 post-86 Dart Isolate 并发编程),避免阻塞 UI 线程。
  • 方案 B:分页读取,每次只读 200 条,渐进式写入文件。

对于 E-Brufen 的使用场景(一个轻量级情绪日记应用,大多数用户的记录量在 100-1000 条之间),getAll() 在主线程直接读取是完全足够的。但我们仍然在架构上预留了 Stream 接口,以便未来切换到异步处理。

4.3 构建导出数据的数据传输对象

直接使用 MoodEntry 作为导出数据源是可行的,但更好的实践是引入一个轻量的 DTO(数据传输对象),将数据库模型与导出格式解耦:

/// 导出用的数据传输对象,将 MoodEntry 转换为"扁平化"的导出格式
class MoodExportDto {
  final int id;
  final int moodValue;
  final String moodLabel;
  final String moodEmoji;
  final String note;
  final String createdAt;
  final String updatedAt;

  const MoodExportDto({
    required this.id,
    required this.moodValue,
    required this.moodLabel,
    required this.moodEmoji,
    required this.note,
    required this.createdAt,
    required this.updatedAt,
  });

  /// 从 MoodEntry 构建导出 DTO
  factory MoodExportDto.fromEntry(MoodEntry entry) {
    return MoodExportDto(
      id: entry.id ?? 0,
      moodValue: entry.moodType.value,
      moodLabel: entry.moodType.label,
      moodEmoji: entry.moodType.emoji,
      note: entry.note ?? '',
      createdAt: entry.createdAt.toIso8601String(),
      updatedAt: entry.updatedAt.toIso8601String(),
    );
  }

  /// 转换为 CSV 行(逗号分隔)
  String toCsvRow() {
    return [
      id.toString(),
      moodValue.toString(),
      _escapeCsvField(moodLabel),
      _escapeCsvField(moodEmoji),
      _escapeCsvField(note),
      createdAt,
      updatedAt,
    ].join(',');
  }

  /// 转换为 JSON Map
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'mood_type': moodValue,
      'mood_label': moodLabel,
      'mood_emoji': moodEmoji,
      'note': note,
      'created_at': createdAt,
      'updated_at': updatedAt,
    };
  }

  /// CSV 字段转义:如果包含逗号、换行或双引号,用双引号包裹
  static String _escapeCsvField(String field) {
    if (field.contains(',') || field.contains('\n') || field.contains('"')) {
      return '"${field.replaceAll('"', '""')}"';
    }
    return field;
  }
}

这个 DTO 有三个关键设计决策:

  1. 时间格式统一为 ISO 86012026-07-15T14:30:00.000。这是国际标准格式,Excel 和 Python 的 pandas 都能直接解析。
  2. moodValue 保留数值:CSV 用户可能需要用数值做统计分析(如计算平均情绪分)。
  3. moodLabel moodEmoji 同时保留:让导出的 CSV 在 Excel 中打开时既有可读的标签也有直观的 emoji。

五、CSV 导出:BOM 头处理与中文 Excel 兼容

CSV 看似简单——不就是逗号分隔的值吗?但一旦涉及中文,情况就复杂了。

5.1 中文 Excel 的编码陷阱

这个问题困扰了无数开发者:用 UTF-8 编码生成的 CSV 文件,在 Excel 中打开时中文变成乱码。

根本原因是:Windows 上的 Microsoft Excel 默认使用系统代码页(如 GBK/GB2312)打开 CSV,而不是 UTF-8。当 Excel 遇到 UTF-8 编码的中文时,它用 GBK 去解码,自然会出现乱码。

解决方案是在文件开头写入 UTF-8 BOM(Byte Order Mark)——三个字节 0xEF 0xBB 0xBF。BOM 告诉 Excel:“这个文件是 UTF-8 编码的”,Excel 就会用正确的编码打开。

5.2 CSV 导出器完整实现

import 'dart:convert';
import 'dart:io';

/// CSV 格式的情绪数据导出器
class CsvExporter {
  /// CSV 列标题(中文,适合 Excel 用户查看)
  static const List<String> _headers = [
    'ID',
    '情绪评分(1-5)',
    '情绪类型',
    '表情',
    '备注',
    '创建时间',
    '更新时间',
  ];

  /// 将情绪记录列表导出为 CSV 文件
  /// [entries] 需要导出的记录
  /// [outputPath] 输出文件的完整路径
  /// 返回生成的文件对象
  Future<File> export(
    List<MoodExportDto> entries, {
    required String outputPath,
  }) async {
    final file = File(outputPath);
    // 确保父目录存在
    await file.parent.create(recursive: true);

    final sink = file.openWrite(encoding: utf8);

    try {
      // ═══ 关键:先写入 UTF-8 BOM ═══
      // 不加这 3 个字节,中文在 Excel 中会乱码
      sink.add([0xEF, 0xBB, 0xBF]);

      // 写入 CSV 表头
      sink.writeln(_headers.join(','));

      // 逐行写入数据
      for (final entry in entries) {
        sink.writeln(entry.toCsvRow());
      }

      await sink.flush();
    } finally {
      await sink.close();
    }

    return file;
  }
}

几个值得注意的细节:

  1. 使用 openWrite 而非 writeAsStringopenWrite 返回一个 IOSink,支持流式写入。如果记录量很大(5000+条),流式写入可以避免在内存中构建完整的文件内容字符串。
  2. sink.add([0xEF, 0xBB, 0xBF]):这行代码写入 BOM。注意必须在 writeln 之前执行,因为 BOM 必须是文件的最开头三个字节。
  3. try/finally 确保 sink.close():即使写入过程中发生异常,也要关闭文件句柄,避免资源泄漏。

5.3 验证:生成的 CSV 文件预览

以 5 条实际的 E-Brufen 情绪记录为例,生成的 CSV 文件内容如下:

ID,情绪评分(1-5),情绪类型,表情,备注,创建时间,更新时间
1,5,开心,😊,今天解决了一个鸿蒙适配的 Bug,2026-07-10T09:30:00.000,2026-07-10T09:30:00.000
2,3,疲惫,😴,昨晚加班到很晚,2026-07-10T18:45:00.000,2026-07-10T18:45:00.000
3,4,平静,😐,中午冥想 15 分钟,2026-07-11T12:00:00.000,2026-07-11T12:00:00.000
4,5,开心,😊,AppGallery 审核通过了!,2026-07-12T10:15:00.000,2026-07-12T10:15:00.000
5,2,难过,😢,"今天心情不太好,想起了一些旧事",2026-07-13T20:00:00.000,2026-07-13T20:00:00.000

注意第 5 行的备注被双引号包裹了——这是因为 _escapeCsvField 检测到内容中有逗号,自动添加了转义。这确保了 CSV 解析器不会把"想起了一些旧事"拆成两列。

5.4 在 WPS 和 Numbers 上的兼容性

软件 平台 UTF-8 BOM CSV 备注
Microsoft Excel 2016+ Windows 完美 需 BOM
Microsoft Excel for Mac macOS 完美 自动检测 UTF-8
WPS Office Windows/鸿蒙 完美 对 BOM 支持良好
Apple Numbers macOS/iOS 完美 原生 UTF-8
Google Sheets Web 完美 导入时可指定编码
LibreOffice Calc Linux 完美 导入时可指定编码

在鸿蒙平台上,WPS Office 是主要的文档查看工具,其对 UTF-8 BOM CSV 的支持完全正常,中文不会出现乱码。


六、JSON 导出:结构化数据与格式化选项

JSON 导出比 CSV 复杂一些——我们需要决定导出的 JSON 结构、是否格式化(美化输出)、以及是否包含元数据。

6.1 JSON Schema 设计

一个好的导出 JSON 不仅要包含数据,还要让接收方能够理解数据的含义。我们设计的 Schema 如下:

{
  "export_info": {
    "app_name": "E-Brufen",
    "app_version": "1.0.0",
    "export_format": "mood-entries/v1",
    "export_date": "2026-07-15T14:30:00.000",
    "total_count": 120,
    "date_from": "2026-04-15T00:00:00.000",
    "date_to": "2026-07-15T23:59:59.999"
  },
  "mood_types": [
    {"value": 1, "label": "生气", "emoji": "😡"},
    {"value": 2, "label": "难过", "emoji": "😢"},
    {"value": 3, "label": "疲惫", "emoji": "😴"},
    {"value": 4, "label": "平静", "emoji": "😐"},
    {"value": 5, "label": "开心", "emoji": "😊"}
  ],
  "entries": [
    {
      "id": 1,
      "mood_type": 5,
      "mood_label": "开心",
      "mood_emoji": "😊",
      "note": "今天解决了一个鸿蒙适配的 Bug",
      "created_at": "2026-07-10T09:30:00.000",
      "updated_at": "2026-07-10T09:30:00.000"
    }
  ]
}

Schema 的设计要点:

  • export_info:元数据块,包含应用名称、版本、导出格式版本号、导出时间、记录数量、日期范围。接收方可以根据 export_format 字段判断是否兼容当前的数据结构。
  • mood_types:枚举映射表。这是一个关键设计——接收方(尤其是程序)看到 mood_type: 5 时,可以查表得知它代表"开心"。如果没有这个映射表,接收方就需要硬编码 1-5 的含义,这是不可靠的。
  • entries:实际的情绪记录数组。每条记录包含所有可用字段。

6.2 JSON 导出器实现

import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';

/// JSON 格式的情绪数据导出器
class JsonExporter {
  /// 是否格式化输出(美化缩进)
  final bool prettyPrint;

  const JsonExporter({this.prettyPrint = true});

  /// 将情绪记录列表导出为 JSON 文件
  Future<File> export(
    List<MoodExportDto> entries, {
    required String outputPath,
    DateTime? dateFrom,
    DateTime? dateTo,
  }) async {
    // 获取应用版本号
    final appVersion = await _getAppVersion();

    // 构建完整的导出数据结构
    final exportData = {
      'export_info': {
        'app_name': 'E-Brufen',
        'app_version': appVersion,
        'export_format': 'mood-entries/v1',
        'export_date': DateTime.now().toIso8601String(),
        'total_count': entries.length,
        'date_from': dateFrom?.toIso8601String() ?? entries.lastOrNull?.createdAt,
        'date_to': dateTo?.toIso8601String() ?? entries.firstOrNull?.createdAt,
      },
      'mood_types': MoodType.values.map((m) => {
            'value': m.value,
            'label': m.label,
            'emoji': m.emoji,
          }).toList(),
      'entries': entries.map((e) => e.toJson()).toList(),
    };

    final file = File(outputPath);
    await file.parent.create(recursive: true);

    // 格式化或紧凑输出
    final encoder = prettyPrint
        ? const JsonEncoder.withIndent('  ')  // 美化:2 空格缩进
        : const JsonEncoder();                 // 紧凑:一行 JSON

    final jsonString = encoder.convert(exportData);
    await file.writeAsString(jsonString, encoding: utf8);

    return file;
  }

  Future<String> _getAppVersion() async {
    try {
      // Android/iOS 用 PackageInfo,这里简化为从本地常量获取
      return '1.0.0';
    } catch (_) {
      return 'unknown';
    }
  }
}

6.3 紧凑模式 vs 美化模式

prettyPrint 参数控制两种输出模式:

特性 美化模式 (prettyPrint: true) 紧凑模式 (prettyPrint: false)
文件体积 较大(空格和换行) 最小(无多余空白)
人类可读 优秀(直接看) 差(需格式化工具)
网络传输 较慢(体积大) 快(体积小)
适用场景 用户本地查看、Git 提交 服务器存储、API 传输

对于 E-Brufen,默认使用美化模式——用户下载到本地后,用任何文本编辑器打开都能直接阅读。

6.4 JSON 与其他应用的互操作

一个设计良好的 JSON Schema 应该让其他应用能够低摩擦地导入。我们以一款假设的第三方心理健康应用"MindFlow"为例,说明 JSON 导入的逻辑:

// MindFlow 应用的导入逻辑(伪代码,说明互操作性)
Future<void> importFromEBrufen(String jsonFilePath) async {
  final content = await File(jsonFilePath).readAsString();
  final data = jsonDecode(content);

  // 检查格式版本兼容性
  final formatVersion = data['export_info']['export_format'];
  if (formatVersion != 'mood-entries/v1') {
    throw UnsupportedFormatException('不支持的导出格式:$formatVersion');
  }

  // 解析情绪类型映射
  final moodTypeMap = <int, String>{};
  for (final mt in data['mood_types']) {
    moodTypeMap[mt['value']] = mt['label'];
  }

  // 导入每条记录
  for (final entry in data['entries']) {
    await myDatabase.insertMood(
      moodLabel: entry['mood_label'],
      note: entry['note'],
      timestamp: DateTime.parse(entry['created_at']),
    );
  }
}

通过 export_format 字段的版本标识,接收方可以判断数据格式是否兼容,并在不兼容时给出明确的错误提示——这是健壮的数据互操作的基础。


七、日期范围筛选:按需导出的核心逻辑

不是每个用户都需要导出所有数据。“最近一个月的”、“2026 年上半年的”——这些都是常见的筛选需求。

7.1 筛选器的实现

/// 按日期范围筛选情绪记录
class DateRangeFilter {
  /// 从记录列表中筛选出指定日期范围内的记录
  /// [entries] 全部记录(已按时间排序)
  /// [from] 起始日期(含),null 表示不限制起始
  /// [to] 截止日期(含),null 表示不限制截止
  static List<MoodEntry> filter({
    required List<MoodEntry> entries,
    DateTime? from,
    DateTime? to,
  }) {
    if (from == null && to == null) {
      return entries; // 无筛选条件,返回全部
    }

    // 标准化为日期边界(起始日 00:00:00,截止日 23:59:59)
    final effectiveFrom = from != null
        ? DateTime(from.year, from.month, from.day)
        : null;
    final effectiveTo = to != null
        ? DateTime(to.year, to.month, to.day, 23, 59, 59)
        : null;

    return entries.where((entry) {
      if (effectiveFrom != null &&
          entry.createdAt.isBefore(effectiveFrom)) {
        return false;
      }
      if (effectiveTo != null &&
          entry.createdAt.isAfter(effectiveTo)) {
        return false;
      }
      return true;
    }).toList();
  }

  /// 获取数据的时间跨度信息(用于 UI 显示)
  static ({DateTime first, DateTime last, int totalDays}) getDateRange(
    List<MoodEntry> entries,
  ) {
    if (entries.isEmpty) {
      throw StateError('没有记录可用');
    }
    final sorted = List<MoodEntry>.from(entries)
      ..sort((a, b) => a.createdAt.compareTo(b.createdAt));

    final first = sorted.first.createdAt;
    final last = sorted.last.createdAt;
    final totalDays = last.difference(first).inDays + 1;

    return (first: first, last: last, totalDays: totalDays);
  }
}

7.2 常用筛选快捷选项

在 UI 上,我们可以提供几个快捷筛选选项,让用户不必手动选择日期:

/// 预设的日期范围选项
enum DateRangePreset {
  all('全部数据'),
  last7Days('最近 7 天'),
  last30Days('最近 30 天'),
  last90Days('最近 90 天'),
  thisMonth('本月'),
  thisYear('今年'),
  custom('自定义范围');

  final String label;
  const DateRangePreset(this.label);

  /// 根据预设生成日期范围
  ({DateTime from, DateTime to})? toDateRange() {
    final now = DateTime.now();
    final today = DateTime(now.year, now.month, now.day);

    switch (this) {
      case DateRangePreset.all:
        return null; // null 表示不筛选
      case DateRangePreset.last7Days:
        return (from: today.subtract(const Duration(days: 7)), to: today);
      case DateRangePreset.last30Days:
        return (from: today.subtract(const Duration(days: 30)), to: today);
      case DateRangePreset.last90Days:
        return (from: today.subtract(const Duration(days: 90)), to: today);
      case DateRangePreset.thisMonth:
        return (
          from: DateTime(now.year, now.month, 1),
          to: DateTime(now.year, now.month + 1, 0),
        );
      case DateRangePreset.thisYear:
        return (
          from: DateTime(now.year, 1, 1),
          to: DateTime(now.year, 12, 31),
        );
      case DateRangePreset.custom:
        return null; // 需要用户手动选择
    }
  }
}

7.3 筛选后的空数据处理

一个重要的边界条件:用户筛选的日期范围内可能没有任何记录。此时应该给出友好的提示,而不是生成一个空文件:

/// 验证导出是否有效
class ExportValidator {
  static ({bool valid, String message}) validate(
    List<MoodEntry> filteredEntries,
    ExportConfig config,
  ) {
    if (filteredEntries.isEmpty) {
      final range = config.dateFrom != null && config.dateTo != null
          ? '${_formatDate(config.dateFrom!)}${_formatDate(config.dateTo!)}'
          : '所选范围';
      return (
        valid: false,
        message: '$range 内没有情绪记录,请调整筛选条件后重试。'
      );
    }

    if (filteredEntries.length > 10000) {
      return (
        valid: false,
        message: '记录数量超过 10,000 条,建议分批导出以避免文件过大。'
      );
    }

    return (valid: true, message: '');
  }

  static String _formatDate(DateTime d) {
    return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
  }
}

八、系统分享面板:share_plus 的集成与鸿蒙适配

文件生成好了,下一步是让用户能够分享出去。在移动端,最自然的交互是调起系统分享面板——微信、邮件、文件管理器、蓝牙传输——用户自己决定把文件发送到哪里。

8.1 为什么选择 share_plus

share_plus 是 Flutter 社区最成熟的跨平台分享插件,它封装了 Android 的 Intent.ACTION_SEND 和 iOS 的 UIActivityViewController。对于鸿蒙平台,需要使用社区适配版 share_plus_ohos

pubspec.yaml 中添加依赖:

dependencies:
  share_plus: ^9.0.0
  path_provider: ^2.1.0
  # 鸿蒙平台使用以下适配版
  # share_plus_ohos: ^1.0.0

8.2 文件分享的实现

import 'dart:io';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';

/// 文件分享管理器
class FileSharer {
  /// 通过系统分享面板分享文件
  /// [file] 需要分享的文件
  /// [subject] 邮件主题(用于邮件分享渠道)
  Future<bool> shareFile({
    required File file,
    String? subject,
  }) async {
    try {
      // share_plus 的 shareXFiles 接受 XFile 列表
      final xFile = XFile(
        file.path,
        mimeType: _getMimeType(file.path),
      );

      await Share.shareXFiles(
        [xFile],
        subject: subject ?? 'E-Brufen 情绪数据导出',
        text: '这是我从 E-Brufen 导出的情绪记录数据。',
      );

      return true;
    } catch (e) {
      // 用户取消分享也会走到这里(正常行为,不是错误)
      if (e is Exception && e.toString().contains('cancel')) {
        return false;
      }
      rethrow;
    }
  }

  /// 根据文件扩展名返回 MIME 类型
  String _getMimeType(String path) {
    if (path.endsWith('.csv')) {
      return 'text/csv';
    } else if (path.endsWith('.json')) {
      return 'application/json';
    }
    return 'application/octet-stream';
  }
}

8.3 鸿蒙平台的特殊处理

share_plus 在鸿蒙平台上有几个需要注意的地方:

  1. 鸿蒙版 share_plus 对 shareXFiles 的支持可能有限。在鸿蒙上,一个更可靠的方案是先生成文件、然后通过鸿蒙的 Want(意图)机制直接打开分享:
/// 鸿蒙平台的降级分享策略
Future<void> shareFileHarmonyOS(File file) async {
  try {
    // 首先尝试标准 share_plus
    await Share.shareXFiles([XFile(file.path)]);
  } catch (e) {
    // 降级:将文件复制到用户可访问的目录,
    // 并引导用户手动分享
    final downloadsDir = Directory('/storage/media/100/local/files/Download');
    if (await downloadsDir.exists()) {
      final destPath = '${downloadsDir.path}/${file.uri.pathSegments.last}';
      await file.copy(destPath);
      // 提示用户:文件已保存到 Downloads 目录
    }
  }
}
  1. 鸿蒙的文件系统沙箱比 Android 严格。分享时需要使用应用沙箱内的临时目录或公共目录。path_provider 提供的 getTemporaryDirectory() 返回的是应用沙箱内的临时路径,这在鸿蒙上是完全安全的。

8.4 分享前清理临时文件

导出的临时文件在使用完毕后应该被清理,避免占用用户设备的存储空间:

/// 分享完成后清理临时文件
Future<void> shareAndCleanup({
  required File tempFile,
  required String subject,
}) async {
  final sharer = FileSharer();
  try {
    await sharer.shareFile(file: tempFile, subject: subject);
  } finally {
    // 无论分享成功还是取消,都清理临时文件
    if (await tempFile.exists()) {
      await tempFile.delete();
    }
  }
}

将清理逻辑放在 finally 块中,确保无论分享结果如何(成功、取消、异常),临时文件都会被删除。


九、E-Brufen 实战:完整导出功能实现

现在,我们把前面的所有模块串联起来,实现 E-Brufen 中完整的情绪数据导出功能。

9.1 导出配置数据类

/// 用户选择的导出配置
class ExportConfig {
  final ExportFormat format;        // CSV 或 JSON
  final DateRangePreset preset;     // 预设日期范围
  final DateTime? customDateFrom;   // 自定义起始日期
  final DateTime? customDateTo;     // 自定义截止日期
  final bool prettyPrint;           // JSON 是否美化输出

  const ExportConfig({
    required this.format,
    this.preset = DateRangePreset.all,
    this.customDateFrom,
    this.customDateTo,
    this.prettyPrint = true,
  });

  /// 获取有效的日期范围
  ({DateTime from, DateTime to})? get effectiveDateRange {
    if (preset == DateRangePreset.custom) {
      if (customDateFrom != null && customDateTo != null) {
        return (from: customDateFrom!, to: customDateTo!);
      }
      return null;
    }
    return preset.toDateRange();
  }
}

enum ExportFormat { csv, json }

9.2 核心导出服务

import 'dart:io';
import 'package:path_provider/path_provider.dart';

/// 数据导出服务——将所有模块串联起来
class DataExportService {
  final MoodStorage _storage;
  final CsvExporter _csvExporter = CsvExporter();
  final JsonExporter _jsonExporter = JsonExporter();
  final FileSharer _sharer = FileSharer();

  DataExportService(this._storage);

  /// 执行导出和分享的完整流程
  /// 返回 null 表示用户取消,返回 String 表示导出成功(包含文件路径)
  Future<String?> exportAndShare(ExportConfig config) async {
    // 第 1 步:读取全部数据
    final allEntries = _storage.getAll();

    // 第 2 步:按日期范围筛选
    final dateRange = config.effectiveDateRange;
    final filtered = DateRangeFilter.filter(
      entries: allEntries,
      from: dateRange?.from,
      to: dateRange?.to,
    );

    // 第 3 步:验证
    final validation = ExportValidator.validate(filtered, config);
    if (!validation.valid) {
      throw ExportException(validation.message);
    }

    // 第 4 步:转换为 DTO
    final dtos = filtered.map(MoodExportDto.fromEntry).toList();

    // 第 5 步:生成文件(到临时目录)
    final tempDir = await _ensureExportDir();
    final timestamp = DateTime.now()
        .toIso8601String()
        .replaceAll(':', '-')
        .split('.')
        .first;
    final extension = config.format == ExportFormat.csv ? 'csv' : 'json';
    final fileName = 'ebrufen_mood_export_$timestamp.$extension';
    final filePath = '${tempDir.path}/$fileName';

    final File file;
    if (config.format == ExportFormat.csv) {
      file = await _csvExporter.export(dtos, outputPath: filePath);
    } else {
      file = await _jsonExporter.export(
        dtos,
        outputPath: filePath,
        prettyPrint: config.prettyPrint,
        dateFrom: dateRange?.from,
        dateTo: dateRange?.to,
      );
    }

    // 第 6 步:通过系统分享面板分享
    final success = await _sharer.shareFile(
      file: file,
      subject: 'E-Brufen 情绪数据导出 ($timestamp)',
    );

    // 第 7 步:清理临时文件
    // 注意:如果分享是异步的(用户还在选择分享目标),
    // 不要立即删除文件。share_plus 在 Android 上使用 FileProvider,
    // 文件在分享完成后才安全删除。
    // 这里我们延迟 10 秒后删除作为简单处理。
    Future.delayed(const Duration(seconds: 10), () async {
      if (await file.exists()) {
        await file.delete();
      }
    });

    return success ? filePath : null;
  }

  /// 获取或创建导出临时目录
  Future<Directory> _ensureExportDir() async {
    final tempDir = await getTemporaryDirectory();
    final exportDir = Directory('${tempDir.path}/ebrufen_exports');
    if (!await exportDir.exists()) {
      await exportDir.create(recursive: true);
    }
    return exportDir;
  }
}

/// 导出异常
class ExportException implements Exception {
  final String message;
  const ExportException(this.message);

  
  String toString() => 'ExportException: $message';
}

9.3 流程总览

整个导出流程可以用以下时序图表示:

用户                UI                 Service            Storage          Filesystem        ShareSheet
 │                   │                    │                   │                 │                │
 │  点击"导出数据"   │                    │                   │                 │                │
 │──────────────────>│                    │                   │                 │                │
 │                   │  选择格式/日期范围 │                   │                 │                │
 │<─────────────────>│                    │                   │                 │                │
 │                   │  exportAndShare()  │                   │                 │                │
 │                   │──────────────────>│                   │                 │                │
 │                   │                    │  getAll()         │                 │                │
 │                   │                    │──────────────────>│                 │                │
 │                   │                    │<──────────────────│                 │                │
 │                   │                    │ (List<MoodEntry>) │                 │                │
 │                   │                    │                   │                 │                │
 │                   │                    │ 筛选 + 验证       │                 │                │
 │                   │                    │                   │                 │                │
 │                   │                    │  生成文件          │                 │                │
 │                   │                    │─────────────────────────────────────>│                │
 │                   │                    │<─────────────────────────────────────│                │
 │                   │                    │ (File)            │                 │                │
 │                   │                    │                   │                 │                │
 │                   │                    │  shareFile()      │                 │                │
 │                   │                    │──────────────────────────────────────────────────>│
 │                   │                    │                   │                 │  系统分享面板   │
 │                   │                    │                   │                 │  (微信/邮件/…) │
 │                   │                    │<──────────────────────────────────────────────────│
 │                   │                    │                   │                 │                │
 │                   │  返回结果          │                   │                 │                │
 │                   │<──────────────────│                   │                 │                │
 │  显示成功提示     │                    │                   │                 │                │
 │<──────────────────│                    │                   │                 │                │

9.4 在 UI 层集成

导出功能需要一个入口。在 E-Brufen 的情绪日记页面(DiaryPage)中,我们在 AppBar 的 actions 中添加一个导出按钮:

/// 情绪日记页面的 AppBar(简化版,突出导出按钮)
class DiaryAppBar extends StatelessWidget implements PreferredSizeWidget {
  final VoidCallback onExportTap;

  const DiaryAppBar({super.key, required this.onExportTap});

  
  Widget build(BuildContext context) {
    return AppBar(
      title: const Text('情绪日记'),
      actions: [
        // 导出按钮
        IconButton(
          icon: const Icon(Icons.file_download_outlined),
          tooltip: '导出数据',
          onPressed: onExportTap,
        ),
      ],
    );
  }

  
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

9.5 导出配置弹出面板

点击导出按钮后,弹出一个 ModalBottomSheet,让用户选择导出格式和日期范围:

/// 导出配置底部弹出面板
class ExportConfigSheet extends StatefulWidget {
  final MoodStorage storage;

  const ExportConfigSheet({super.key, required this.storage});

  
  State<ExportConfigSheet> createState() => _ExportConfigSheetState();
}

class _ExportConfigSheetState extends State<ExportConfigSheet> {
  ExportFormat _format = ExportFormat.csv;
  DateRangePreset _preset = DateRangePreset.all;
  bool _isExporting = false;
  String? _errorMessage;

  
  Widget build(BuildContext context) {
    final allEntries = widget.storage.getAll();
    final totalCount = allEntries.length;

    return Padding(
      padding: const EdgeInsets.all(24.0),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 标题
          Text(
            '导出情绪数据',
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const SizedBox(height: 4),
          Text(
            '共 $totalCount 条记录可供导出',
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                  color: Colors.grey[600],
                ),
          ),
          const SizedBox(height: 20),

          // 格式选择
          Text('导出格式', style: Theme.of(context).textTheme.titleSmall),
          const SizedBox(height: 8),
          _buildFormatSelector(),
          const SizedBox(height: 20),

          // 日期范围选择
          Text('日期范围', style: Theme.of(context).textTheme.titleSmall),
          const SizedBox(height: 8),
          _buildDateRangeSelector(),
          const SizedBox(height: 8),

          // 预估记录数
          _buildEstimatedCount(allEntries),
          const SizedBox(height: 16),

          // 错误提示
          if (_errorMessage != null) ...[
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.red[50],
                borderRadius: BorderRadius.circular(8),
              ),
              child: Row(
                children: [
                  Icon(Icons.error_outline, color: Colors.red[700], size: 20),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      _errorMessage!,
                      style: TextStyle(color: Colors.red[700], fontSize: 13),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 12),
          ],

          // 导出按钮
          SizedBox(
            width: double.infinity,
            height: 48,
            child: ElevatedButton.icon(
              onPressed: _isExporting ? null : _handleExport,
              icon: _isExporting
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Icon(Icons.file_download_outlined),
              label: Text(_isExporting ? '正在导出...' : '导出并分享'),
            ),
          ),
          const SizedBox(height: 8),
        ],
      ),
    );
  }

  Widget _buildFormatSelector() {
    return Row(
      children: [
        Expanded(
          child: _FormatCard(
            icon: Icons.table_chart_outlined,
            label: 'CSV',
            subtitle: 'Excel 分析',
            isSelected: _format == ExportFormat.csv,
            onTap: () => setState(() => _format = ExportFormat.csv),
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: _FormatCard(
            icon: Icons.code,
            label: 'JSON',
            subtitle: '程序处理',
            isSelected: _format == ExportFormat.json,
            onTap: () => setState(() => _format = ExportFormat.json),
          ),
        ),
      ],
    );
  }

  Widget _buildDateRangeSelector() {
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: DateRangePreset.values.map((preset) {
        final isSelected = _preset == preset;
        return ChoiceChip(
          label: Text(preset.label),
          selected: isSelected,
          onSelected: (_) => setState(() {
            _preset = preset;
            _errorMessage = null;
          }),
        );
      }).toList(),
    );
  }

  Widget _buildEstimatedCount(List<MoodEntry> allEntries) {
    final dateRange = _preset.toDateRange();
    final filtered = DateRangeFilter.filter(
      entries: allEntries,
      from: dateRange?.from,
      to: dateRange?.to,
    );

    return Text(
      '将导出 ${filtered.length} 条记录',
      style: TextStyle(color: Colors.grey[600], fontSize: 13),
    );
  }

  Future<void> _handleExport() async {
    setState(() {
      _isExporting = true;
      _errorMessage = null;
    });

    try {
      final config = ExportConfig(format: _format, preset: _preset);
      final service = DataExportService(widget.storage);

      await service.exportAndShare(config);

      if (mounted) {
        Navigator.of(context).pop(); // 关闭底部面板
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text('数据导出成功!请选择分享方式。'),
            duration: Duration(seconds: 2),
          ),
        );
      }
    } on ExportException catch (e) {
      setState(() {
        _errorMessage = e.message;
        _isExporting = false;
      });
    } catch (e) {
      setState(() {
        _errorMessage = '导出失败:$e';
        _isExporting = false;
      });
    }
  }
}

/// 格式选择卡片
class _FormatCard extends StatelessWidget {
  final IconData icon;
  final String label;
  final String subtitle;
  final bool isSelected;
  final VoidCallback onTap;

  const _FormatCard({
    required this.icon,
    required this.label,
    required this.subtitle,
    required this.isSelected,
    required this.onTap,
  });

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 200),
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: isSelected
              ? Theme.of(context).colorScheme.primaryContainer
              : Colors.grey[100],
          borderRadius: BorderRadius.circular(12),
          border: Border.all(
            color: isSelected
                ? Theme.of(context).colorScheme.primary
                : Colors.grey[300]!,
            width: isSelected ? 2 : 1,
          ),
        ),
        child: Column(
          children: [
            Icon(
              icon,
              size: 32,
              color: isSelected
                  ? Theme.of(context).colorScheme.primary
                  : Colors.grey[600],
            ),
            const SizedBox(height: 8),
            Text(
              label,
              style: TextStyle(
                fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
                fontSize: 16,
              ),
            ),
            Text(
              subtitle,
              style: TextStyle(color: Colors.grey[600], fontSize: 12),
            ),
          ],
        ),
      ),
    );
  }
}

9.6 实际导出效果对比

以 E-Brufen 中 120 条真实情绪记录为例,三种导出方式的效果对比:

指标 CSV JSON (美化) JSON (紧凑)
文件体积 8.2 KB 24.6 KB 15.3 KB
生成耗时 12ms 18ms 14ms
Excel 打开 完美(含 BOM) N/A N/A
Python 解析 2 行代码 3 行代码 3 行代码
微信直接查看 可读(需表格工具) 可读(文本格式) 可读(需格式化工具)

十、UI 交互设计:导出入口与用户引导

导出功能的价值取决于用户是否知道它的存在。一个藏在"设置→高级→数据管理→导出"四层菜单下的导出按钮,几乎等于不存在。

10.1 导出入口的位置策略

在 E-Brufen 中,我们选择将导出按钮放在两个位置:

位置 原因 可见性
情绪日记页 AppBar 最自然的联想——用户在查看数据时想到"我想导出这些数据"
设置页(辅助入口) 作为数据管理功能的集中入口,符合用户对"设置"的心理模型

10.2 导出前的预览提示

在用户最终确认导出之前,展示一个简洁的预览:

┌─────────────────────────────────────┐
│  导出预览                           │
│                                     │
│  格式:CSV(Excel 可打开)           │
│  范围:最近 30 天(6/16 - 7/15)    │
│  记录:42 条                        │
│                                     │
│  ┌──────────┐  ┌──────────────────┐ │
│  │  CSV      │  │  JSON            │ │
│  │  ✨ 推荐   │  │                  │ │
│  │  表格分析  │  │  程序处理        │ │
│  └──────────┘  └──────────────────┘ │
│                                     │
│  ⚠️ 导出文件包含个人情绪数据,      │
│  请注意文件安全,避免分享给不信任    │
│  的第三方。                         │
│                                     │
│  [  取消  ]      [  导出并分享  ]   │
└─────────────────────────────────────┘

10.3 导出成功后的反馈

导出成功后,不要只是默默地做完。用户需要明确的反馈:

  1. SnackBar 提示:“数据导出成功!请选择分享方式。”——简洁明了,2 秒自动消失。
  2. 系统分享面板自动弹出——这是最好的反馈,用户直接进入下一步操作。
  3. 不生成通知——对于数据导出这种用户主动触发的操作,通知是多余的。

十一、隐私与安全考量

情绪数据是高度敏感的个人信息。一片"今天很难过,和伴侣吵架了"的笔记,包含着用户的情感隐私。如果我们把导出功能做得太便利而忽略了安全,反而可能伤害用户。

11.1 风险分析矩阵

风险场景 严重程度 发生概率 缓解措施
用户无意中将 CSV 文件通过微信发送给了陌生人 导出前弹窗提醒,文件分享后自动清理
导出的 JSON 文件被其他恶意应用读取 低(鸿蒙沙箱保护) 使用应用私有临时目录
文件名包含敏感的时间戳信息 文件名仅包含日期,不包含情绪信息
用户在公共电脑上打开 CSV(忘记删除) 导出前提醒"请确保在可信设备上操作"

11.2 导出前的隐私提醒

在用户点击"导出并分享"之前,展示一个不可跳过的确认对话框:

/// 隐私提醒对话框
Future<bool> showPrivacyReminder(BuildContext context) async {
  final result = await showDialog<bool>(
    context: context,
    barrierDismissible: false,
    builder: (context) => AlertDialog(
      icon: Icon(Icons.shield_outlined, size: 48, color: Colors.amber[700]),
      title: const Text('数据安全提醒'),
      content: const Text(
        '导出的文件包含您的个人情绪记录。\n\n'
        '请注意:\n'
        '• 不要将文件分享给不信任的第三方\n'
        '• 在公共设备上使用后请及时删除文件\n'
        '• E-Brufen 不会将您的数据上传到任何服务器\n\n'
        '数据仅存储在您的设备上,导出后的文件安全由您自行负责。',
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context, false),
          child: const Text('取消导出'),
        ),
        FilledButton(
          onPressed: () => Navigator.pop(context, true),
          child: const Text('我知道了,继续导出'),
        ),
      ],
    ),
  );
  return result ?? false;
}

11.3 数据最小化原则

在导出功能中,我们遵循数据最小化原则:

  • 不导出应用设置:用户的呼吸模式偏好、定时器设置等不属于"个人数据"范畴,不在导出范围内。
  • 不导出使用统计:用户在哪个页面停留了多久——这是应用分析数据,用户不需要也不应该关心。
  • 只导出用户明确创建的内容:情绪记录是用户主动创建的数据,"数据可移植性"的法定权利也只涵盖这部分。

11.4 隐私合规检查清单

在发布导出功能之前,逐项检查:

检查项 状态 说明
隐私政策是否提及"用户可导出数据" 待补充 需在隐私政策中添加相关条款
导出操作是否有明确的用户同意 已实现 通过 showPrivacyReminder 获取确认
导出文件是否存储在安全位置 已实现 使用应用私有临时目录
导出后是否自动清理临时文件 已实现 延迟 10 秒清理
数据是否会被上传到服务器 不适用 E-Brufen 是离线应用,无网络权限
是否符合 GDPR 第 20 条(数据可移植权) 符合 提供结构化、常用、机器可读格式

十二、测试策略与边界条件

数据导出功能涉及文件 I/O、编码、日期处理,这些都是容易出 Bug 的领域。我们需要一套完整的测试策略。

12.1 测试金字塔

           ╱  E2E 测试 ╲
          ╱  (手动)    ╲
         ╱────────────────╲
        ╱   Widget 测试     ╲
       ╱   (导出面板交互)   ╲
      ╱────────────────────────╲
     ╱     单元测试               ╲
    ╱     (各模块独立测试)         ╲
   ╱──────────────────────────────────╲

12.2 核心单元测试

import 'package:flutter_test/flutter_test.dart';

void main() {
  group('DateRangeFilter', () {
    final testEntries = [
      _createEntry(2026, 7, 1, MoodType.happy),
      _createEntry(2026, 7, 5, MoodType.calm),
      _createEntry(2026, 7, 10, MoodType.sad),
      _createEntry(2026, 7, 15, MoodType.tired),
    ];

    test('无筛选条件时返回全部', () {
      final result = DateRangeFilter.filter(entries: testEntries);
      expect(result.length, 4);
    });

    test('按起始日期筛选', () {
      final result = DateRangeFilter.filter(
        entries: testEntries,
        from: DateTime(2026, 7, 5),
      );
      expect(result.length, 3);
      expect(result.first.moodType, MoodType.calm);
    });

    test('按截止日期筛选', () {
      final result = DateRangeFilter.filter(
        entries: testEntries,
        to: DateTime(2026, 7, 10),
      );
      expect(result.length, 3);
    });

    test('范围内无数据时返回空列表', () {
      final result = DateRangeFilter.filter(
        entries: testEntries,
        from: DateTime(2026, 8, 1),
        to: DateTime(2026, 8, 31),
      );
      expect(result, isEmpty);
    });

    test('空列表输入返回空列表', () {
      final result = DateRangeFilter.filter(entries: []);
      expect(result, isEmpty);
    });
  });

  group('CsvExporter', () {
    test('生成的 CSV 以 BOM 开头', () async {
      final exporter = CsvExporter();
      final dtos = [
        MoodExportDto(
          id: 1,
          moodValue: 5,
          moodLabel: '开心',
          moodEmoji: '😊',
          note: '测试',
          createdAt: '2026-07-15T10:00:00.000',
          updatedAt: '2026-07-15T10:00:00.000',
        ),
      ];

      final tempDir = Directory.systemTemp;
      final filePath = '${tempDir.path}/test_export.csv';
      final file = await exporter.export(dtos, outputPath: filePath);

      final bytes = await file.readAsBytes();
      // 验证 BOM:前三个字节应为 0xEF 0xBB 0xBF
      expect(bytes[0], 0xEF);
      expect(bytes[1], 0xBB);
      expect(bytes[2], 0xBF);

      // 清理
      await file.delete();
    });

    test('包含逗号的备注被双引号包裹', () async {
      final exporter = CsvExporter();
      final dtos = [
        MoodExportDto(
          id: 1,
          moodValue: 5,
          moodLabel: '开心',
          moodEmoji: '😊',
          note: '今天做了什么,感觉如何',
          createdAt: '2026-07-15T10:00:00.000',
          updatedAt: '2026-07-15T10:00:00.000',
        ),
      ];

      final tempDir = Directory.systemTemp;
      final filePath = '${tempDir.path}/test_comma.csv';
      final file = await exporter.export(dtos, outputPath: filePath);

      final content = await file.readAsString();
      expect(content, contains('"今天做了什么,感觉如何"'));

      await file.delete();
    });
  });

  group('JsonExporter', () {
    test('美化模式输出包含缩进', () async {
      final exporter = JsonExporter(prettyPrint: true);
      final dtos = [
        _createDto(id: 1),
        _createDto(id: 2),
      ];

      final tempDir = Directory.systemTemp;
      final filePath = '${tempDir.path}/test_pretty.json';
      final file = await exporter.export(dtos, outputPath: filePath);

      final content = await file.readAsString();
      expect(content, contains('  "export_info"')); // 有缩进
      expect(content, contains('\n'));               // 有换行

      await file.delete();
    });

    test('紧凑模式输出不包含换行', () async {
      final exporter = JsonExporter(prettyPrint: false);
      final dtos = [_createDto(id: 1)];

      final tempDir = Directory.systemTemp;
      final filePath = '${tempDir.path}/test_compact.json';
      final file = await exporter.export(dtos, outputPath: filePath);

      final content = await file.readAsString();
      // 紧凑模式不应该有多余换行(只有 JSON 字符串本身)
      expect(content.split('\n').length, 1);

      await file.delete();
    });
  });
}

// ── 测试辅助函数 ──

MoodEntry _createEntry(int year, int month, int day, MoodType mood) {
  return MoodEntry(
    id: 1,
    moodType: mood,
    note: '测试笔记',
    createdAt: DateTime(year, month, day),
    updatedAt: DateTime(year, month, day),
  );
}

MoodExportDto _createDto({required int id}) {
  return MoodExportDto(
    id: id,
    moodValue: 5,
    moodLabel: '开心',
    moodEmoji: '😊',
    note: '测试数据 $id',
    createdAt: '2026-07-15T10:00:00.000',
    updatedAt: '2026-07-15T10:00:00.000',
  );
}

12.3 边界条件清单

边界条件 预期行为 测试方法
零条记录导出 显示提示"所选范围内无记录" 单元测试
单条记录导出 正常生成,CSV 含表头+1 行数据 单元测试
10000+ 条记录导出 提示"建议分批导出" 单元测试
备注包含特殊字符(逗号、引号、换行) CSV 自动转义 单元测试
备注为 null CSV 输出空字符串,JSON 输出空字符串 单元测试
文件写入权限不足 抛出异常并显示友好提示 手动测试
系统分享面板不可用(鸿蒙降级) 降级到文件保存方案 手动测试
用户在分享中途取消 不报错,静默返回 手动测试

十三、鸿蒙平台兼容性说明

13.1 Hive CE 的数据读取

E-Brufen 使用 Hive CE 作为本地存储。在鸿蒙平台上,Hive CE 的数据文件存储在应用的沙箱目录中,路径类似于:

/data/storage/el2/base/haps/<bundleName>/files/

导出功能通过 MoodStorage.getAll() 读取数据,完全在应用进程内完成,不涉及跨进程通信,因此在鸿蒙平台上与 Android/iOS 行为一致,无需额外适配。

13.2 share_plus 的鸿蒙现状

截至 2026 年 7 月,share_plus 对鸿蒙平台的支持情况:

特性 支持状态 备注
share_plus_ohos 基础文本分享 已支持 分享纯文本到系统面板
shareXFiles 文件分享 部分支持 部分鸿蒙设备型号可能不兼容
微信/QQ 等第三方接收 取决于第三方应用 鸿蒙版微信已支持接收系统分享

降级方案:当 shareXFiles 在鸿蒙上不可用时,我们将文件复制到用户可访问的公共目录(如 Downloads),并显示一个 SnackBar 提示用户手动操作。这不如自动弹出分享面板流畅,但在生态成熟之前是一个务实的兜底方案。

13.3 文件编码兼容性

鸿蒙系统底层使用 UTF-8 作为默认编码。使用 dart:ioFile.writeAsString(encoding: utf8) 写入的文件,在鸿蒙的文件管理器中可以正常打开和预览。BOM 头在鸿蒙的 WPS Office 中同样有效。


十四、小结

本文从零开始,为 E-Brufen 构建了一个完整的数据导出功能。让我们回顾一下核心要点:

核心决策

  1. CSV + JSON 双格式:CSV 面向数据分析(Excel/WPS),JSON 面向数据可移植性(程序处理)。这不是"选哪个"的问题,而是"两种都要"——因为它们的用户场景完全不同。
  2. BOM 头不可省略:三个字节(0xEF 0xBB 0xBF)决定了用户用 Excel 打开 CSV 时看到的是正确的中文还是一堆乱码。这个细节在中文生态中是必须处理的。
  3. 数据导出不等于数据上传:E-Brufen 的导出功能完全在本地完成——读取本地数据、生成本地文件、通过本地分享面板发送。没有任何数据经过服务器。这是离线优先架构的一贯原则。
  4. 隐私提醒不是多余的设计:情绪数据的高度敏感性决定了我们不能"悄悄地"帮用户导出。showPrivacyReminder 这个确认步骤,既是法律合规的需要,也是对用户数据的尊重。

代码文件清单

文件 职责
lib/models/mood_entry.dart 情绪条目数据模型(已有)
lib/data/mood_storage.dart Hive CE 数据存储(已有)
lib/services/export/mood_export_dto.dart 导出数据传输对象(新增)
lib/services/export/csv_exporter.dart CSV 格式导出器(新增)
lib/services/export/json_exporter.dart JSON 格式导出器(新增)
lib/services/export/date_range_filter.dart 日期范围筛选器(新增)
lib/services/export/data_export_service.dart 导出服务编排(新增)
lib/services/export/file_sharer.dart 系统分享面板封装(新增)
lib/widgets/export_config_sheet.dart 导出配置 UI(新增)

待完善的工作

  • 支持导出到 iCloud/华为云(云端备份场景)
  • 导出历史记录(用户可以查看之前导出了哪些文件)
  • PDF 情绪报告生成(美化排版的"情绪健康月报")
  • 导入功能(将 CSV/JSON 数据重新导入回 E-Brufen)
  • 端到端加密导出(用密码保护导出的 JSON 文件)

一句话总结

数据导出的本质不是"生成一个文件",而是"尊重用户对自己数据的主权"。当用户能够自由地将情绪记录导出为 CSV 在 Excel 中分析、导出为 JSON 迁移到其他应用时,我们的应用就从一个"数据孤岛"变成了用户数据生态中的一个可信节点。


作者简介

E-Brufen Dev,鸿蒙 Flutter 全栈开发者,专注于跨平台移动应用开发与用户体验设计。E-Brufen 情绪健康应用的创建者与维护者,"鸿蒙 Flutter 实战"系列博客的作者。关注数据隐私、离线优先架构与程序化资源生成技术。


Logo

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

更多推荐