跨平台文件保存的挑战

PipeMonitor 的 Flutter 应用需要把历史数据导出为 CSV,方便用户在 PC 上做离线分析。但 Flutter 的文件保存在三个平台上行为完全不同:

  • Web 端:没有文件系统 API,只能借助浏览器下载
  • Android:有公共 Downloads 目录,但 path_provider 不直接暴露,需要从外部存储路径推导
  • iOS:沙盒严格,无法写入公共目录,只能写到应用文档目录
  • 桌面端:走 dart:io,与移动端共用一套逻辑

如果直接用 if/else 在运行时判断平台,Web 端会因为没有 dart:io 而编译失败;移动端会因为引入 dart:html 而无法打包。Dart 的条件导入(conditional import)是解决这个问题的标准方案。

条件导入:编译期分流

条件导入的核心思想是:在编译期根据平台可用的库选择不同的实现文件,而不是在运行时判断。入口文件 file_saver.dart 只有一行 import:

// 平台条件文件保存入口。
//
// Web 编译时导入 file_saver_web.dart(dart:html 下载),
// 移动端 / 桌面端编译时导入 file_saver_mobile.dart(dart:io 写入本地文件)。
import 'file_saver_mobile.dart'
    if (dart.library.html) 'file_saver_web.dart';

/// 平台自适应文件保存。
///
/// Web 端:通过浏览器下载 CSV 文件,返回文件名。
/// 移动端:将 CSV 文件写入应用文档目录下的 exported_data/ 文件夹,返回完整路径。
Future<String> saveTextFile(String content, String fileName) =>
    saveTextFileImpl(content, fileName);

关键是 if (dart.library.html) 这个条件:编译器检查当前目标平台是否能解析 dart:html 库。Web 编译时该库可用,于是导入 file_saver_web.dart;Android/iOS/桌面编译时该库不可用,回退到默认的 file_saver_mobile.dart

两个实现文件都导出了同名函数 saveTextFileImpl,入口文件只做转发。这样上层调用方永远只依赖 saveTextFile 一个接口,平台差异被完全隐藏。

Web 端:Blob + AnchorElement 触发下载

Web 端没有文件系统,唯一的”保存”方式是让浏览器触发下载。file_saver_web.dartdart:htmlBlob + AnchorElement 组合实现:

// Web 端文件保存实现:创建 Blob + AnchorElement 触发浏览器下载。
// 返回文件名,表示已触发下载。
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;
}

几个细节值得注意:

  1. Blob 的 MIME 类型显式声明 text/csv;charset=utf-8,避免某些浏览器把 CSV 当纯文本打开而非下载
  2. download 属性告诉浏览器这是下载链接,文件名由该属性决定,而不是 URL
  3. 先加到 DOM 再 click——部分浏览器(尤其 Firefox)要求 anchor 必须在文档树中才能触发下载,style.display='none' 保证用户看不到它
  4. revokeObjectUrl 释放内存——Blob URL 创建后一直占用内存,下载完成立即撤销

返回值是文件名而不是路径,因为 Web 端不存在”文件路径”的概念,调用方只能拿到触发下载的文件名用于 UI 提示。

移动端:Android Downloads 目录推导

Android 上 path_provider 提供了 getExternalStorageDirectory(),但它返回的是应用专属外部目录:

/storage/emulated/0/Android/data/<package>/files

这个目录在 Android 11+ 上对用户不可见,导出文件后用户根本找不到。用户期望的是放在 /storage/emulated/0/Download/ 下的公共 Downloads 目录。

path_provider 没有直接提供公共 Downloads 目录的 API(getDownloadsDirectory 在 Android 上会抛 UnsupportedError)。解决办法是从外部存储路径向上推导存储根,再拼接 Download

