跨平台文件保存的挑战
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.dart 用 dart:html 的 Blob + 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;
}
几个细节值得注意:
- Blob 的 MIME 类型显式声明
text/csv;charset=utf-8,避免某些浏览器把 CSV 当纯文本打开而非下载 download属性告诉浏览器这是下载链接,文件名由该属性决定,而不是 URL- 先加到 DOM 再 click——部分浏览器(尤其 Firefox)要求 anchor 必须在文档树中才能触发下载,
style.display='none'保证用户看不到它 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 格式长期稳定