4. 文件系统与路径管理
在 Flutter 里做本地存储,第一件容易踩坑的事不是“怎么写文件”,而是“文件该写到哪里”。移动端应用运行在沙箱环境中,不同平台对可写目录的划分并不相同,而 path_provider 正是把这些平台差异统一成几个语义明确的目录入口。理解临时目录、文档目录、缓存目录的区别,再配合 dart:io 完成读写与清理,才能让离线数据既可靠又不失控。
一、path_provider 与 dart:io 的分工
path_provider 只负责“拿到路径”,它不负责读写。它返回的是 Directory 对象,真正的文件操作交给 dart:io 的 File、Directory、RandomAccessFile 等类完成。两者是典型的“定位 + 操作”组合:先用 getApplicationDocumentsDirectory() 等方法确定位置,再用 File(path).writeAsString() 之类的方法落盘。
需要特别注意的是,path_provider 的多数方法返回 Future,必须在异步上下文中调用;而 dart:io 在 Web 平台不可用,如果项目要兼容 Web,需要把文件存储逻辑隔离到条件导入或平台判断之后。
二、三类目录的语义与选择
三类目录的核心差异在于“系统是否可能自动清理”以及“是否随应用卸载而删除”。
- 临时目录(getTemporaryDirectory):系统在空间紧张或应用退出后可能随时清理,适合存放可再生成的中间文件,例如解压中的临时包、下载分片、图片解码缓存。绝不能把用户唯一数据放这里。
- 文档目录(getApplicationDocumentsDirectory):应用私有、持久保存,只有卸载应用才会删除。适合用户笔记、离线草稿、需要长期保留的 JSON 数据。iOS 上该目录默认参与 iCloud 备份,因此大文件或可重建数据不宜放这里。
- 缓存目录(getApplicationCacheDirectory):持久性介于两者之间,系统在存储压力下可能清理,但不会像临时目录那样频繁。适合接口响应缓存、缩略图、可重新下载的资源。应用应主动管理其大小。
选择判断可以归纳为一句话:数据丢了用户会不会痛?会痛放文档目录,不会痛放缓存目录,只是过程产物放临时目录。
三、文本、JSON、二进制文件的读写
三类文件在 dart:io 中的处理方式不同,下面给出可直接参考的实现片段。
import 'dart:io';
import 'dart:convert';
import 'package:path_provider/path_provider.dart';
// 1. 文本文件:写入与读取
Future<File> writeText(String name, String content) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$name.txt');
await file.writeAsString(content, flush: true);
return file;
}
Future<String?> readText(String name) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$name.txt');
if (!await file.exists()) return null;
return file.readAsString();
}
// 2. JSON 文件:编码后写入,读取后解码
Future<void> writeJson(String name, Map<String, dynamic> data) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$name.json');
await file.writeAsString(jsonEncode(data), flush: true);
}
Future<Map<String, dynamic>?> readJson(String name) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$name.json');
if (!await file.exists()) return null;
final raw = await file.readAsString();
return jsonDecode(raw) as Map<String, dynamic>;
}
// 3. 二进制文件:字节写入与读取
Future<void> writeBytes(String name, List<int> bytes) async {
final dir = await getApplicationCacheDirectory();
final file = File('${dir.path}/$name.bin');
await file.writeAsBytes(bytes, flush: true);
}
Future<List<int>?> readBytes(String name) async {
final dir = await getApplicationCacheDirectory();
final file = File('${dir.path}/$name.bin');
if (!await file.exists()) return null;
return file.readAsBytes();
}
几个关键点值得强调:flush: true 会强制把缓冲区数据刷入磁盘,适合对可靠性要求高的写入;writeAsString 默认使用 UTF-8,中文不会乱码;JSON 写入前必须 jsonEncode,读取后必须 jsonDecode,否则存进去的是 Dart 的 toString() 结果,无法还原结构。二进制场景下,readAsBytes 返回 Uint8List,可直接交给 Image.memory 或解密逻辑。
四、清理策略与目录大小管理
缓存目录不会自动“按你的意愿”清理,应用需要自己负责。常见做法是记录每个缓存文件的写入时间,在启动或空闲时扫描并删除超过阈值的文件。下面是一个按“最后修改时间 + 总大小”双重条件清理的示例:
Future<void> cleanCache({Duration maxAge = const Duration(days: 7), int maxBytes = 50 * 1024 * 1024}) async {
final dir = await getApplicationCacheDirectory();
if (!await dir.exists()) return;
final files = <FileSystemEntity>[];
await for (final e in dir.list(recursive: true, followLinks: false)) {
if (e is File) files.add(e);
}
final now = DateTime.now();
// 先按时间清理
for (final f in files) {
final stat = await f.stat();
if (now.difference(stat.modified) > maxAge) {
await f.delete();
}
}
// 再按总大小清理,从最旧开始删
final remain = <File>[];
for (final e in files) {
if (e is File && await e.exists()) remain.add(e);
}
remain.sort((a, b) => (await a.stat()).modified.compareTo((await b.stat()).modified));
var total = 0;
for (final f in remain) {
total += (await f.stat()).size;
}
for (final f in remain) {
if (total <= maxBytes) break;
total -= (await f.stat()).size;
await f.delete();
}
}
临时目录的清理更简单:可以在应用启动时直接清空,因为其中的数据本就可重建。文档目录则要谨慎,删除前应确认数据是否已同步到服务端,避免误删用户唯一副本。
'$dir/$name' 之外的复杂逻辑,跨平台时建议使用 path 包的 join(),它能正确处理分隔符差异。文件名避免使用 :、*、? 等字符,iOS 与 Android 对非法字符的容忍度不同。Info.plist 中按需配置备份排除。五、结果如何观察与判断
验证目录选择是否正确,可以在调试时打印实际路径:debugPrint((await getApplicationDocumentsDirectory()).path)。Android 上通常形如 /data/user/0/包名/app_flutter,iOS 上位于应用沙箱的 Documents 子目录。写入后调用 file.exists() 与 file.length() 确认落盘成功;读取时若返回 null,说明文件不存在或路径不一致,应优先检查目录获取方法是否与写入时一致。清理逻辑执行后,重新统计目录总大小,确认已降到阈值以下,即视为清理生效。