Flutter 类库大揭秘#02 | path_provider 各平台实现

引言:

上一篇我们看了快递公司的整体架构:总调度台、服务协议、加盟审核。你已经知道了"系统怎么运转"。但有一个问题还没回答:各个分公司到底是怎么找到仓库地址的?

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/包名/filestag5 处调用 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),返回所有存储分区上对应类型的目录。

graph LR A[&#34;StorageDirectory.music&#34;] --> B[&#34;Environment.DIRECTORY_MUSIC&#34;] A2[&#34;StorageDirectory.pictures&#34;] --> B2[&#34;Environment.DIRECTORY_PICTURES&#34;] A3[&#34;StorageDirectory.downloads&#34;] --> B3[&#34;Environment.DIRECTORY_DOWNLOADS&#34;] A4[&#34;StorageDirectory.documents&#34;] --> B4[&#34;Environment.DIRECTORY_DOCUMENTS&#34;] style A fill:#4CAF50,color:#fff style A2 fill:#4CAF50,color:#fff style A3 fill:#4CAF50,color:#fff style A4 fill:#4CAF50,color:#fff

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,而是复用了 getExternalStorageDirectoriesStorageDirectory.downloads 类型。如果有多个存储分区,只取第一个。

这是一个"用现有能力组合出新能力"的设计模式。不新增原生调用,减少维护成本。


二、iOS/macOS 分公司:Apple 的 FFI 之路

上一篇对这个分公司已经做了核心分析。这里不重复,只补充几个上篇没展开的设计细节。

重点关注三件事:为什么 iOS 和 macOS 能合成一个包、条件导出机制怎么回事、以及一个协议里没有但很实用的独有方法。

1. iOS 和 macOS 共享一个包

为什么能合为一个包?因为它们底层都是 Darwin 内核,文件系统 API 几乎一样。核心函数 NSSearchPathForDirectoriesInDomains 在两个平台都能用。唯一的差异就是 macOS 非沙盒应用需要用 bundleIdentifier 隔离目录。

graph TD subgraph &#34;path_provider_foundation&#34; A[&#34;_getDirectoryPath(directory)&#34;] A --> B{&#34;isMacOS?&#34;} B -->|是| C[&#34;追加 bundleIdentifier 子目录&#34;] B -->|否| D[&#34;直接返回路径&#34;] end style A fill:#9C27B0,color:#fff

一个 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_HOMEXDG_CACHE_HOMEXDG_CONFIG_HOME 等),任何语言都能读取,不需要调用任何原生 API。

graph LR subgraph &#34;XDG 环境变量&#34; X1[&#34;XDG_DATA_HOME<br/>默认 ~/.local/share&#34;] X2[&#34;XDG_CACHE_HOME<br/>默认 ~/.cache&#34;] X3[&#34;XDG_CONFIG_HOME<br/>默认 ~/.config&#34;] X4[&#34;TMPDIR<br/>默认 /tmp&#34;] end subgraph &#34;path_provider 映射&#34; P1[&#34;getApplicationSupportPath&#34;] P2[&#34;getApplicationCachePath&#34;] P3[&#34;(未使用)&#34;] P4[&#34;getTemporaryPath&#34;] end X1 --> P1 X2 --> P2 X3 --> P3 X4 --> P4

注意 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;
}

tag1tag2 处从 .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 只是返回路径,不保证目录真的在。

这些细节客户(开发者)永远不会注意到,但如果不处理,就会有人在某个边缘场景下踩坑。


五、横向对比:同一份协议,四种性格

看完了四个分公司的实现,我们回过头来做一个横向对比。

graph TD subgraph &#34;通信方式&#34; R[&#34;Android<br/>JNI → Java Context&#34;] E[&#34;iOS/macOS<br/>FFI → Objective-C Foundation&#34;] F[&#34;Linux<br/>纯 Dart → 环境变量&#34;] G[&#34;Windows<br/>FFI → Win32 API&#34;] end style R fill:#4CAF50,color:#fff style E fill:#9C27B0,color:#fff style F fill:#FF9800,color:#fff style G fill:#2196F3,color:#fff
维度 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 包里,接口层就要反向依赖实现层,依赖方向就乱了。

所以虽然只有一个平台用,枚举还是得放在接口层。这是联邦制架构的一个小代价:公共类型必须在协议层声明,即使它只有一个分公司消费。


学到了什么

  1. JNI 的资源管理:跨语言调用时,另一侧的对象不受当前语言 GC 管理。用完必须手动 release,否则内存泄漏。这是所有 FFI/JNI 场景的通用注意事项。

  2. 条件导出(conditional export)export 'stub.dart' if (dart.library.ffi) 'real.dart' 是 Flutter 插件处理 Web 兼容的标准模式。stub 的存在只是为了让编译器不报错,运行时不会被调用。

  3. 平台惯例要尊重:Linux 用 XDG + app ID,Windows 用 CompanyName\ProductName,Android 天然沙盒。不要用一套逻辑硬套所有平台,每个平台有自己的规矩。

  4. 防御性细节:去掉尾部反斜杠保持一致性、清洗非法字符防止路径创建失败、确保目录存在后再返回。这些"看不见"的工作,就是让百万开发者安静使用的前提。

  5. 类型放在哪一层:即使某个类型只有一个平台使用,只要方法签名需要它,就得放在接口层。依赖方向不能乱。


碎碎念

写这篇的时候,我一直在想一个问题:为什么同一个"给我一个路径"这么简单的事,四个平台的实现差异这么大?

Android 要跟 Java 世界打交道,要手动释放引用。Windows 要去 exe 文件里挖版本信息,还要清洗非法字符。iOS 要在沙盒和非沙盒之间做区分。Linux 最轻松,但要处理 xdg-user-dir 可能没装的情况。

这就是跨平台开发的真相:上层越统一,底层越分裂。path_provider 的价值不在于它的代码有多精巧,而在于它把这些分裂藏起来了,让你面对的永远是同一个干净的 API。

你用一行代码拿到了路径。但背后有四个分公司、四种语言、四套规矩在为你跑腿。

相关推荐
铁皮饭盒2 小时前
26年bunjs, elysia+pg一把梭, redis都省了
前端·javascript·后端
lichenyang45315 小时前
Docker 学习笔记(一):为什么需要镜像、容器和仓库?
前端
kyriewen15 小时前
别再对着 TypeScript 报错发呆了:我把 10 个最常见的红色波浪线翻译成了人话
前端·javascript·typescript
IT_陈寒15 小时前
SpringBoot自动配置的坑,我的API突然就404了
前端·人工智能·后端
奇奇怪怪的16 小时前
Embedding 模型 10+ 横向评测
前端
陈广亮16 小时前
Monorepo 从 0 到 1 实操指南 2026 版:pnpm catalogs + Turborepo 2.x + changesets 全链路
前端
子兮曰16 小时前
OpenMontage 深度解剖:你的 AI 编程助手,其实是个视频工作室
前端·后端·ai编程
敲代码的鱼16 小时前
PDF 预览与签名批注写回 支持安卓 iOS 鸿蒙 UTS插件
android·前端·ios