「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_STORAGE(maxSdkVersion="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;
}
这里有一个新手容易懵的点 :不同平台的 uri 和 path 字段填充规则不一样,写跨平台代码时建议优先用 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会导致文件一直「隐身」,所以必须同时重新 putDISPLAY_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 保留设备名 :
con、prn、aux、nul、com1~9、lpt1~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 模式,架构非常干净:
两个值得注意的工程细节:
- Web 端用条件导入绕开
dart:io:saveFile(File)依赖dart:io,而 Web 平台根本没有dart:io。插件通过import 'src/io_compat_web.dart' if (dart.library.io) 'src/io_compat.dart'做条件导入,Web 上调用saveFile会抛出明确的UnsupportedError(提示改用saveBytes),而不是编译都过不了。 - 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('下载并保存'),
),
],
);
}
}
九、错误处理约定
保持简单,两条规则:
- 用户取消对话框 / 保存失败 → 返回
null; - 网络错误 → 抛异常 (仅
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。
如果你正在做导出、下载、报告生成之类的功能,不妨试试它:
- 📦 pub.dev: pub.dev/packages/pu...
- 📖 GitHub: github.com/Chihiro-bit...
- 🐛 遇到问题欢迎提 Issue,PR 也随时欢迎!