需求概述
在 Flutter 客户端中添加历史传感器数据导出功能,支持:
- 选择日期范围(一天或多天,含今天)
- 按天分割,每天一个独立 CSV 文件
- 同时支持 Android(本地文件保存)和 Web(浏览器下载)
- 导出完成后显示保存位置,提供”用其他软件打开”按钮
架构模式
沿用项目已有的 Provider + ChangeNotifier(MVVM)模式。导出功能拥有独立的 ViewModel,通过 GoRoute builder 获取已有 Repository,不修改根 main.dart 的 DI 树。
lib/
├── utils/
│ ├── csv_exporter.dart # CSV 生成工具
│ ├── file_saver.dart # 平台条件导入入口
│ ├── file_saver_mobile.dart # 移动端 dart:io 文件写入
│ └── file_saver_web.dart # Web 端 dart:html 下载
├── ui/user/
│ ├── view/
│ │ ├── user_page.dart # 添加"导出历史数据"入口
│ │ └── export_history_page.dart # 导出页面(4 态 UI)
│ └── view_model/
│ └── export_history_view_model.dart # 导出业务逻辑
CSV 生成
字段合并与去重
合并 11 个传感器字段(瞬时流量、累计量、流速、压力、7 路温度),按秒级时间戳去重。NaN 值输出为空字符串。
class CsvExporter {
static const _orderedFields = <HistoryField>[
HistoryField.flow, HistoryField.total, HistoryField.velocity,
HistoryField.pressure,
HistoryField.t0, HistoryField.t1, HistoryField.t2,
HistoryField.t3, HistoryField.t4, HistoryField.t5, HistoryField.t6,
];
/// 返回 null 表示该天无有效数据。
static String? generateDailyCsv({
required DateTime day,
required Map<HistoryField, List<HistoryPoint>> pointsByField,
}) {
// 1. 收集所有字段的唯一时间戳(精确到秒)
// 2. 按秒构建字段值查找表
// 3. 按时戳升序输出行
// 4. NaN / 缺失值输出为空
}
}
格式规格
| 项目 | 规格 |
|---|---|
| 编码 | UTF-8 with BOM(Excel 中文兼容) |
| 行分隔符 | \r\n |
| 数值精度 | toStringAsFixed(2) |
| 文件名 | 管道数据_YYYY-MM-DD.csv |
| 表头 | timestamp,瞬时流量(L/min),累计量(L),流速(m/s),压力(MPa),管内温度(°C),... |
跨平台文件保存
条件导入
利用 Dart 的条件导入,编译时按平台选择实现:
// file_saver.dart(入口)
import 'file_saver_mobile.dart'
if (dart.library.html) 'file_saver_web.dart';
Future<String> saveTextFile(String content, String fileName) =>
saveTextFileImpl(content, fileName);
编译 Web 时自动使用 dart:html 实现,编译 Android 时使用 dart:io 实现,零运行时开销。
Web 实现
通过 dart:html 创建 Blob 和 AnchorElement 触发下载:
// file_saver_web.dart
Future<String> saveTextFileImpl(String content, String fileName) async {
final blob = html.Blob([content], 'text/csv;charset=utf-8');
final url = html.Url.createObjectUrl(blob);
final anchor = html.AnchorElement(href: url)
..setAttribute('download', fileName)
..style.display = 'none';
html.document.body?.children.add(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
return fileName;
}
移动端三级回退
Android 端文件保存路径按优先级三级回退:
// file_saver_mobile.dart
Future<String> saveTextFileImpl(String content, String fileName) async {
// 1. 优先:公共 Downloads 目录
final downloadDir = await _downloadsDirectory();
if (downloadDir != null) return _writeFile(downloadDir, content, fileName);
// 2. 回退:外部存储应用专属目录
final extDir = await getExternalStorageDirectory();
if (extDir != null) {
final exportDir = Directory('${extDir.path}/exported_data');
if (!await exportDir.exists()) await exportDir.create(recursive: true);
return _writeFile(exportDir, content, fileName);
}
// 3. 最终回退:内部私有目录
final docDir = await getApplicationDocumentsDirectory();
final fallbackDir = Directory('${docDir.path}/exported_data');
if (!await fallbackDir.exists()) await fallbackDir.create(recursive: true);
return _writeFile(fallbackDir, content, fileName);
}
_downloadsDirectory() 从外部存储路径推导公共 Downloads 目录,写入 Download/PipeMonitor_Data/。
UTF-8 BOM 写入
Future<String> _writeFile(Directory dir, String content, String fileName) async {
final file = File('${dir.path}/$fileName');
final bom = utf8.encode('\uFEFF') + utf8.encode(content);
await file.writeAsBytes(bom, flush: true);
return file.path;
}
BOM 头确保 Excel 能正确识别 UTF-8 编码,避免中文乱码。
Android 原生配置
FileProvider 授权
Android 11+ 需要 FileProvider 才能让其他应用打开导出文件:
AndroidManifest.xml:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application android:requestLegacyExternalStorage="true">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
res/xml/file_paths.xml:
<paths>
<external-path name="download" path="Download/PipeMonitor_Data/" />
<external-path name="external_root" path="." />
</paths>
用其他软件打开
使用 open_filex 包调用系统文件选择器:
// 依赖:open_filex: ^4.6.0
await OpenFilex.open(filePath);
导出 ViewModel
enum ExportStatus { idle, exporting, done, error }
class ExportHistoryViewModel extends ChangeNotifier {
// 状态
ExportStatus status = ExportStatus.idle;
DateTime dateFrom; // 默认今天-6天
DateTime dateTo; // 默认今天
double progress; // 0.0 ~ 1.0
String progressText; // "正在获取 2026-06-15 数据 (3/7)"
String? error;
List<String> savedFilePaths;
Future<void> startExport() async {
// 1. 收集日期列表
// 2. 逐天顺序处理:
// a. Future.wait 并发拉取全部 11 个 HistoryField
// b. CsvExporter.generateDailyCsv() 生成 CSV
// c. saveTextFile() 保存文件
// d. 更新 progress
// 3. 无数据的天自动跳过
// 4. 完成 → status = done
}
}
页面 UI(4 态切换)
switch (vm.status) {
ExportStatus.idle => _IdleView(), // 日期选择 + 导出按钮
ExportStatus.exporting => _ProgressView(), // 线性进度条 + 当前天
ExportStatus.done => _DoneView(), // 成功 + 路径 + 操作按钮
ExportStatus.error => _ErrorView(), // 错误信息 + 重试
}
Done 页展示保存位置(SelectableText 可复制)、文件列表,以及”返回”和”用其他软件打开”按钮。
日期处理要点
- 默认范围:今天往前 7 天
- 日期上限:31 天
- 今天结束时间:
DateTime.now()(截止当前) - 非今天结束时间:
DateTime(year, month, day, 23, 59, 59, 999) - 无数据的天自动跳过,不生成空 CSV
- 全部范围无数据时:显示”所选日期范围内无有效数据”
踩坑记录
| 问题 | 原因 | 解决 |
|---|---|---|
| 条件导入编译失败 | stub / web 文件各自声明了 library 导致冲突 | 移除 library 声明,保持相同函数签名 |
| ViewModel 相对路径找不到依赖 | 路径在 ui/user/view_model/,../../ 只到达 ui/ | 改为 ../../../ 到达 lib/ |
| Web 下载无提示 | 浏览器静默执行下载 | Done 页判断 kIsWeb,提示查看浏览器默认下载目录 |
| 移动端 CSV 用户不可访问 | 写入应用内部私有目录 | 三级回退策略 |
| Android 11+ 无法打开 CSV | 缺少 FileProvider 授权 | 添加 FileProvider + file_paths.xml + open_filex |
| 路径在卡片中截断 | Text 默认不换行 | 改用 SelectableText + SingleChildScrollView |
依赖
dependencies:
path_provider: ^2.1.5 # 获取存取路径
open_filex: ^4.6.0 # 用其他软件打开文件(Android FileProvider)
intl # 日期格式化
无需额外添加的依赖:dart:html(Web 下载)、dart:io(移动端文件写入)、dart:convert(UTF-8 BOM 编码),均为 Flutter 内置。