「Flutter 文件保存太难了?」一个插件打通 7 大平台,我把方案开源了 🎉

「Flutter 文件保存太难了?」一个插件打通 7 大平台,我把方案开源了 🎉

还在为「用户找不到保存的文件」而头疼?还在为 Android 10+ 的 MediaStore 适配而抓狂?public_file_saver 一个插件搞定 Android / iOS / macOS / Web / Windows / Linux / 鸿蒙的文件保存,支持直接保存、系统对话框、本地文件和 URL 下载四种姿势。

前言:文件保存为什么这么难?

做 Flutter 开发的兄弟们应该都踩过这个坑:应用里生成了一个文件,想让用户能在系统里找到它,结果发现这件事远没有想象中简单。

  • path_provider 拿到的 getApplicationDocumentsDirectory()应用私有目录,用户根本看不到,还得自己想办法调起系统文件管理器或者分享面板;
  • Android 10 之后 /storage/emulated/0/Downloads 不能直接写入了,必须走 MediaStore
  • 想弹一个系统「另存为」对话框,各平台的 API 五花八门:Android 是 SAF(Storage Access Framework),iOS 是 UIDocumentPickerViewController,Windows 是 COM 接口 IFileSaveDialog,Linux 是 GTK 的 GtkFileChooserNative......
  • 更别说还有 Web 端(浏览器沙箱)、macOS 沙盒、鸿蒙 DocumentViewPicker 这些大坑。

网上现成的方案,file_saver 之类的插件维护频率一言难尽,桌面端和鸿蒙更是基本空白。于是我干脆自己造了个轮子:public_file_saver ,目前已经迭代到 1.2.0全平台覆盖,今天把使用姿势完整分享给大家。

一、插件能力速览 ✨

public_file_saver 提供了 4 个核心 API:

API 作用 弹对话框?
saveBytes() Uint8List 二进制数据保存到公开位置
saveBytesWithDialog() 通过系统文件选择器让用户自己选保存位置
saveFile() 保存本地 File 对象(可选对话框) 可选
saveFromUrl() 从 URL 下载文件并保存(可选对话框) 可选

其他亮点:

  • ✅ 自动清洗非法文件名字符(包括 Windows 保留设备名 con/prn/aux 这种冷门坑)
  • ✅ 自动推断 MIME 类型,支持解析 Content-Disposition(含 RFC 5987 的 filename*=UTF-8'' 编码)
  • ✅ 所有平台统一返回 PublicSavedFile 结构
  • ✅ 文件名冲突自动追加 (1)(2) 后缀,不会静默覆盖
  • ✅ iOS 端同时支持 CocoaPods 和 Swift Package Manager
  • ✅ 大文件写入全部走后台线程 + 原子写入,不卡 UI、不产生半截文件

平台支持矩阵:

功能 Android iOS macOS Web Windows Linux 鸿蒙
saveBytes()
saveBytesWithDialog()
saveFile()
saveFromUrl()

二、安装

pubspec.yaml 中添加依赖:

yaml 复制代码
dependencies:
  public_file_saver: ^1.2.0

然后:

bash 复制代码
flutter pub get

平台特定配置(大多数平台零配置)

Android :插件已声明 WRITE_EXTERNAL_STORAGEmaxSdkVersion="28",仅作用于 Android 9 及以下)。Android 10+ 走 MediaStore,无需任何权限 ;Android 6--9 需要在调用前用 permission_handler 自行申请运行时权限。

iOS :如果希望文件能在「文件」App 中看到,在 Info.plist 里加两行:

xml 复制代码
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>

macOS:沙盒应用(Mac App Store 默认)需要在 entitlements 里开启用户选择文件读写:

xml 复制代码
<key>com.apple.security.files.user-selected.read-write</key>
<true/>

Web / Windows / Linux / 鸿蒙:零配置,开箱即用。

三、快速上手:四种保存姿势

1. 直接保存字节数据

不弹对话框,直接存到系统公开目录(Android 的 Downloads、Windows 的下载文件夹、iOS 的文档目录等):

dart 复制代码
import 'dart:convert';
import 'dart:typed_data';
import 'package:public_file_saver/public_file_saver.dart';

final fileSaver = PublicFileSaver();

final bytes = Uint8List.fromList(utf8.encode('你好,掘金!'));
final result = await fileSaver.saveBytes(
  bytes: bytes,
  fileName: 'hello.txt',
  mimeType: 'text/plain',
  subDir: 'MyApp', // 可选:保存到 Downloads/MyApp/hello.txt
);

