需求概述

在 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 内置。