AtomGit Flutter 鸿蒙客户端:CSV 与 JSON 格式的生成与分享
让用户拥有自己的数据——导出、分享与数据可移植性
目录
- 为什么用户需要数据导出
- 导出格式的选择:CSV vs JSON vs PDF
- 架构设计:导出功能的模块划分与数据流
- 数据源设计:从 Hive CE 中高效读取情绪记录
- CSV 导出:BOM 头处理与中文 Excel 兼容
- JSON 导出:结构化数据与格式化选项
- 日期范围筛选:按需导出的核心逻辑
- 系统分享面板:share_plus 的集成与鸿蒙适配
- E-Brufen 实战:完整导出功能实现
- UI 交互设计:导出入口与用户引导
- 隐私与安全考量
- 测试策略与边界条件
- 鸿蒙平台兼容性说明
- 小结
一、为什么用户需要数据导出

让我们从一个真实的用户故事开始。
小张是 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 | |
|---|---|---|---|
| 适合场景 | 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 有三个关键设计决策:
- 时间格式统一为 ISO 8601:
2026-07-15T14:30:00.000。这是国际标准格式,Excel 和 Python 的pandas都能直接解析。 moodValue保留数值:CSV 用户可能需要用数值做统计分析(如计算平均情绪分)。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;
}
}
几个值得注意的细节:
- 使用
openWrite而非writeAsString:openWrite返回一个IOSink,支持流式写入。如果记录量很大(5000+条),流式写入可以避免在内存中构建完整的文件内容字符串。 sink.add([0xEF, 0xBB, 0xBF]):这行代码写入 BOM。注意必须在writeln之前执行,因为 BOM 必须是文件的最开头三个字节。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 在鸿蒙平台上有几个需要注意的地方:
- 鸿蒙版 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 目录
}
}
}
- 鸿蒙的文件系统沙箱比 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 导出成功后的反馈
导出成功后,不要只是默默地做完。用户需要明确的反馈:
- SnackBar 提示:“数据导出成功!请选择分享方式。”——简洁明了,2 秒自动消失。
- 系统分享面板自动弹出——这是最好的反馈,用户直接进入下一步操作。
- 不生成通知——对于数据导出这种用户主动触发的操作,通知是多余的。
十一、隐私与安全考量
情绪数据是高度敏感的个人信息。一片"今天很难过,和伴侣吵架了"的笔记,包含着用户的情感隐私。如果我们把导出功能做得太便利而忽略了安全,反而可能伤害用户。
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:io 的 File.writeAsString(encoding: utf8) 写入的文件,在鸿蒙的文件管理器中可以正常打开和预览。BOM 头在鸿蒙的 WPS Office 中同样有效。
十四、小结
本文从零开始,为 E-Brufen 构建了一个完整的数据导出功能。让我们回顾一下核心要点:
核心决策
- CSV + JSON 双格式:CSV 面向数据分析(Excel/WPS),JSON 面向数据可移植性(程序处理)。这不是"选哪个"的问题,而是"两种都要"——因为它们的用户场景完全不同。
- BOM 头不可省略:三个字节(
0xEF 0xBB 0xBF)决定了用户用 Excel 打开 CSV 时看到的是正确的中文还是一堆乱码。这个细节在中文生态中是必须处理的。 - 数据导出不等于数据上传:E-Brufen 的导出功能完全在本地完成——读取本地数据、生成本地文件、通过本地分享面板发送。没有任何数据经过服务器。这是离线优先架构的一贯原则。
- 隐私提醒不是多余的设计:情绪数据的高度敏感性决定了我们不能"悄悄地"帮用户导出。
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 实战"系列博客的作者。关注数据隐私、离线优先架构与程序化资源生成技术。
- AtomGit:https://atomgit.com/e-brufen
- E-Brufen 官网:https://e-brufen.dev
更多推荐




所有评论(0)