if (result != null && result.isSuccess) {
  print('已保存: ${result.fileName}');
  print('URI: ${result.uri}');
  print('路径: ${result.path}');
}

2. 弹系统对话框,让用户选位置

这个场景很常见:导出数据、保存报告,让用户自己决定放哪:

dart 复制代码
final jsonData = {'name': '测试', 'value': 123};
final bytes = Uint8List.fromList(utf8.encode(jsonEncode(jsonData)));

final result = await fileSaver.saveBytesWithDialog(
  bytes: bytes,
  fileName: 'data.json',
  mimeType: 'application/json',
);

if (result != null && result.isSuccess) {
  print('用户保存到: ${result.path ?? result.uri}');
} else {
  print('用户取消或保存失败'); // 返回 null 说明用户点了取消
}

3. 保存本地文件

适合「应用沙盒里已经有一个文件,想转存到公开目录」的场景,一行搞定,还支持重命名:

dart 复制代码
import 'dart:io';

final file = File('/path/to/document.pdf');

// 直接转存,自动推断 MIME 类型
final result = await fileSaver.saveFile(
  file: file,
  subDir: 'Documents',
);

// 或者弹对话框转存并重命名
final result2 = await fileSaver.saveFile(
  file: file,
  fileName: 'renamed_document.pdf',
  useDialog: true,
);

4. 从 URL 下载并保存

文件名、MIME 类型都会自动从响应头推断,不需要自己写下载逻辑:

dart 复制代码
try {
  final result = await fileSaver.saveFromUrl(
    url: 'https://example.com/document.pdf',
    subDir: 'Downloads',
    timeout: Duration(seconds: 30), // 可选:超时抛 TimeoutException
  );

  if (result != null && result.isSuccess) {
    print('下载并保存成功: ${result.fileName}');
  }
} catch (e) {
  print('下载失败: $e'); // 网络错误会抛异常
}

四、返回值 PublicSavedFile:平台差异要心里有数

所有 API 都返回 PublicSavedFile?

dart 复制代码
class PublicSavedFile {
  final String fileName;  // 保存的文件名
  final String? uri;      // 保存文件的 URI(取决于平台)
  final String? path;     // 文件系统路径(取决于平台)

  bool get isSuccess => fileName.isNotEmpty || uri != null || path != null;
}

这里有一个新手容易懵的点 :不同平台的 uripath 字段填充规则不一样,写跨平台代码时建议优先用 isSuccess 判断,展示位置用 path ?? uri