/// 获取公共 Downloads 目录。
///
/// Android:从外部存储路径推导 /Download/exported_data/
/// iOS/其他:返回 null(不支持)
Future<Directory?> _downloadsDirectory() async {
  try {
    final extDir = await getExternalStorageDirectory();
    if (extDir == null) return null;

    // 外部存储路径格式:/storage/emulated/0/Android/data/<pkg>/files
    // 向上三级取存储根,再拼接 Download
    final parts = extDir.path.split('/');
    // 找到 "Android" 的索引,其父级即为存储根
    final androidIdx = parts.indexOf('Android');
    if (androidIdx <= 0) return null;

    final root = parts.sublist(0, androidIdx).join('/');
    final dir = Directory('$root/Download/PipeMonitor_Data');
    if (!await dir.exists()) {
      await dir.create(recursive: true);
    }
    return dir;
  } catch (_) {
    return null;
  }
}

这里没有硬编码”向上三级”,而是用 indexOf('Android') 定位 Android 段的索引。原因是在不同 Android 版本和厂商 ROM 上,外部存储路径的前缀可能不同(/storage/emulated/0//storage/sdcard0/ 等),但 Android/data/<pkg>/files 这段结构是稳定的。以 Android 为锚点比硬编码层级更鲁棒。

这个方案依赖 Android 对 /storage/emulated/0/Download/ 的公共写入权限。Android 10+ 引入 Scoped Storage 后,写入公共 Downloads 在大多数设备上仍然可用(Downloads 目录有特殊豁免),但部分受限设备会抛异常——这正是下一节三级回退要解决的问题。

三级回退策略

saveTextFileImpl 在移动端采用三级回退,保证任何设备都能写成功:

Future<String> saveTextFileImpl(String content, String fileName) async {
  // 第一级:公共 Downloads 目录
  final downloadDir = await _downloadsDirectory();
  if (downloadDir != null) {
    return _writeFile(downloadDir, content, fileName);
  }

  // 第二级:外部存储的应用专属目录(仍比内部私有目录更易访问)
  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);
  }

  // 第三级:内部私有目录
  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);
}

三级的取舍:

级别目录可见性适用场景
1/storage/emulated/0/Download/PipeMonitor_Data/用户文件管理器直接可见Android 大多数设备
2/storage/emulated/0/Android/data/<pkg>/files/exported_data/文件管理器可见但需翻到 Android/data 下Scoped Storage 受限设备
3应用沙盒 Documents/exported_data/仅应用内可见iOS、沙盒严格的设备

公共 Downloads 不可用时退到外部应用目录——这个目录虽然对用户不直观,但至少在文件管理器里还能找到;如果连外部存储都没有(iOS),再退到内部沙盒文档目录。每一级都用 try/catch 包裹(_downloadsDirectory 内部捕获异常返回 null),任何一级失败都不会阻塞导出。

UTF-8 BOM:让 Excel 正确识别中文

_writeFile 不是简单地用 writeAsString,而是用 writeAsBytes 手动拼接 BOM:

/// 将内容写入指定目录的文件,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;
}

\uFEFF 是 UTF-8 BOM(Byte Order Mark)。为什么非要加这 3 个字节?因为 Excel 在 Windows 上打开 CSV 时,默认按 ANSI/GBK 编码解析。如果文件是纯 UTF-8 无 BOM,中文表头会显示成乱码。加上 BOM 后 Excel 能识别出这是 UTF-8 文件,从而正确解码中文。

writeAsBytes 而不是 writeAsString 是因为 writeAsString 配合 utf8 编码默认不加 BOM——Dart 的 utf8.encode 只产生纯 UTF-8 字节序列。手动拼接 bom + content 的字节流是控制 BOM 的唯一可靠方式。flush: true 确保数据立即落盘,避免导出后立刻断开 USB 连接时数据丢失。

CSV 多字段合并导出

CsvExporter 负责把按天分组的历史数据点合并成 CSV 字符串。PipeMonitor 有 13 个历史字段(流量、累计量、流速、压力 + 9 个温度),每个字段的采样时间戳不完全一致,需要做时间戳对齐。

固定列顺序

/// CSV 列顺序(与 HistoryField.values 不同,按业务意义排列)。
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,
  HistoryField.t7,
  HistoryField.t8,
];

列顺序是业务定义的,不依赖枚举的声明顺序。这样即使将来 HistoryField 枚举重排,CSV 列顺序也保持稳定,用户已有的分析脚本不会失效。

时间戳并集

13 个字段的采样频率不同(流量计 2 秒一帧,温度传感器可能更慢),同一秒内可能只有部分字段有数据。导出时取所有字段时间戳的并集,按升序排列,每行对应一个时间戳:

