Flutter 3.47 鸿蒙三方库适配实战:file_picker 文件选择兼容层

一、项目信息

项目内容
适配仓库名称file_picker
原始包名file_picker
适配版本13.1.0-ohos.1
上游项目vicajilau/flutter_file_picker
方法通道miguelruivo.flutter.plugins.filepicker
OHOS 原生入口FilePickerPlugin
Dart OHOS 注册FilePickerOhos
构建产物entry-default-unsigned.hap

文件选择器的适配目标不是自己实现一个文件浏览页面,而是把 FilePicker 的 Dart API 接到 OHOS 系统的 DocumentViewPicker。这样用户仍然使用系统文件提供方、权限模型和保存流程,业务代码也无需改变。

二、保留 FilePicker 静态 API

上游包在 v13 中将 FilePicker 设计为静态入口,业务代码只需要调用:

final PlatformFile? file = await FilePicker.pickFile();
final List<PlatformFile> files = await FilePicker.pickFiles(
  type: FileType.image,
);
final String? directory = await FilePicker.getDirectoryPath();
final Uri? uri = await FilePicker.saveFile(
  fileName: 'ohos-demo.txt',
  bytes: Uint8List.fromList('hello'.codeUnits),
);

根 pubspec.yaml 增加 OHOS 声明:

version: 13.1.0-ohos.1

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: FilePickerPlugin
        dartPluginClass: FilePickerOhos

FilePickerOhos 注册到 FilePickerPlatform.instance,因此 FilePicker 静态方法不需要增加 OHOS 专用分支。

三、原生选择器实现

插件在 onAttachedToEngine 时创建通道,在 Ability 绑定时保存 UIAbilityContext:

const CHANNEL = 'miguelruivo.flutter.plugins.filepicker';

export default class FilePickerPlugin
  implements FlutterPlugin, MethodCallHandler, AbilityAware {
  private channel: MethodChannel | null = null;
  private context: common.UIAbilityContext | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL);
    this.channel.setMethodCallHandler(this);
  }

  onAttachedToAbility(binding: AbilityPluginBinding): void {
    this.context = binding.getAbility().context;
  }
}

选择文件时使用 DocumentSelectOptions:

const target = new picker.DocumentViewPicker(this.context);
const options = new picker.DocumentSelectOptions();
const multiple = directory ? false : Boolean(call.argument('multiple'));
options.maxSelectNumber = multiple ? 50 : 1;
if (directory) {
  options.selectMode = picker.DocumentSelectMode.FOLDER;
}

const filters = this.suffixFilters(call);
if (filters.length > 0) {
  options.fileSuffixFilters = filters;
}

return await target.select(options);

单文件选择使用 maxSelectNumber = 1,多文件选择最多允许 50 个文件。FileType.image、FileType.video、FileType.audio 分别映射为系统选择器的后缀过滤;FileType.custom 使用业务传入的扩展名。

四、保存文件的完整链路

保存文件先创建 DocumentSaveOptions,再由系统选择器返回 URI:

const options = new picker.DocumentSaveOptions();
options.newFileNames = [fileName];

const target = new picker.DocumentViewPicker(this.context);
const uris = await target.save(options);
if (uris.length === 0) {
  return null;
}

const uri = uris[0];
const bytes = call.argument('bytes') as Uint8Array;
const file = fs.openSync(
  this.pathFromUri(uri),
  fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC,
);
try {
  fs.writeSync(file.fd, bytes.buffer as ArrayBuffer);
} finally {
  fs.closeSync(file);
}
return uri;

这里必须区分“用户取消”和“写入失败”:取消返回 null,写入失败通过 MethodChannel error 返回,不能把空 URI 当成保存成功。

五、PlatformFile 的 OHOS 表示

OHOS 返回的 URI 不一定都是本地 file:// 路径,因此适配层实现了 OhosPlatformFile:

base class OhosPlatformFile extends PlatformFile {
  OhosPlatformFile(this.uri, {int? size});

  
  final Uri uri;

  String? get path => uri.scheme == 'file' ? uri.toFilePath() : null;

  
  Future<Uint8List> readAsBytes() =>
      File(path ?? uri.toString()).readAsBytes();
}

业务读取内容前应优先使用 readAsBytes() 或 readAsByteStream(),不要假设所有系统 URI 都能直接用 dart:io.File 打开。

六、业务示例

选择图片:

final List<PlatformFile> images = await FilePicker.pickFiles(
  type: FileType.custom,
  allowedExtensions: <String>['png', 'jpg', 'jpeg'],
);

for (final PlatformFile image in images) {
  debugPrint('${image.name}: ${await image.length()} bytes');
}

选择目录:

final String? directory = await FilePicker.getDirectoryPath();
if (directory != null) {
  debugPrint('selected directory: $directory');
}

保存文本:

import 'dart:typed_data';

final Uint8List bytes = Uint8List.fromList(
  'OpenHarmony file picker'.codeUnits,
);

final Uri? saved = await FilePicker.saveFile(
  fileName: 'ohos-demo.txt',
  bytes: bytes,
  mimeType: 'text/plain',
);

七、能力边界

能力OHOS 结果
单文件选择支持
多文件选择支持,最多 50 个
图片、视频、音频过滤支持
自定义扩展名过滤支持
目录选择支持
保存文件支持
clearTemporaryFiles()空实现,返回成功
skipEntitlementsChecks()空实现,返回成功
pickFileAndDirectoryPaths()部分支持,返回选择器 URI

八、构建验证

验证命令:

flutter pub get
flutter analyze
cd example
flutter pub get
flutter analyze
flutter build hap --debug --no-codesign

当前结果:

检查项结果
根目录 flutter analyze通过
示例目录 flutter analyze通过
OHOS ArkTS 编译通过
HAP 产物entry-default-unsigned.hap
release HAP 大小约 21 MB
在这里插入图片描述

九、把“选择器返回 URI”当作真实边界

1. 为什么不能把所有结果都当成普通文件路径

OHOS 系统文件选择器返回的是 URI,可能是 file:// 形式,也可能带有其他 authority。Dart 层的 OhosPlatformFile 因此保留原始 Uri,只在 URI 确实是本地文件时才提供 path:

base class OhosPlatformFile extends PlatformFile {
  OhosPlatformFile(this.uri, {int? size}) : _size = size;

  
  final Uri uri;

  String? get path =>
      uri.scheme == 'file' ? uri.toFilePath() : null;

  
  Future<Uint8List> readAsBytes() =>
      File(path ?? uri.toString()).readAsBytes();
}

这解释了为什么业务代码不应无条件写 File(platformFile.path!)。如果系统返回非 file:// URI,path 可能为 null,此时应该使用 URI、平台文件接口或专门的 OHOS 文件访问能力,而不是假设一个本地路径。

2. FileType 到系统后缀过滤器的映射

DocumentSelectOptions.fileSuffixFilters 不理解 Dart 的 FileType.image 枚举,所以 ArkTS 侧先把类型转换为后缀列表:

Dart 类型OHOS 后缀过滤
FileType.any不设置过滤器
FileType.media图片、视频常用后缀
FileType.image.jpg、.jpeg、.png、.gif、.bmp、.webp
FileType.video.mp4、.mov、.mkv、.webm、.avi
FileType.audio.mp3、.aac、.flac、.wav、.ogg、.m4a
FileType.custom业务传入的 allowedExtensions

如果业务传入自定义扩展名,适配层会自动补上前导点:

private withDot(value: string): string {
  return value.startsWith('.') ? value : `.${value}`;
}

这样 Dart 层既可以传 png,也可以传 .png,不会因为一个字符差异导致过滤器失效。

3. 目录模式必须显式设置

目录选择是这轮适配中最容易漏掉的分支。DocumentSelectOptions 默认选择文件,即使把 maxSelectNumber 设为 1,也只会限制“只选一个文件”,不会切换成目录选择器:

const options = new picker.DocumentSelectOptions();
const multiple = directory ? false : Boolean(call.argument('multiple'));
options.maxSelectNumber = multiple ? 50 : 1;

if (directory) {
  options.selectMode = picker.DocumentSelectMode.FOLDER;
}

DocumentSelectMode.FOLDER 从 OHOS API 11 开始提供,且目录一次只能选择一个。当前实现据此把 getDirectoryPath() 映射为目录 URI;不支持目录能力的设备应让系统选择器返回错误,而不是把文件 URI 伪装成目录路径。

4. 保存文件不是“选择器返回就结束”

保存流程分为四步:

Dart FilePicker.saveFile()
        |
        v
DocumentSaveOptions.newFileNames
        |
        v
DocumentViewPicker.save() 返回 URI
        |
        v
ArkTS fs.openSync() + fs.writeSync()
        |
        v
返回 URI 给 Dart

用户取消时系统返回空 URI,适配层返回 null;写入失败时通过 MethodChannel error 传播。两种情况不能合并成同一个“保存失败”,否则业务无法区分用户主动取消和文件权限、磁盘空间等真实异常。

5. 保存路径和读取路径的示例

保存一个 UTF-8 文本文件:

final Uint8List bytes = Uint8List.fromList(
  utf8.encode('OpenHarmony file picker'),
);

final Uri? uri = await FilePicker.saveFile(
  fileName: 'ohos-demo.txt',
  bytes: bytes,
  mimeType: 'text/plain',
);

读取选中文件时,先判断是否能拿到本地路径:

Future<void> inspectFile(PlatformFile file) async {
  final String? path = file.path;
  if (path != null) {
    final File localFile = File(path);
    debugPrint('local size: ${await localFile.length()}');
    return;
  }

  final Uint8List bytes = await file.readAsBytes();
  debugPrint('uri size: ${bytes.length}');
}

这段代码比直接使用 File(file.path!) 更接近 OHOS 的真实文件模型。

6. 测试矩阵

文件选择器同时涉及 Flutter Dart 契约、ArkTS 系统 API 和真实 UI,因此测试要分层:

层次验证内容
Dart 单元测试FilePicker 静态 API 能委托到平台实例
Dart 单元测试取消选择返回 null 或空列表
ArkTS 编译DocumentSelectMode.FOLDER 和 DocumentSaveOptions 类型可用
HAP 构建dartPluginClass 和 pluginClass 都被示例工程识别

7. 常见故障排查

现象原因处理方式
目录按钮却选择了一个文件未设置 DocumentSelectMode.FOLDER在目录分支设置 FOLDER 模式
图片过滤器无效业务传入的扩展名没有统一格式使用 withDot() 归一化
保存后文件为空只打开了文件但没有写入检查 fs.writeSync() 和 finally 关闭逻辑
file.path 为 nullURI 不是本地 file://使用 URI 或平台文件读取接口
多文件选择数量异常只设置单选模式却调用 pickFiles()确认 Dart 层和 ArkTS 层的 multiple 一致
示例点击无反应UIAbility Context 未绑定在 Ability attached 后再调用选择器

十、总结

file_picker 的 OHOS 适配遵循三个原则:

  • 保留上游静态 API 和 PlatformFile 模型。
  • 通过系统 DocumentViewPicker 选择和保存文件。
  • 对 URI、临时文件和目录混合选择保持明确限制。

欢迎加入 CPF-Flutter OpenHarmony 社区,交流 Flutter 三方库适配、系统文件验证经验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