平台 uri path
Android 10+ content:// URI null
Android 9- null 完整文件路径
iOS(直接保存) null 完整文件路径
iOS(对话框) file:// URL 完整文件路径
macOS file:// URL 完整文件路径
Web null null(浏览器不暴露,只有 fileName
Windows null 完整文件路径
Linux file:// URL 完整文件路径
鸿蒙 文件 URI 转换后的路径

💡 Web 端出于隐私原因,浏览器永远不会把真实保存路径暴露给 JavaScript,所以插件在 Web 上用「非空 fileName」来判断成功。

五、踩坑实录:这些细节你可能想不到 🕳️

下面是我开发过程中真实踩过的坑,每一个都值得单独说道说道。

1. Android 10+ 必须走 MediaStore

Android 10 开始,应用直接往公共目录写文件会被拒绝。插件内部做了版本分流:Android 10+ 走 MediaStore.Downloads ,插入 ContentValues 时标记 IS_PENDING=1,写完再置回 0。这里有两个隐蔽的坑:

  • 部分国产 ROM 上只更新 IS_PENDING 会导致文件一直「隐身」,所以必须同时重新 put DISPLAY_NAME/MIME_TYPE/RELATIVE_PATH
  • 写入失败时要把 pending 的 MediaStore 行删掉,否则相册/文件管理器里会永久残留一个打不开的幽灵文件。

这些在 1.2.0 里都已经处理好了。

2. Web 端「先下载再弹框」会直接报 SecurityError

这是最坑的一个:浏览器的 showSaveFilePicker(File System Access API)要求必须在用户点击手势的同步调用栈里触发 ,否则会抛 SecurityError

saveFromUrl() 的场景天然是「先网络下载、后弹保存框」------如果老老实实等 HTTP 请求返回再调 showSaveFilePicker,用户的「瞬时激活(transient user activation)」早就过期了。

解决方案:先同步弹框拿句柄,再异步下载写入 。所以你会看到插件内部有一个 saveBytesWithDialogDeferred 的接口------对话框先行,数据后续到达。这也是 Web 端代码里最巧妙的一处设计。

另外,Web 上创建的 object URL 不能立刻 revoke,Safari 会直接中断下载,所以要延迟释放------又是一个浏览器兼容性坑。

3. 文件名清洗,比你想的更复杂

非法字符替换 \ / : * ? " < > | 只是基本功,真正冷门的是:

  • Windows 保留设备名conprnauxnulcom1~9lpt1~9,即使带扩展名(如 con.txt)也不能用,插件会自动加 _ 前缀;
  • 尾随的点和空格 :Windows 上 file.txt. 会出问题,要替换;
  • 长度限制是字节数不是字符数 :文件系统组件名上限 255 字节,插件按 UTF-8 字节数截断到 240,并且按完整 Unicode 字符边界截断,避免把一个多字节汉字劈成两半产生非法 UTF-8。
dart 复制代码
final safeName = PublicFileSaver.sanitizeFileName('file:name?.txt');
print(safeName); // file_name_.txt

4. subDir 的目录穿越防护

subDir 参数允许用户指定公共目录下的子目录,但必须防一手 ../../etc/passwd 之类的路径穿越。插件会拒绝绝对路径和 .. 段,只保留相对段:

dart 复制代码
fileSaver.saveBytes(
  bytes: bytes,
  fileName: 'a.txt',
  subDir: '../evil', // ❌ 直接抛 ArgumentError
);

5. 大文件写入的工程细节

下载文件动辄几十上百 MB,如果都在主线程写:

  • Android 上会 ANR,iOS/macOS 上会卡 UI;
  • 写一半进程被杀,会留下半截损坏文件

所以插件在 Android/iOS/macOS 上全部后台线程 + 原子写入(先写临时文件再 rename),并且 method channel 的结果回调会切回主线程投递。

6. 重名文件:拒绝静默覆盖

鸿蒙端早期版本保存同名文件会直接覆盖旧文件(其他平台都是追加 (1)(2) 后缀),用户数据可能莫名其妙丢失。1.2.0 已统一行为:所有平台重名都自动追加序号

六、和其他方案对比

方案 保存字节 系统对话框 URL 下载 桌面端 鸿蒙 维护状态
public_file_saver ✅ Windows/macOS/Linux 活跃
file_saver 部分支持 更新缓慢
file_picker 部分平台 ✅(主打选文件) 活跃
path_provider 仅应用私有目录 官方
saver_gallery/gal 仅图片到相册 活跃

简单来说:

  • 只想把图片/视频存进系统相册 → saver_gallery 更对口;
  • 只是选文件(读)→ file_picker 是标杆;
  • 要把任意类型文件保存到用户可见位置、还要跨 7 个平台public_file_saver

七、架构简析:一套 Dart API 如何打通 7 个平台

插件遵循 Flutter 官方推荐的 platform interface 模式,架构非常干净:

graph TD A[&#34;PublicFileSaver(Dart 统一入口)<br/>文件名清洗 / MIME 推断 / 下载逻辑&#34;] --> B[&#34;PublicFileSaverPlatform(接口)&#34;] B --> C[&#34;MethodChannel 实现<br/>Android / iOS / macOS /<br/>Windows / Linux / 鸿蒙&#34;] B --> D[&#34;纯 Dart 实现<br/>PublicFileSaverWeb&#34;] C --> E[&#34;原生 API:<br/>MediaStore / SAF /<br/>UIDocumentPicker / NSSavePanel /<br/>IFileSaveDialog / GtkFileChooserNative /<br/>DocumentViewPicker&#34;] D --> F[&#34;浏览器 API:<br/>Blob 下载 / showSaveFilePicker&#34;]

两个值得注意的工程细节:

  1. Web 端用条件导入绕开 dart:iosaveFile(File) 依赖 dart:io,而 Web 平台根本没有 dart:io。插件通过 import 'src/io_compat_web.dart' if (dart.library.io) 'src/io_compat.dart' 做条件导入,Web 上调用 saveFile 会抛出明确的 UnsupportedError(提示改用 saveBytes),而不是编译都过不了。
  2. Web 端是纯 Dart 实现,不注册 method channel,直接操作浏览器 API------这也是为什么 Web 端能优先用原生「另存为」对话框,不支持 File System Access API 的浏览器(如 Firefox)则优雅回退为普通下载。

八、完整实战:一个文件导出 Demo

下面是一个可以直接跑的小 Demo:生成文本、JSON、下载 PDF,三种方式各一个按钮:

dart 复制代码
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:public_file_saver/public_file_saver.dart';

class SaveFileExample extends StatefulWidget {
  @override
  _SaveFileExampleState createState() => _SaveFileExampleState();
}

class _SaveFileExampleState extends State<SaveFileExample> {
  final _fileSaver = PublicFileSaver();
  String _status = '准备就绪';

  Future<void> _saveTextFile() async {
    final bytes = Uint8List.fromList(
      utf8.encode('来自 Flutter 的问候!\n时间戳: ${DateTime.now()}'),
    );

    final result = await _fileSaver.saveBytes(
      bytes: bytes,
      fileName: 'flutter_demo.txt',
      mimeType: 'text/plain',
    );

    setState(() {
      _status = result != null && result.isSuccess
          ? '已保存: ${result.fileName}\n位置: ${result.path ?? result.uri}'
          : '保存失败或已取消';
    });
  }

  Future<void> _saveWithDialog() async {
    final data = {
      'message': '你好',
      'timestamp': DateTime.now().toIso8601String(),
    };
    final bytes = Uint8List.fromList(utf8.encode(jsonEncode(data)));

    final result = await _fileSaver.saveBytesWithDialog(
      bytes: bytes,
      fileName: 'data.json',
      mimeType: 'application/json',
    );

    setState(() {
      _status = result?.isSuccess == true
          ? '保存到: ${result!.path ?? result.uri}'
          : '已取消';
    });
  }

  Future<void> _downloadAndSave() async {
    try {
      final result = await _fileSaver.saveFromUrl(
        url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
        useDialog: true,
      );

      setState(() {
        _status = result?.isSuccess == true ? '已下载: ${result!.fileName}' : '失败';
      });
    } catch (e) {
      setState(() {
        _status = '错误: $e';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text(_status),
        SizedBox(height: 20),
        ElevatedButton(
          onPressed: _saveTextFile,
          child: Text('保存文本文件'),
        ),
        ElevatedButton(
          onPressed: _saveWithDialog,
          child: Text('通过对话框保存'),
        ),
        ElevatedButton(
          onPressed: _downloadAndSave,
          child: Text('下载并保存'),
        ),
      ],
    );
  }
}

九、错误处理约定

保持简单,两条规则:

  1. 用户取消对话框 / 保存失败 → 返回 null
  2. 网络错误 → 抛异常 (仅 saveFromUrl),比如 HTTP 非 200、超时、连接失败。
dart 复制代码
try {
  final result = await fileSaver.saveFromUrl(url: 'https://example.com/file.pdf');
} catch (e) {
  // 这里处理网络层错误
  print('下载失败: $e');
}

总结

文件保存这个需求看着小,做深了全是坑:MediaStore 版本分流、浏览器瞬时激活、Windows 保留设备名、沙盒重定向、原子写入......public_file_saver 把这些坑全部填平,给你一个全平台统一、行为一致的保存 API。

如果你正在做导出、下载、报告生成之类的功能,不妨试试它:

相关推荐
坚果的博客6 小时前
Flutter 三方库 phone_state 的 OpenHarmony 适配实战
flutter
大雷神6 小时前
HarmonyOS ArkGraphics 2D 自定义字体实操:注册字体并验证中文回退
pytorch·华为·harmonyos
Nayana8 小时前
《Web 到 HarmonyOS》-- 第一课:前端经验哪些能带走
vue.js·harmonyos
大雷神8 小时前
HarmonyOS ArkGraphics 2D 复杂文本排版实操:用 ParagraphBuilder 做资讯阅读卡片
华为·harmonyos
大雷神8 小时前
HarmonyOS ArkGraphics 2D NativeImage 实操:获取 NativeWindow 与 SurfaceId
华为·harmonyos
小玮看世界9 小时前
当“教用户配置系统“成为产品的遮羞布:从鸿蒙拦截栈与小红书联系机制看科技公司的“驯化式创新“
科技·华为·harmonyos
梦想不只是梦与想9 小时前
鸿蒙 应用类型:企业应用
华为·harmonyos·企业应用
大雷神9 小时前
HarmonyOS ArkGraphics 2D 文本绘制实操:用 TextBlob 做动态文字排版
华为·harmonyos
搬砖的kk9 小时前
从 0 到 1:用 KMP + Compose Multiplatform + Ktor 实现鸿蒙「历史上的今天」应用
开源·harmonyos