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):持久性介于两者之间,系统在存储压力下可能清理,但不会像临时目录那样频繁。适合接口响应缓存、缩略图、可重新下载的资源。应用应主动管理其大小。

选择判断可以归纳为一句话:数据丢了用户会不会痛?会痛放文档目录,不会痛放缓存目录,只是过程产物放临时目录。

三类目录的持久性与清理行为对比 文档目录 Documents 持久性:最高 系统清理:不主动 卸载删除:是 iCloud 备份:默认参与 典型内容 用户笔记 / 草稿 长期 JSON 数据 离线待同步队列 缓存目录 Cache 持久性:中等 系统清理:存储紧张时 卸载删除:是 iCloud 备份:不参与 典型内容 接口响应缓存 缩略图 / 封面 可重新下载资源 临时目录 Temporary 持久性:最低 系统清理:随时可能 卸载删除:是 iCloud 备份:不参与 典型内容 解压中间文件 下载分片 一次性导出缓冲 持久性递减 · 系统清理概率递增 · 数据可重建性递增

三、文本、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 对非法字符的容忍度不同。
不要把数据库文件、大体积媒体文件直接放在文档目录并参与 iCloud 备份,否则可能因备份体积过大被审核拒绝。可重建的大文件应放缓存目录,并在 Info.plist 中按需配置备份排除。

五、结果如何观察与判断

验证目录选择是否正确,可以在调试时打印实际路径:debugPrint((await getApplicationDocumentsDirectory()).path)。Android 上通常形如 /data/user/0/包名/app_flutter,iOS 上位于应用沙箱的 Documents 子目录。写入后调用 file.exists() 与 file.length() 确认落盘成功;读取时若返回 null,说明文件不存在或路径不一致,应优先检查目录获取方法是否与写入时一致。清理逻辑执行后,重新统计目录总大小,确认已降到阈值以下,即视为清理生效。