引言:
想象你开了一家国际快递公司。客户只需要说"帮我寄到文档仓库",至于这个仓库在俄国的 /var/documents 还是英国的 ~/Documents 还是法国的 AppData\Roaming,客户不关心。他只想寄到,你负责找到路。
这就是 path_provider 在做的事。你调用 getApplicationDocumentsDirectory(),它帮你在当前平台找到正确的路径。Android、iOS、macOS、Linux、Windows,五个"国家"各有各的文件系统规矩,但你面对的永远是同一个调度台。
今天我们走进这家快递公司的内部,看看调度台背后的架构。
一、公司架构:六个部门的分工
先来看看这家快递公司的组织架构。跟你想象的不一样,它不是一个铁板一块的整体,而是一个松散的联盟。
你可能以为一个 path_provider 就是一个包。但实际上它是六个包。就像一家跨国快递公司,虽然对外只有一个品牌名,内部却是总部加四个国家分公司的架构。
1. 总部和分公司
这家快递公司由六个部门组成。一个总调度台,一份国际服务协议,四个国家的分公司。
客户(开发者)只走进总调度台的大门。服务协议规定了"必须能寄到临时仓库、文档仓库、缓存仓库"等标准服务。至于各国分公司用卡车还是雪橇送货,总部不管。
为什么要这么拆?一个包不是更简单吗?
答案是:维护成本。如果所有平台的代码塞在一个包里,Android 改一行代码就要把 iOS、Linux、Windows 的测试全跑一遍。分成六个包之后,俄国分公司(Android)发新版,跟英国分公司(iOS)完全无关。各国的快递员也不需要懂别国的规矩,只管自己那一摊事。
2. 三层职责
| 层 | 对应角色 | 做什么 | 不做什么 |
|---|---|---|---|
| 总调度台 | path_provider |
接单、核验地址、包装快递单 | 不跑腿送货 |
| 服务协议 | platform_interface |
规定服务标准、审核加盟资质 | 不指定具体路线 |
| 各国分公司 | path_provider_linux 等 |
按本地规矩找到实际地址 | 不关心上游怎么包装 |
客户说"寄到文档仓库",总调度台接单,翻服务协议找到对应的标准接口,然后交给当前国家的分公司去跑腿。分公司跑完,把地址(路径字符串)回传给总调度台,总调度台在快递单上贴好标签(包装成 Directory 对象),交还给客户。
二、总调度台:接单与核验
架构看清楚了,接下来进入总调度台的办公室。它的工作手册只有一个文件:path_provider/lib/path_provider.dart。
打开这个文件你会发现,调度台的活儿比想象中简单得多。它不做任何"找路"的事,只做三件事:接单、转发、核验。
1. 每一单都是同一个流程
九个顶层函数,处理流程完全一样:
来看实际代码:
dart
---->[path_provider/lib/path_provider.dart#getTemporaryDirectory]----
PathProviderPlatform get _platform => PathProviderPlatform.instance; // tag1
Future<Directory> getTemporaryDirectory() async {
final String? path = await _platform.getTemporaryPath(); // tag2
if (path == null) { // tag3
throw MissingPlatformDirectoryException(
'Unable to get temporary directory'
);
}
return Directory(path); // tag4
}
tag1 处拿到当前国家的分公司实例。tag2 处问分公司:"临时仓库在哪?"。tag3 处核验:如果分公司说"没有这个地址",调度台就告诉客户出了问题。tag4 处把裸地址字符串贴上标签,变成一个可以直接操作的 Directory 快递单。
停下来想想:为什么要把 String 包装成 Directory?直接返回路径字符串不行吗?
当然行。但 Directory 对象自带 exists()、create()、list() 这些操作方法。包一层之后,你拿到的不只是一个地址,而是一个"可以直接操作的仓库钥匙"。就像快递单上不只写了地址,还附了仓库大门的门禁卡。这个包装的成本几乎为零(创建一个对象),换来的是 API 的便利性。
其余八个函数,getApplicationDocumentsDirectory、getApplicationSupportDirectory、getApplicationCacheDirectory 等等,结构完全一样,只是问分公司的问题不同。
2. 两种快递单的区别
有些单子保证送达(Future<Directory>),有些单子不保证(Future<Directory?>)。
dart
---->[path_provider/lib/path_provider.dart#getExternalStorageDirectory]----
Future<Directory?> getExternalStorageDirectory() async {
final String? path = await _platform.getExternalStoragePath();
if (path == null) {
return null; // tag1
}
return Directory(path);
}
tag1 处返回了 null 而不是抛异常。为什么?因为"外部存储"这个仓库只有 Android 这个"国家"才有。iOS 的国土上根本没有这种仓库。返回 null 不是快递丢了,而是"您要寄的目的地不存在于这个国家"。
而 getTemporaryDirectory 返回不可空,因为临时仓库每个国家都有。如果连临时仓库都找不到,那不是仓库的问题,是这个国家出了灾难性故障。
3. 调度台的报错方式
调度台有一个专门的异常类 MissingPlatformDirectoryException,就像快递公司的"投递失败回执单":
dart
---->[path_provider/lib/path_provider.dart#MissingPlatformDirectoryException]----
class MissingPlatformDirectoryException implements Exception {
MissingPlatformDirectoryException(this.message, {this.details});
final String message;
final Object? details;
}
为什么不用通用的 Exception?因为客户收到回执单时,需要一眼看出"这是地址找不到的问题",而不是跟其他各种杂乱的错误混在一起。专用回执单让问题诊断更快。
三、服务协议:加盟规则
调度台的逻辑看完了,你可能会好奇:分公司是怎么接入这个体系的?总部怎么保证分公司靠谱?
答案就在接口层。path_provider_platform_interface 这个包,就是快递公司的加盟协议书。它规定了分公司必须提供哪些服务,以及想加盟必须通过什么审核。
1. 协议内容
服务协议(PathProviderPlatform)规定了分公司必须能处理哪些类型的寄件:
dart
---->[path_provider_platform_interface/lib/path_provider_platform_interface.dart#PathProviderPlatform]----
abstract class PathProviderPlatform extends PlatformInterface {
PathProviderPlatform() : super(token: _token);
static final Object _token = Object(); // tag1
static PathProviderPlatform _instance = MethodChannelPathProvider(); // tag2
static set instance(PathProviderPlatform instance) {
PlatformInterface.verify(instance, _token); // tag3
_instance = instance;
}
Future<String?> getTemporaryPath() {
throw UnimplementedError('getTemporaryPath() has not been implemented.');
}
Future<String?> getApplicationSupportPath() { ... }
Future<String?> getApplicationDocumentsPath() { ... }
Future<String?> getDownloadsPath() { ... }
// ... 共 9 个方法
}
协议里列了九种仓库类型。每个方法的默认实现是抛 UnimplementedError,意思是:"如果你加盟了但没实现这个服务,就告诉客户你还没准备好。"
这里有一个设计细节值得注意:为什么用 abstract class 而不是 Dart 的 interface?
因为 abstract class 可以持有状态、可以有默认实现。PathProviderPlatform 需要携带 _token 和 _instance 这些私有字段,纯 interface 做不到这一点。更关键的是,它继承了 PlatformInterface,这让 token 验证机制成为可能。
另外,文档里写了一句很关键的话:"Platform implementations should extend this class rather than implement it"。为什么?因为如果分公司用 implements 实现协议,后续总部往协议里加新方法时,所有分公司的代码都会编译报错。用 extends 的话,新方法有默认实现(抛 UnimplementedError),旧分公司可以先不管,等准备好了再覆写。这是一种"不破坏现有加盟商"的向后兼容策略。
2. 加盟资质审核
tag1 处的 _token 和 tag3 处的 verify 是加盟审核机制。不是谁都能当分公司的。
只有通过 extends PathProviderPlatform 正式继承协议的类,才能通过验证。如果某个野路子包试图用 implements 冒充分公司,token 校验会把它拦在门外。
这就像快递加盟体系:你必须通过总部的资质审核,不能随便挂个牌子就说自己是顺丰。
3. 兜底分公司
tag2 处的默认实例 MethodChannelPathProvider 是什么?它是一个"万能分公司",什么国家都能送,但送得慢,因为每次都要打电话给当地的原生代理(MethodChannel)。
dart
---->[path_provider_platform_interface/lib/src/method_channel_path_provider.dart#MethodChannelPathProvider]----
class MethodChannelPathProvider extends PathProviderPlatform {
MethodChannel methodChannel =
const MethodChannel('plugins.flutter.io/path_provider');
@override
Future<String?> getTemporaryPath() {
return methodChannel.invokeMethod<String>('getTemporaryDirectory');
}
@override
Future<String?> getExternalStoragePath() {
if (!_platform.isAndroid) { // tag1
throw UnsupportedError('Functionality only available on Android');
}
return methodChannel.invokeMethod<String>('getStorageDirectory');
}
}
tag1 处可以看到,兜底分公司需要自己判断"当前在哪个国家"然后决定能不能送。这很笨重。正式的分公司不需要这个判断,因为它只在自己的国家运营。
这是历史遗留方案。现在各国分公司都已经独立运营了,这个万能分公司更多是兜底用的。
四、各国分公司:本地化派件
协议定好了,加盟审核也有了。接下来看各国分公司怎么落地执行。
每个国家的文件系统规则完全不同,分公司的实现方式也天差地别。有的要和操作系统底层打交道,有的只需要翻翻公开的地址簿。同一份协议,落地的姿势各不相同。
1. 英国分公司(iOS + macOS)
英国用 FFI 直接和 Foundation 框架对话,让本地快递员(NSSearchPathForDirectoriesInDomains)去找仓库地址:
dart
---->[path_provider_foundation/lib/src/path_provider_foundation_real.dart#PathProviderFoundation]----
class PathProviderFoundation extends PathProviderPlatform {
static void registerWith() { // tag1: 开业注册
PathProviderPlatform.instance = PathProviderFoundation();
}
@override
Future<String?> getTemporaryPath() async {
return _getDirectoryPath(NSSearchPathDirectory.NSCachesDirectory);
}
@override
Future<String?> getExternalStoragePath() async {
throw UnsupportedError( // tag2: 美国没有这种仓库
'getExternalStoragePath is not supported on this platform'
);
}
String? _getDirectoryPath(NSSearchPathDirectory directory) {
NSString? path = _getUserDirectory(directory);
if (path != null && _platformProvider.isMacOS) { // tag3
if (directory == NSSearchPathDirectory.NSApplicationSupportDirectory ||
directory == NSSearchPathDirectory.NSCachesDirectory) {
final NSString? bundleIdentifier =
NSBundle.getMainBundle().bundleIdentifier;
if (bundleIdentifier != null) {
final NSURL basePathURL = NSURL.fileURLWithPath(path);
path = basePathURL.URLByAppendingPathComponent(bundleIdentifier)?.path;
}
}
}
return path?.toDartString();
}
}
tag1 处是开业流程:App 启动时,Flutter 插件系统调用 registerWith,英国分公司正式挂牌营业。这个方法是怎么被调用的?Flutter 的 GeneratedPluginRegistrant 在 App 启动时会自动扫描所有注册的平台实现,依次调用各自的 registerWith。你不需要手动做任何事,插件系统帮你搞定了。
tag2 处很直接:英国没有"外部存储"这种仓库,客户问起来直接说"我们这儿没有"。注意它抛的是 UnsupportedError 而不是返回 null,语义是"永远不会支持",不是"暂时找不到"。
tag3 处是一个有趣的本地规则:macOS 的 ApplicationSupport 和 Caches 仓库是"共享园区",多家公司共用一栋楼。所以分公司必须用 bundleIdentifier 标出自己的楼层,防止跟别人的货混在一起。iOS 不需要,因为 iOS 天然独栋别墅(沙盒隔离)。
如果是你来设计,你会怎么处理"iOS 和 macOS 共享一个包但行为有差异"这件事?path_provider 的答案是一个 if (isMacOS) 分支。简单粗暴,但有效。因为差异只有这一处,不值得为此拆成两个包。
2. 法国分公司(Linux)
法国分公司最特别:它完全不需要原生快递员。Linux 的文件系统规范本身就是公开的(XDG 标准),地址簿直接写在环境变量里,分公司自己翻翻就行。
dart
---->[path_provider_linux/lib/src/path_provider_linux.dart#PathProviderLinux]----
class PathProviderLinux extends PathProviderPlatform {
final Map<String, String> _environment;
@override
Future<String?> getTemporaryPath() {
final String environmentTmpDir = _environment['TMPDIR'] ?? ''; // tag1
return Future<String?>.value(
environmentTmpDir.isEmpty ? '/tmp' : environmentTmpDir
);
}
@override
Future<String?> getApplicationSupportPath() async {
final directory = Directory(
path.join(xdg.dataHome.path, await _getId()) // tag2
);
if (directory.existsSync()) {
return directory.path;
}
// tag3: 旧仓库地址兼容
final legacyDirectory = Directory(
path.join(xdg.dataHome.path, await _getExecutableName())
);
if (legacyDirectory.existsSync()) {
return legacyDirectory.path;
}
await directory.create(recursive: true); // tag4
return directory.path;
}
@override
Future<String?> getDownloadsPath() {
return Future<String?>.value(
xdg.getUserDirectory('DOWNLOAD')?.path // tag5
);
}
}
tag1 处翻环境变量拿临时仓库地址,没写的就默认用 /tmp。注意这里是同步返回的(Future.value),因为读环境变量不需要 async,但为了遵守协议的 Future<String?> 签名,还是包了一层 Future。
tag2 处按 XDG 规范,用 ~/.local/share 加上应用 ID 拼出地址。XDG Base Directory 是 Linux 桌面环境的公约,XDG_DATA_HOME 默认是 ~/.local/share,XDG_CACHE_HOME 默认是 ~/.cache。法国分公司不需要调用任何原生 API,读这些环境变量就够了。
tag3 处是一个"老客户照顾"逻辑,值得展开说。旧版分公司用可执行文件名当仓库门牌号,新版改用 application ID(通常是反向域名格式,如 com.example.myapp)。问题是:如果老客户的货还存在旧仓库里怎么办?直接用新门牌号,旧仓库里的数据就"消失"了。所以这里做了一个优雅的降级:先查新地址,再查旧地址,都没有才创建新的(tag4)。
tag5 处问的是用户自定义的下载目录。Linux 允许用户把下载目录设在任意位置(比如挂载的外接硬盘),所以要调用 xdg-user-dir 去查。如果这个工具没装?返回 null,表示"地址暂时查不到"。这就是前面说的三种失败里的第一种:不是不支持,是暂时查不到。
3. 五国仓库对照表
同一个"文档仓库"在五个国家的实际地址:
五个完全不同的底层调用,一个统一的入口。快递公司存在的意义就在这里。
五、三种投递失败
快递公司处理投递失败的方式很讲究。不是所有失败都一样,不同的失败要给客户不同的反馈:
| 失败类型 | 快递公司的说法 | 技术实现 | 例子 |
|---|---|---|---|
| 仓库暂时找不到 | "地址存在但今天路断了,您稍后再试" | 返回 null |
Linux 上 xdg-user-dir 没装 |
| 这个国家没有这种仓库 | "对不起,我们在俄国不提供SD卡仓库服务" | 抛 UnsupportedError |
iOS 上调用 getExternalStoragePath |
| 仓库应该在但系统故障 | "仓库明明在那,但我们进不去,请联系管理员" | 抛 MissingPlatformDirectoryException |
临时目录无法获取 |
这三种区分让客户(调用者)可以精确决策:"暂时找不到"我可以等一等或者提示用户安装缺失工具,"不支持"我需要换一个方案或者干脆不展示这个功能,"系统故障"我应该报警并上报错误日志。
如果三种情况都返回 null,调用者就懵了:到底是我该等一等,还是该放弃,还是该报 bug?精确的失败语义就像医院的诊断报告,"感冒"和"骨折"虽然都是"不舒服",但治疗方案完全不同。
学到了什么
-
三层分离的架构:调度台只接单包装,服务协议只定规矩,分公司只管本地派件。每一层只做自己的事,不越界。这让六个平台可以独立开发、独立发版、独立测试,贡献者只需要关注自己熟悉的国家。
-
加盟资质审核(Token 验证) :一个
Object()实例 +PlatformInterface.verify,成本几乎为零,但杜绝了冒牌分公司劫持寄件地址的风险。设计可替换单例时值得借鉴。 -
精确的失败语义:null、UnsupportedError、自定义异常,三种失败模式三种含义。好的 API 不只是告诉你"失败了",还要告诉你"为什么失败"和"你该怎么办"。
-
向后兼容是框架作者的责任:法国分公司多写几行代码查旧仓库,换来老客户升级时数据不丢失。用户不应该为框架的内部重构买单。
碎碎念
path_provider 大概是你最早用的 Flutter 插件之一,也是最没存在感的那个。它不炫技,不花哨,就默默帮你在五个国家找仓库地址。
但正是这种"无聊"的基础设施,最能看出设计功底。六个包的拆分不是为了凑数量,token 验证不是过度设计,三种失败语义不是吹毛求疵。每一个看似多余的决策,都是在为"百万开发者安静使用"这件事铺路。
我特别喜欢法国分公司那个向后兼容的细节。多查一次旧目录,多写几行代码,换来的是用户升级时数据不丢失、不迁移、不报错。这种"润物细无声"的关怀,比任何花哨的新功能都值得学习。
好的快递公司,就是让你只管寄件,不操心路线。好的基础设施,就是让你感觉不到它的存在。