// 汇总所有字段的唯一时间戳(精确到秒)。
final tsSet = <DateTime>{};
for (final field in _orderedFields) {
  final points = pointsByField[field];
  if (points == null) continue;
  for (final p in points) {
    // 精确到秒的去重
    tsSet.add(
      DateTime(
        p.timestamp.year,
        p.timestamp.month,
        p.timestamp.day,
        p.timestamp.hour,
        p.timestamp.minute,
        p.timestamp.second,
      ),
    );
  }
}

时间戳被截断到秒级再做去重——原始数据可能带毫秒,但 CSV 导出精度到秒足够。截断后用 Set 自动去重,最后 toList()..sort() 得到升序时间戳列表。

查找表与 NaN 输出空

为避免 O(n²) 遍历,先为每个字段构建”秒级时间戳 → 值”的查找表:

// 构建快速查找表:秒级时间戳 → 值
final lookup = <HistoryField, Map<DateTime, double>>{};
for (final field in _orderedFields) {
  final points = pointsByField[field];
  if (points == null) continue;
  final map = <DateTime, double>{};
  for (final p in points) {
    final sec = DateTime(
      p.timestamp.year,
      p.timestamp.month,
      p.timestamp.day,
      p.timestamp.hour,
      p.timestamp.minute,
      p.timestamp.second,
    );
    // 同一秒内后面的值覆盖前面的值
    map[sec] = p.value;
  }
  lookup[field] = map;
}

同一秒内若有多帧(罕见),后面的值覆盖前面的。生成行时,缺失或 NaN 的字段输出为空字符串:

for (final field in _orderedFields) {
  final v = lookup[field]?[ts];
  if (v != null && v.isFinite && !v.isNaN) {
    // 保留合理精度:温度保留 2 位,其余保留 2 位也足够
    parts.add(v.toStringAsFixed(2));
    rowHasValue = true;
    hasValidData = true;
  } else {
    parts.add('');
  }
}

判断条件 v != null && v.isFinite && !v.isNaN 三重保险:null 表示该字段这一秒没采样;NaN/Infinity 来自 Modbus 寄存器读取异常(浮点 NaN 会通过 JSON 透传)。这些值都输出为空,避免污染下游数据分析。

如果一整行所有字段都为空(rowHasValue == false),整行被跳过,不写入 CSV。最终如果整个文件没有任何有效数据(hasValidData == false),返回 null,调用方据此跳过该天的导出。

CRLF 行尾

final buf = StringBuffer(_header);
buf.write('\r\n');

for (final ts in timestamps) {
  // ...拼接一行...
  if (rowHasValue) {
    buf.write(parts.join(','));
    buf.write('\r\n');
  }
}

行尾用 \r\n(CRLF)而不是 \n(LF)。这是 Excel 在 Windows 上的默认期望——如果用 LF,Excel 打开时可能把整个 CSV 压成一行。CRLF 是 CSV 文件的事实标准,跨平台兼容性最好。

表头是硬编码的中文列名(瞬时流量(L/min)管内温度(°C) 等),配合前面提到的 UTF-8 BOM,Excel 打开后中文表头正确显示,列宽和单位一目了然。

小结

  • 条件导入 if (dart.library.html) 在编译期分流 Web/Mobile 实现,避免运行时平台判断和跨平台库冲突
  • Web 端用 Blob + AnchorElement 触发下载,download 属性决定文件名,revokeObjectUrl 释放内存
  • Android 公共 Downloads 目录通过 getExternalStorageDirectory 路径向上找 Android 锚点推导,比硬编码层级更鲁棒
  • 三级回退(公共 Downloads → 外部应用目录 → 内部文档目录)保证任何设备都能写成功
  • UTF-8 BOM 让 Excel 正确识别中文,用 writeAsBytes 手动拼接 BOM 字节是唯一可靠方式
  • CSV 多字段合并取时间戳并集,秒级去重,NaN 输出空,CRLF 行尾保证 Excel 兼容
  • 固定列顺序不依赖枚举声明顺序,保证 CSV 格式长期稳定

后续阅读