引言:
上一篇我们看了快递公司的整体架构:总调度台、服务协议、加盟审核。你已经知道了"系统怎么运转"。但有一个问题还没回答:各个分公司到底是怎么找到仓库地址的?
Android 分公司用 JNI 打电话给 Java 层的 Context。iOS/macOS 分公司用 FFI 直接调用 Objective-C 的 Foundation。Linux 分公司翻环境变量就够了。Windows 分公司要跟 Win32 API 周旋,还得自己从可执行文件的版本信息里读取公司名。
同一份服务协议,四种完全不同的本地化策略。今天我们逐个拆开看,比一比各个分公司的"跑腿方式"到底有多不一样。
一、Android 分公司:JNI 之路
Android 是 Flutter 最早支持的平台,也是用户量最大的平台。它的分公司经历了从 MethodChannel 到 JNI 的技术升级,跑腿方式在几个分公司里算是最"重"的。
这个"重"不是说代码多,而是跨语言通信本身带来的资源管理负担。来看看它到底怎么跟 Java 世界打交道。
1. 通信方式的演进
早期所有平台分公司都共用一个"万能翻译官":MethodChannel。Dart 说一句,等原生侧回一句,中间有序列化和反序列化的开销,就像打国际长途电话,每句话都要等翻译。
现在它用 JNI(Java Native Interface)直接调用 Java 对象,就像分公司的快递员学会了当地语言,不再需要翻译官。入口文件里那个条件导出就是切换机制:
dart
---->[path_provider_android/lib/path_provider_android.dart]----
export 'src/path_provider_android_stub.dart'
if (dart.library.ffi) 'src/path_provider_android_real.dart';
如果当前环境支持 FFI(比如真机和模拟器),就用真正的 JNI 实现。如果不支持(比如 Web),就导出一个空壳 stub,避免编译报错。
2. 核心实现
俄国分公司的所有操作都围绕一个 Android 核心概念:Context。在 Android 的世界里,Context 就是你的"身份证",有了它才能问操作系统要各种资源。
dart
---->[path_provider_android/lib/src/path_provider_android_real.dart#PathProviderAndroid]----
class PathProviderAndroid extends PathProviderPlatform {
late final Context _applicationContext =
androidApplicationContext.as(Context.type); // tag1
static void registerWith() {
PathProviderPlatform.instance = PathProviderAndroid();
}
@override
Future<String?> getTemporaryPath() {
return getApplicationCachePath(); // tag2
}
@override
Future<String?> getApplicationSupportPath() async {
final File? file = _applicationContext.filesDir; // tag3
final String? path = file?.path?.toDartString(releaseOriginal: true);
file?.release(); // tag4
return path;
}
@override
Future<String?> getApplicationCachePath() async {
final File? file = _applicationContext.cacheDir; // tag5
final String? path = file?.path?.toDartString(releaseOriginal: true);
file?.release();
return path;
}
}
tag1 处拿到 Android 的 Application Context。androidApplicationContext 来自 jni_flutter 包,它在 Dart 侧暴露了 Flutter 引擎持有的全局 Context 引用。
tag2 处揭示了一个有趣的事实:Android 上"临时目录"和"缓存目录"是同一个地方,都指向 context.getCacheDir()。这跟 iOS 不同,iOS 的 temp 和 cache 是两个不同的目录。
tag3 处调用 context.getFilesDir(),对应 Android 上的 /data/data/包名/files。tag5 处调用 context.getCacheDir(),对应 /data/data/包名/cache。
3. JNI 的资源管理
tag4 处的 file?.release() 值得注意。JNI 创建的 Java 对象在 Dart 侧是一个"引用",用完之后必须手动释放,否则会内存泄漏。这跟 Dart 的垃圾回收不同,GC 管不到 JNI 层面的 Java 对象。
每个方法里都有 release() 调用,看起来啰嗦,但这是跨语言编程的代价。就像你在国外快递仓库借了一辆推车,用完必须还回去,不能指望当地的清洁工帮你收。
4. 外部存储和 StorageDirectory
Android 比其他平台多了一个独特概念:外部存储。手机可能有多个存储分区,甚至插了 SD 卡。getExternalStorageDirectories 支持按类型查询:
dart
---->[path_provider_android/lib/src/path_provider_android_real.dart#PathProviderAndroid]----
@override
Future<List<String>?> getExternalStoragePaths({StorageDirectory? type}) async {
final JString? directory =
type != null ? _toNativeStorageDirectory(type) : null; // tag1
final JArray<File?>? files =
_applicationContext.getExternalFilesDirs(directory); // tag2
directory?.release();
if (files != null) {
final List<String> paths = _toStringList(files);
files.release();
return paths;
}
return null;
}
tag1 处把 Dart 的 StorageDirectory 枚举转换为 Android 的 Environment.DIRECTORY_* 常量。tag2 处调用 context.getExternalFilesDirs(type),返回所有存储分区上对应类型的目录。
StorageDirectory 枚举是接口层定义的,但只有 Android 用得上。其他平台调用带 StorageDirectory 参数的方法会直接抛 UnsupportedError。这是一个为单一平台开的"特别通道",虽然接口层声明了它,但实际上只有一个分公司能处理。
5. getDownloadsPath 的巧妙实现
dart
---->[path_provider_android/lib/src/path_provider_android_real.dart#PathProviderAndroid]----
@override
Future<String?> getDownloadsPath() async {
final List<String>? paths =
await getExternalStoragePaths(type: StorageDirectory.downloads);
return paths?.firstOrNull;
}
Android 的"下载目录"不是一个独立的 API,而是复用了 getExternalStorageDirectories 加 StorageDirectory.downloads 类型。如果有多个存储分区,只取第一个。
这是一个"用现有能力组合出新能力"的设计模式。不新增原生调用,减少维护成本。
二、iOS/macOS 分公司:Apple 的 FFI 之路
上一篇对这个分公司已经做了核心分析。这里不重复,只补充几个上篇没展开的设计细节。
重点关注三件事:为什么 iOS 和 macOS 能合成一个包、条件导出机制怎么回事、以及一个协议里没有但很实用的独有方法。
1. iOS 和 macOS 共享一个包
为什么能合为一个包?因为它们底层都是 Darwin 内核,文件系统 API 几乎一样。核心函数 NSSearchPathForDirectoriesInDomains 在两个平台都能用。唯一的差异就是 macOS 非沙盒应用需要用 bundleIdentifier 隔离目录。
一个 if 分支解决了两个平台的差异。值得吗?值得。因为除了这一处,其他逻辑完全相同。为一个 if 拆成两个包,维护成本反而更高。
2. 条件导出和 Web 兼容
iOS/macOS 分公司的入口文件有一个不起眼但很重要的设计:
dart
---->[path_provider_foundation/lib/path_provider_foundation.dart]----
export 'src/path_provider_foundation_stub.dart'
if (dart.library.ffi) 'src/path_provider_foundation_real.dart';
为什么需要 stub?因为 path_provider 包被声明为所有平台的依赖。即使你的 App 跑在 Web 上,Dart 编译器也会尝试分析 path_provider_foundation 的代码。如果真正的实现里引用了 dart:ffi,Web 编译会报错。
stub 文件里定义了一个空壳类,所有方法都抛 UnsupportedError。它的存在只是为了让编译器不报错,运行时永远不会被调用到。
这就像 iOS/macOS 分公司在自家大门口挂了一块牌子:"如果你是从网上来的,请回去,这里不接待。"
3. App Group 容器(iOS 独有)
iOS/macOS 分公司有一个独有的方法,协议里没有定义,属于"额外服务":
dart
---->[path_provider_foundation/lib/src/path_provider_foundation_real.dart#PathProviderFoundation]----
Future<String?> getContainerPath({required String appGroupIdentifier}) async {
if (!_platformProvider.isIOS) {
throw UnsupportedError('getContainerPath is not supported on this platform');
}
return _containerURLForSecurityApplicationGroupIdentifier(
NSString(appGroupIdentifier),
)?.path?.toDartString();
}
这是 iOS App Extension(如 Widget、Share Extension)和主 App 共享数据的机制。通过同一个 App Group Identifier,多个进程可以访问同一个容器目录。macOS 上没有这个需求,所以加了平台检查。
三、Linux 分公司:纯 Dart 之路
四个分公司里,Linux 分公司是最"轻"的。它不需要 FFI,不需要 JNI,甚至不需要任何原生代码。纯 Dart 就能搞定一切。
这种轻量来自 Linux 桌面生态的一个设计哲学:用环境变量和公约代替 API 调用。
1. 为什么不需要 FFI
上一篇已经介绍了 Linux 分公司的核心逻辑。这里展开一个关键点:为什么 Linux 可以用纯 Dart 实现,而其他平台不行?
答案在于 XDG Base Directory 规范。这是 Linux 桌面环境的公约:目录路径通过环境变量暴露(XDG_DATA_HOME、XDG_CACHE_HOME、XDG_CONFIG_HOME 等),任何语言都能读取,不需要调用任何原生 API。
注意 XDG_CONFIG_HOME 在 path_provider 里没有对应的 API。如果你需要存配置文件,得自己去读这个环境变量。这是 path_provider 的设计边界:它只提供最通用的几类目录,不覆盖所有可能。
2. Application ID 的获取
Linux 分公司需要一个 Application ID 来拼接子目录路径。但 Linux 桌面应用不像 Android 那样有天然的包名,ID 从哪来?
dart
---->[path_provider_linux/lib/src/get_application_id.dart]----
getApplicationId() 函数的实现逻辑是:先尝试从 GLib 的 g_application_get_default() 获取,如果没有,再尝试从 /proc/self/exe 的符号链接推断可执行文件名。这是一个"尽力而为"的策略,实在拿不到 ID,就用文件名兜底。
四、Windows 分公司:Win32 之路
Windows 分公司是四个里面"最讲究"的。其他平台要么有天然沙盒(Android、iOS),要么有公约规范(Linux 的 XDG)。Windows 两样都没有,它需要自己想办法确定应用的子目录名。
这个过程涉及 Win32 API、GUID 体系、可执行文件的版本信息,还有一套目录名清洗逻辑。
1. Known Folder 体系
Windows 的文件系统目录管理靠一套叫 Known Folder 的体系。每个"已知文件夹"有一个全局唯一的 GUID。比如:
| path_provider API | Known Folder GUID | 对应路径 |
|---|---|---|
getApplicationDocumentsPath |
{FDD39AD0-238F-...} |
C:\Users\用户名\Documents |
getDownloadsPath |
{374DE290-123F-...} |
C:\Users\用户名\Downloads |
getApplicationSupportPath |
{3EB685DB-65F9-...} |
C:\Users\用户名\AppData\Roaming |
getApplicationCachePath |
{F1B32785-6FBA-...} |
C:\Users\用户名\AppData\Local |
Windows 分公司通过 SHGetKnownFolderPath 这个 Win32 API,拿 GUID 去换路径:
dart
---->[path_provider_windows/lib/src/path_provider_windows_real.dart#PathProviderWindows]----
Future<String?> getPath(String folderID) {
final Pointer<Pointer<Utf16>> pathPtrPtr = calloc<Pointer<Utf16>>();
final Pointer<GUID> knownFolderID = calloc<GUID>()
..ref.parse(folderID); // tag1
try {
final int hr = SHGetKnownFolderPath(
knownFolderID, KF_FLAG_DEFAULT, NULL, pathPtrPtr // tag2
);
if (FAILED(hr)) {
if (hr == E_INVALIDARG || hr == E_FAIL) {
throw _createWin32Exception(hr);
}
return Future<String?>.value();
}
final String path = pathPtrPtr.value.toDartString(); // tag3
return Future<String>.value(path);
} finally {
calloc.free(pathPtrPtr);
calloc.free(knownFolderID); // tag4
}
}
tag1 处把 GUID 字符串解析成内存中的 GUID 结构体。tag2 处调用 Win32 API,传入 GUID,系统返回对应路径的指针。tag3 处把 UTF-16 指针转为 Dart 字符串。tag4 处手动释放分配的内存。
跟 Android 分公司类似,Win32 的 FFI 调用也需要手动管理内存。calloc 分配,calloc.free 释放,try/finally 保证不泄漏。
2. 应用子目录的智能拼接
Windows 的 AppData 目录是所有应用共享的。Windows 分公司需要在里面创建自己的子目录。但子目录名怎么定?
dart
---->[path_provider_windows/lib/src/path_provider_windows_real.dart#PathProviderWindows]----
String _getApplicationSpecificSubdirectory() {
String? companyName;
String? productName;
// 从可执行文件的 VERSIONINFO 资源中读取公司名和产品名
final int infoSize = GetFileVersionInfoSize(moduleNameBuffer, unused);
if (infoSize != 0) {
infoBuffer = calloc<BYTE>(infoSize);
if (GetFileVersionInfo(...) == 0) { ... }
}
companyName = _sanitizedDirectoryName(
_getStringValue(infoBuffer, 'CompanyName') // tag1
);
productName = _sanitizedDirectoryName(
_getStringValue(infoBuffer, 'ProductName') // tag2
);
// 如果读不到产品名,用可执行文件名兜底
productName ??= path.basenameWithoutExtension(
moduleNameBuffer.toDartString() // tag3
);
return companyName != null
? path.join(companyName, productName) // tag4
: productName;
}
tag1 和 tag2 处从 .exe 文件的版本信息资源里读取 CompanyName 和 ProductName。这是 Windows 生态的惯例:右键 exe → 属性 → 详细信息 里能看到的那些字段。
tag3 处是兜底:如果版本信息里没写产品名,就用 exe 文件名(去掉扩展名)。
tag4 处拼接出最终的子目录路径。比如 CompanyName\ProductName,最终完整路径就是 C:\Users\用户名\AppData\Roaming\CompanyName\ProductName。
这个设计很"Windows":它尊重 Windows 的惯例(CompanyName\ProductName 的目录结构),而不是像 Linux 那样用 application ID。每个平台用自己的规矩,体现了联邦制的精神。
3. 目录名清洗
从版本信息里读出来的字符串不能直接当目录名,Windows 对文件名有一堆限制。Windows 分公司有一个专门的清洗函数:
dart
---->[path_provider_windows/lib/src/path_provider_windows_real.dart#PathProviderWindows]----
String? _sanitizedDirectoryName(String? rawString) {
if (rawString == null) return null;
String sanitized = rawString
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') // tag1
.trimRight() // tag2
.replaceAll(RegExp(r'[.]+$'), ''); // tag3
const kMaxComponentLength = 255;
if (sanitized.length > kMaxComponentLength) { // tag4
sanitized = sanitized.substring(0, kMaxComponentLength);
}
return sanitized.isEmpty ? null : sanitized;
}
tag1 处替换 Windows 禁止的字符(<>:"/\|?*)。tag2 处去除尾部空白。tag3 处去除尾部的点(Windows 不允许目录名以点结尾)。tag4 处截断到 255 字符以内。
这种防御性代码看着不起眼,但能防止一类运行时崩溃。想象一个公司名里带了 :(比如 MyCompany: Solutions),如果不清洗就直接拼路径,Directory.create 会失败。
4. GetTempPath 的特殊处理
dart
---->[path_provider_windows/lib/src/path_provider_windows_real.dart#PathProviderWindows]----
@override
Future<String?> getTemporaryPath() async {
final Pointer<Utf16> buffer = calloc<Uint16>(MAX_PATH + 1).cast<Utf16>();
try {
final int length = GetTempPath(MAX_PATH, buffer);
if (length == 0) {
throw _createWin32Exception(GetLastError());
}
path = buffer.toDartString();
// tag1: 去掉尾部反斜杠
if (path.endsWith(r'\')) {
path = path.substring(0, path.length - 1);
}
// tag2: 确保目录存在
final directory = Directory(path);
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
return path;
} finally {
calloc.free(buffer);
}
}
tag1 处有一个微妙的一致性处理:GetTempPath 返回的路径带尾部反斜杠(C:\Users\xxx\AppData\Local\Temp\),但 SHGetKnownFolderPath 返回的不带。为了让所有方法的返回值格式一致,这里手动去掉了尾部的 \。
tag2 处确保目录存在,因为 GetTempPath 只是返回路径,不保证目录真的在。
这些细节客户(开发者)永远不会注意到,但如果不处理,就会有人在某个边缘场景下踩坑。
五、横向对比:同一份协议,四种性格
看完了四个分公司的实现,我们回过头来做一个横向对比。
| 维度 | Android | iOS/macOS | Linux | Windows |
|---|---|---|---|---|
| 通信方式 | JNI(Dart→Java) | FFI(Dart→ObjC) | 纯 Dart | FFI(Dart→Win32) |
| 需要原生代码 | 否(JNIgen 生成) | 否(FFIgen 生成) | 否 | 否(手写 FFI) |
| 内存管理 | 手动 release JNI 引用 | 自动(ARC) | 不需要 | 手动 calloc/free |
| 临时目录 = 缓存目录 | 是 | 否 | 否 | 否 |
| 外部存储支持 | 是(唯一支持的平台) | 否 | 否 | 否 |
| 子目录隔离方式 | 天然沙盒 | macOS 用 bundleId | XDG + appId | VERSIONINFO CompanyName\ProductName |
| 向后兼容逻辑 | 无 | 无 | 有(旧目录名查找) | 无 |
六、StorageDirectory:Android 独有的仓库分类
四个分公司看完了,最后补充一个接口层的小细节。前面提到 Android 支持按"类型"查询外部存储目录,那个"类型"参数就是 StorageDirectory 枚举。
它只有 Android 用得上,但偏偏定义在接口层。这个"放在哪一层"的决策背后有依赖方向的考量。
1. 枚举定义
在接口层有一个小文件 enums.dart,定义了 Android 外部存储的目录类型:
dart
---->[path_provider_platform_interface/lib/src/enums.dart#StorageDirectory]----
enum StorageDirectory {
music,
podcasts,
ringtones,
alarms,
notifications,
pictures,
movies,
downloads,
dcim,
documents,
}
每个枚举值对应 Android Environment 类的一个常量。当你调用 getExternalStorageDirectories(type: StorageDirectory.pictures) 时,Android 分公司会把它翻译成 Environment.DIRECTORY_PICTURES,然后去所有存储分区上找这个类型的目录。
2. 为什么放在接口层
停下来想想:StorageDirectory 只有 Android 用得上,为什么要放在接口层?
因为 getExternalStoragePaths 方法的签名需要用到这个类型作为参数。而方法签名是接口层定义的。如果枚举放在 Android 包里,接口层就要反向依赖实现层,依赖方向就乱了。
所以虽然只有一个平台用,枚举还是得放在接口层。这是联邦制架构的一个小代价:公共类型必须在协议层声明,即使它只有一个分公司消费。
学到了什么
-
JNI 的资源管理:跨语言调用时,另一侧的对象不受当前语言 GC 管理。用完必须手动 release,否则内存泄漏。这是所有 FFI/JNI 场景的通用注意事项。
-
条件导出(conditional export) :
export 'stub.dart' if (dart.library.ffi) 'real.dart'是 Flutter 插件处理 Web 兼容的标准模式。stub 的存在只是为了让编译器不报错,运行时不会被调用。 -
平台惯例要尊重:Linux 用 XDG + app ID,Windows 用 CompanyName\ProductName,Android 天然沙盒。不要用一套逻辑硬套所有平台,每个平台有自己的规矩。
-
防御性细节:去掉尾部反斜杠保持一致性、清洗非法字符防止路径创建失败、确保目录存在后再返回。这些"看不见"的工作,就是让百万开发者安静使用的前提。
-
类型放在哪一层:即使某个类型只有一个平台使用,只要方法签名需要它,就得放在接口层。依赖方向不能乱。
碎碎念
写这篇的时候,我一直在想一个问题:为什么同一个"给我一个路径"这么简单的事,四个平台的实现差异这么大?
Android 要跟 Java 世界打交道,要手动释放引用。Windows 要去 exe 文件里挖版本信息,还要清洗非法字符。iOS 要在沙盒和非沙盒之间做区分。Linux 最轻松,但要处理 xdg-user-dir 可能没装的情况。
这就是跨平台开发的真相:上层越统一,底层越分裂。path_provider 的价值不在于它的代码有多精巧,而在于它把这些分裂藏起来了,让你面对的永远是同一个干净的 API。
你用一行代码拿到了路径。但背后有四个分公司、四种语言、四套规矩在为你跑腿。