Flutter for OpenHarmony三方库适配实战:file_selector文件选择详解

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
本文基于flutter3.27.5开发

一、file_selector 库概述

文件选择是移动应用和桌面应用中非常常见的功能,无论是上传文档、导入配置文件、选择媒体资源还是批量处理文件,都需要使用到文件选择器。在 Flutter for OpenHarmony 应用开发中,file_selector 是一个功能强大的文件选择插件,提供了完整的文件选择和管理功能,支持多种文件类型过滤和自定义配置。

📋 file_selector 库特点

file_selector 库基于 Flutter 平台接口实现,提供了以下核心特性:

单文件选择 :支持选择单个文件,返回 XFile 对象,包含文件路径、名称、大小等信息。适用于头像上传、文档导入等场景。

多文件选择 :支持一次选择多个文件,返回 List<XFile> 对象列表。适用于批量上传、多文件处理等场景。

目录选择:经过源码分析,该功能未实现

文件类型过滤:支持通过扩展名、MIME 类型、统一类型标识符等多种方式过滤文件类型,帮助用户快速定位目标文件。

初始目录设置:支持设置文件选择器的初始打开目录,提升用户体验。

自定义按钮文本:支持自定义确认按钮的文本内容,适应不同的业务场景。

支持平台对比

平台 支持情况 说明
Android ✅ 完全支持 支持 Android SDK 19+
iOS ✅ 完全支持 支持 iOS 11+
Web ✅ 完全支持 支持文件选择
Windows ✅ 完全支持 支持文件选择和保存
macOS ✅ 完全支持 支持文件选择和保存
Linux ✅ 完全支持 支持文件选择和保存
OpenHarmony ✅ 完全支持 专门适配,支持 API 12+

功能支持对比

功能 Android iOS Web OpenHarmony
选择单个文件
选择多个文件
选择保存位置
选择目录 ✅†
文件类型过滤

† Android SDK 21 (Lollipop) 及以上版本支持选择目录。

文件类型过滤支持

过滤类型 说明 Android iOS OpenHarmony
extensions 文件扩展名(如 jpg、png)
mimeTypes MIME 类型(如 image/jpeg)
uniformTypeIdentifiers 统一类型标识符(iOS/macOS)
webWildCards Web 通配符(如 image/*)

💡 使用场景:文档上传、图片选择、配置文件导入、批量文件处理、文件管理等。


二、安装与配置

2.1 添加依赖

在项目的 pubspec.yaml 文件中添加 file_selector 依赖:

yaml 复制代码
dependencies:
  file_selector:
    git:
      url: https://atomgit.com/openharmony-tpc/flutter_packages.git
      path: packages/file_selector/file_selector

然后执行以下命令获取依赖:

bash 复制代码
flutter pub get

2.2 权限配置

file_selector 在 OpenHarmony 平台上使用系统原生的文件选择器(PhotoViewPicker、DocumentViewPicker),这些选择器会自动处理文件访问权限,因此不需要申请媒体读写权限

如果应用需要访问网络资源(例如加载远程文件),则需要配置网络权限:

在 entry 目录下的 module.json5 中添加权限

打开 ohos/entry/src/main/module.json5,在 requestPermissions 数组中添加:

json 复制代码
"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
    "reason": "$string:network_reason",
    "usedScene": {
      "abilities": [
        "EntryAbility"
      ],
      "when": "inuse"
    }
  }
]
在 entry 目录下添加申请权限的原因

打开 ohos/entry/src/main/resources/base/element/string.json,在 string 数组中添加:

json 复制代码
{
  "string": [
    {
      "name": "network_reason",
      "value": "使用网络访问文件资源"
    }
  ]
}

三、核心 API 详解

3.1 openFile - 选择单个文件

openFile 方法用于打开文件选择对话框,选择单个文件。

方法签名
dart 复制代码
Future<XFile?> openFile({
  List<XTypeGroup> acceptedTypeGroups = const <XTypeGroup>[],
  String? initialDirectory,
  String? confirmButtonText,
})
参数详解

acceptedTypeGroups 是可选参数,指定可以在对话框中选择的文件类型组列表。通过设置此参数,可以过滤显示特定类型的文件,帮助用户快速定位目标文件。如果不设置,则显示所有类型的文件。

initialDirectory 是可选参数,指定对话框打开时显示的初始目录的完整路径。如果不设置,平台会选择一个默认的初始位置。

confirmButtonText 是可选参数,指定对话框确认按钮上的文本。如果不设置,使用操作系统的默认标签(如"打开")。

返回值

返回 Future<XFile?>,其中 XFile 是跨平台文件抽象类。如果用户取消选择,返回 null。

基本用法示例
dart 复制代码
import 'package:file_selector/file_selector.dart';

class FileSelectorDemo extends StatefulWidget {
  @override
  _FileSelectorDemoState createState() => _FileSelectorDemoState();
}

class _FileSelectorDemoState extends State<FileSelectorDemo> {
  XFile? _selectedFile;

  Future<void> _openFile() async {
    const XTypeGroup typeGroup = XTypeGroup(
      label: '图片',
      extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
    );

    final XFile? file = await openFile(
      acceptedTypeGroups: [typeGroup],
    );

    if (file != null) {
      setState(() {
        _selectedFile = file;
      });
      print('选择的文件: ${file.path}');
      print('文件名: ${file.name}');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('文件选择')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (_selectedFile != null)
              Text('已选择: ${_selectedFile!.name}')
            else
              Text('未选择文件'),
            SizedBox(height: 20),
            ElevatedButton.icon(
              onPressed: _openFile,
              icon: Icon(Icons.folder_open),
              label: Text('选择文件'),
            ),
          ],
        ),
      ),
    );
  }
}

3.2 openFiles - 选择多个文件

openFiles 方法用于打开文件选择对话框,选择多个文件。

方法签名
dart 复制代码
Future<List<XFile>> openFiles({
  List<XTypeGroup> acceptedTypeGroups = const <XTypeGroup>[],
  String? initialDirectory,
  String? confirmButtonText,
})
参数详解

参数与 openFile 方法相同。

返回值

返回 Future<List<XFile>>,包含用户选择的所有文件。如果用户取消选择,返回空列表。

使用示例
dart 复制代码
class MultiFileSelectorDemo extends StatefulWidget {
  @override
  _MultiFileSelectorDemoState createState() => _MultiFileSelectorDemoState();
}

class _MultiFileSelectorDemoState extends State<MultiFileSelectorDemo> {
  List<XFile> _selectedFiles = [];

  Future<void> _openFiles() async {
    final List<XFile> files = await openFiles(
      acceptedTypeGroups: [
        XTypeGroup(
          label: '文档',
          extensions: ['pdf', 'doc', 'docx', 'txt'],
        ),
      ],
    );

    if (files.isNotEmpty) {
      setState(() {
        _selectedFiles = files;
      });
      for (XFile file in files) {
        print('选择的文件: ${file.name}');
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('多文件选择')),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: _selectedFiles.length,
              itemBuilder: (context, index) {
                return ListTile(
                  leading: Icon(Icons.insert_drive_file),
                  title: Text(_selectedFiles[index].name),
                  subtitle: Text(_selectedFiles[index].path),
                );
              },
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16),
            child: ElevatedButton.icon(
              onPressed: _openFiles,
              icon: Icon(Icons.folder_open),
              label: Text('选择文件 (${_selectedFiles.length})'),
            ),
          ),
        ],
      ),
    );
  }
}

3.3 getDirectoryPath - 选择目录(虽然有API但是源码并未实现这个功能)

getDirectoryPath 方法用于打开目录选择对话框,选择一个目录。

方法签名
dart 复制代码
Future<String?> getDirectoryPath({
  String? initialDirectory,
  String? confirmButtonText,
})
参数详解

initialDirectory 是可选参数,指定对话框打开时显示的初始目录的完整路径。

confirmButtonText 是可选参数,指定对话框确认按钮上的文本。

返回值

返回 Future<String?>,包含选择的目录路径。如果用户取消选择,返回 null。

3.4 XTypeGroup - 文件类型组

XTypeGroup 类用于定义文件类型组,可以设置多种过滤条件。

构造函数
dart 复制代码
const XTypeGroup({
  String? label,
  List<String>? extensions,
  List<String>? mimeTypes,
  List<String>? uniformTypeIdentifiers,
  List<String>? webWildCards,
})
属性详解

label 是可选参数,定义这个类型组的"名称"或引用,用于在文件选择器中显示给用户。例如"图片"、"文档"、"视频"等。

extensions 是可选参数,定义这个组的文件扩展名列表。例如 ['jpg', 'png', 'gif'] 表示只显示这三种扩展名的文件。

mimeTypes 是可选参数,定义这个组的 MIME 类型列表。例如 ['image/jpeg', 'image/png'] 表示只显示 JPEG 和 PNG 类型的文件。

uniformTypeIdentifiers 是可选参数,定义这个组的统一类型标识符(主要用于 iOS/macOS)。例如 ['public.image'] 表示所有图片类型。

webWildCards 是可选参数,定义这个组的网络通配符。例如 ['image/*'] 表示所有图片类型,['video/*'] 表示所有视频类型。

使用示例
dart 复制代码
const XTypeGroup imageGroup = XTypeGroup(
  label: '图片文件',
  extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
  mimeTypes: ['image/jpeg', 'image/png', 'image/gif'],
);

const XTypeGroup documentGroup = XTypeGroup(
  label: '文档文件',
  extensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
  mimeTypes: [
    'application/pdf',
    'application/msword',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  ],
);

const XTypeGroup videoGroup = XTypeGroup(
  label: '视频文件',
  extensions: ['mp4', 'mov', 'avi', 'mkv', 'webm'],
  mimeTypes: ['video/mp4', 'video/quicktime', 'video/x-msvideo'],
);

const XTypeGroup audioGroup = XTypeGroup(
  label: '音频文件',
  extensions: ['mp3', 'wav', 'aac', 'flac', 'ogg'],
  mimeTypes: ['audio/mpeg', 'audio/wav', 'audio/aac'],
);

Future<XFile?> pickImage() async {
  return await openFile(acceptedTypeGroups: [imageGroup]);
}

Future<XFile?> pickDocument() async {
  return await openFile(acceptedTypeGroups: [documentGroup]);
}

Future<XFile?> pickMedia() async {
  return await openFile(acceptedTypeGroups: [imageGroup, videoGroup, audioGroup]);
}

3.5 XFile 类

XFile 是跨平台文件抽象类,提供了文件的基本信息和操作方法。该类与 image_picker 库使用的 XFile 是同一个类。

常用属性

path 属性返回文件的绝对路径,类型为 String。这是访问文件的主要方式。

name 属性返回文件名(包含扩展名),类型为 String。例如 document.pdf

mimeType 属性返回文件的 MIME 类型,类型为 String?。可能为 null,取决于平台支持。

常用方法

length() 方法返回文件大小(字节),返回 Future<int>。适用于显示文件大小或进行大小限制检查。

readAsBytes() 方法将文件内容读取为字节数组,返回 Future<Uint8List>。适用于处理二进制文件。

readAsString() 方法将文件内容读取为字符串,返回 Future<String>。适用于处理文本文件。

openRead() 方法返回文件读取流,返回 Stream<List<int>>。适用于大文件的分块读取。

lastModified() 方法返回文件最后修改时间,返回 Future<DateTime>

使用示例
dart 复制代码
Future<void> analyzeFile(XFile file) async {
  final int size = await file.length();
  final String fileName = file.name;
  final String filePath = file.path;
  final DateTime? modified = await file.lastModified();

  print('文件名: $fileName');
  print('文件路径: $filePath');
  print('文件大小: ${(size / 1024).toStringAsFixed(2)} KB');
  print('MIME类型: ${file.mimeType}');
  if (modified != null) {
    print('最后修改: $modified');
  }
}

Future<String> readTextFile(XFile file) async {
  return await file.readAsString();
}

Future<Uint8List> readBinaryFile(XFile file) async {
  return await file.readAsBytes();
}

四、实战案例

4.1 完整的文件选择器组件

dart 复制代码
class FilePickerWidget extends StatefulWidget {
  final Function(List<XFile> files) onFilesSelected;
  final List<XTypeGroup>? allowedTypes;
  final bool allowMultiple;
  final int maxFiles;

  const FilePickerWidget({
    required this.onFilesSelected,
    this.allowedTypes,
    this.allowMultiple = true,
    this.maxFiles = 10,
    Key? key,
  }) : super(key: key);

  @override
  _FilePickerWidgetState createState() => _FilePickerWidgetState();
}

class _FilePickerWidgetState extends State<FilePickerWidget> {
  List<XFile> _selectedFiles = [];

  Future<void> _pickFiles() async {
    if (_selectedFiles.length >= widget.maxFiles) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('最多只能选择 ${widget.maxFiles} 个文件')),
      );
      return;
    }

    List<XFile> files;
    if (widget.allowMultiple) {
      files = await openFiles(
        acceptedTypeGroups: widget.allowedTypes ?? [],
      );
    } else {
      final XFile? file = await openFile(
        acceptedTypeGroups: widget.allowedTypes ?? [],
      );
      files = file != null ? [file] : [];
    }

    if (files.isNotEmpty) {
      setState(() {
        _selectedFiles.addAll(files);
        if (_selectedFiles.length > widget.maxFiles) {
          _selectedFiles = _selectedFiles.sublist(0, widget.maxFiles);
        }
      });
      widget.onFilesSelected(_selectedFiles);
    }
  }

  void _removeFile(int index) {
    setState(() {
      _selectedFiles.removeAt(index);
    });
    widget.onFilesSelected(_selectedFiles);
  }

  String _formatFileSize(int bytes) {
    if (bytes < 1024) return '$bytes B';
    if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
    if (bytes < 1024 * 1024 * 1024) {
      return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
    }
    return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
  }

  IconData _getFileIcon(String fileName) {
    final ext = fileName.split('.').last.toLowerCase();
    switch (ext) {
      case 'pdf':
        return Icons.picture_as_pdf;
      case 'doc':
      case 'docx':
        return Icons.description;
      case 'xls':
      case 'xlsx':
        return Icons.table_chart;
      case 'ppt':
      case 'pptx':
        return Icons.slideshow;
      case 'jpg':
      case 'jpeg':
      case 'png':
      case 'gif':
        return Icons.image;
      case 'mp4':
      case 'mov':
      case 'avi':
        return Icons.videocam;
      case 'mp3':
      case 'wav':
        return Icons.audiotrack;
      case 'zip':
      case 'rar':
        return Icons.folder_zip;
      default:
        return Icons.insert_drive_file;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ListView.builder(
          shrinkWrap: true,
          physics: NeverScrollableScrollPhysics(),
          itemCount: _selectedFiles.length,
          itemBuilder: (context, index) {
            final file = _selectedFiles[index];
            return Card(
              margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
              child: ListTile(
                leading: Icon(_getFileIcon(file.name)),
                title: Text(
                  file.name,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
                subtitle: FutureBuilder<int>(
                  future: file.length(),
                  builder: (context, snapshot) {
                    if (snapshot.hasData) {
                      return Text(_formatFileSize(snapshot.data!));
                    }
                    return Text('计算中...');
                  },
                ),
                trailing: IconButton(
                  icon: Icon(Icons.close),
                  onPressed: () => _removeFile(index),
                ),
              ),
            );
          },
        ),
        Padding(
          padding: EdgeInsets.all(16),
          child: ElevatedButton.icon(
            onPressed: _pickFiles,
            icon: Icon(Icons.attach_file),
            label: Text('选择文件 (${_selectedFiles.length}/${widget.maxFiles})'),
          ),
        ),
      ],
    );
  }
}

4.2 文档导入功能

dart 复制代码
class DocumentImporter extends StatefulWidget {
  @override
  _DocumentImporterState createState() => _DocumentImporterState();
}

class _DocumentImporterState extends State<DocumentImporter> {
  XFile? _importedFile;
  String? _fileContent;
  bool _isLoading = false;

  static const XTypeGroup documentTypes = XTypeGroup(
    label: '文档',
    extensions: ['txt', 'json', 'xml', 'csv'],
  );

  Future<void> _importDocument() async {
    final XFile? file = await openFile(
      acceptedTypeGroups: [documentTypes],
    );

    if (file != null) {
      setState(() {
        _isLoading = true;
        _importedFile = file;
      });

      try {
        final content = await file.readAsString();
        setState(() {
          _fileContent = content;
          _isLoading = false;
        });
      } catch (e) {
        setState(() {
          _fileContent = '读取文件失败: $e';
          _isLoading = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('文档导入'),
        actions: [
          IconButton(
            icon: Icon(Icons.folder_open),
            onPressed: _importDocument,
          ),
        ],
      ),
      body: _isLoading
          ? Center(child: CircularProgressIndicator())
          : _importedFile == null
              ? Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.description, size: 64, color: Colors.grey),
                      SizedBox(height: 16),
                      Text('点击右上角按钮导入文档'),
                    ],
                  ),
                )
              : SingleChildScrollView(
                  padding: EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        '文件名: ${_importedFile!.name}',
                        style: TextStyle(fontWeight: FontWeight.bold),
                      ),
                      SizedBox(height: 8),
                      Divider(),
                      SizedBox(height: 8),
                      Text(
                        '文件内容:',
                        style: TextStyle(fontWeight: FontWeight.bold),
                      ),
                      SizedBox(height: 8),
                      Container(
                        padding: EdgeInsets.all(12),
                        decoration: BoxDecoration(
                          color: Colors.grey[200],
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Text(
                          _fileContent ?? '',
                          style: TextStyle(fontFamily: 'monospace'),
                        ),
                      ),
                    ],
                  ),
                ),
    );
  }
}

4.3 目录浏览器

dart 复制代码
class DirectoryBrowser extends StatefulWidget {
  @override
  _DirectoryBrowserState createState() => _DirectoryBrowserState();
}

class _DirectoryBrowserState extends State<DirectoryBrowser> {
  String? _selectedDirectory;

  Future<void> _selectDirectory() async {
    final String? directory = await getDirectoryPath();

    if (directory != null) {
      setState(() {
        _selectedDirectory = directory;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('目录浏览器')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.folder, size: 64, color: Colors.amber),
            SizedBox(height: 16),
            if (_selectedDirectory != null)
              Padding(
                padding: EdgeInsets.symmetric(horizontal: 32),
                child: Column(
                  children: [
                    Text(
                      '已选择目录:',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    SizedBox(height: 8),
                    Container(
                      padding: EdgeInsets.all(12),
                      decoration: BoxDecoration(
                        color: Colors.grey[200],
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text(
                        _selectedDirectory!,
                        textAlign: TextAlign.center,
                        style: TextStyle(fontFamily: 'monospace'),
                      ),
                    ),
                  ],
                ),
              )
            else
              Text('未选择目录'),
            SizedBox(height: 24),
            ElevatedButton.icon(
              onPressed: _selectDirectory,
              icon: Icon(Icons.folder_open),
              label: Text('选择目录'),
            ),
          ],
        ),
      ),
    );
  }
}

五、最佳实践

5.1 文件类型过滤策略

根据业务需求设置合适的文件类型过滤:

dart 复制代码
const XTypeGroup images = XTypeGroup(
  label: '图片',
  extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
);

const XTypeGroup documents = XTypeGroup(
  label: '文档',
  extensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'],
);

const XTypeGroup allSupported = XTypeGroup(
  label: '所有支持的文件',
  extensions: [
    'jpg', 'jpeg', 'png', 'gif', 'webp',
    'pdf', 'doc', 'docx', 'xls', 'xlsx',
  ],
);

Future<XFile?> pickImage() => openFile(acceptedTypeGroups: [images]);
Future<XFile?> pickDocument() => openFile(acceptedTypeGroups: [documents]);
Future<XFile?> pickAny() => openFile(acceptedTypeGroups: [allSupported]);

5.2 文件大小检查

在选择文件后检查文件大小:

dart 复制代码
Future<bool> checkFileSize(XFile file, int maxSizeMB) async {
  final int size = await file.length();
  final int maxSizeBytes = maxSizeMB * 1024 * 1024;
  
  if (size > maxSizeBytes) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('文件大小不能超过 ${maxSizeMB}MB')),
    );
    return false;
  }
  return true;
}

Future<void> _pickAndCheckFile() async {
  final XFile? file = await openFile();
  if (file != null) {
    if (await checkFileSize(file, 10)) {
      setState(() {
        _selectedFile = file;
      });
    }
  }
}

5.3 错误处理

对所有文件操作进行错误处理:

dart 复制代码
Future<void> _pickFileWithErrorHandling() async {
  try {
    final XFile? file = await openFile();
    if (file != null) {
      setState(() {
        _selectedFile = file;
      });
    }
  } on PlatformException catch (e) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('选择文件失败: ${e.message}')),
    );
  } catch (e) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('发生未知错误: $e')),
    );
  }
}

六、常见问题

Q1:如何获取文件的扩展名?

使用 Dart 的 path 包:

dart 复制代码
import 'package:path/path.dart' as path;

String getExtension(XFile file) {
  return path.extension(file.path).toLowerCase();
}

String getExtensionWithoutDot(XFile file) {
  return path.extension(file.path).replaceAll('.', '').toLowerCase();
}

Q2:如何判断文件类型?

通过扩展名或 MIME 类型判断:

dart 复制代码
bool isImage(XFile file) {
  final ext = path.extension(file.path).toLowerCase();
  return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
}

bool isDocument(XFile file) {
  final ext = path.extension(file.path).toLowerCase();
  return ['.pdf', '.doc', '.docx', '.xls', '.xlsx'].contains(ext);
}

bool isVideo(XFile file) {
  final ext = path.extension(file.path).toLowerCase();
  return ['.mp4', '.mov', '.avi', '.mkv'].contains(ext);
}

Q3:如何上传文件到服务器?

使用 http 包上传:

dart 复制代码
Future<void> uploadFile(XFile file) async {
  final uri = Uri.parse('https://your-server.com/upload');
  final request = http.MultipartRequest('POST', uri);
  
  final stream = http.ByteStream(file.openRead());
  final length = await file.length();
  
  final multipartFile = http.MultipartFile(
    'file',
    stream,
    length,
    filename: file.name,
  );
  
  request.files.add(multipartFile);
  final response = await request.send();
  
  if (response.statusCode == 200) {
    print('上传成功');
  }
}

Q4:OpenHarmony 平台支持保存文件吗?

目前 OpenHarmony 平台的 file_selector 不支持 getSaveLocation 方法(选择保存位置)。如果需要保存文件,可以使用 path_provider 库获取应用目录,然后直接写入文件。


七、总结

file_selector 库为 Flutter for OpenHarmony 开发提供了完整的文件选择功能。通过统一的 API 接口,开发者可以轻松实现单文件选择、多文件选择、目录选择等功能。该库支持丰富的文件类型过滤选项,能够满足各种业务场景的需求。在鸿蒙平台上,file_selector 已经完成了完整的适配,开发者可以放心使用。


八、完整代码示例

以下是一个完整的可运行示例,展示了 file_selector 库的所有核心功能:

pubspec.yaml

yaml 复制代码
name: file_selector_demo
description: Flutter for OpenHarmony file_selector 演示项目
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  file_selector:
    git:
      url: https://gitcode.com/openharmony-tpc/flutter_packages.git
      path: packages/file_selector/file_selector

flutter:
  uses-material-design: true

main.dart

dart 复制代码
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'File Selector Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('File Selector 文件选择演示'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: ListView(
        children: [
          _buildMenuItem(
            context,
            '单文件选择',
            '选择单个文件并查看详情',
            Icons.insert_drive_file,
            () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const SingleFileDemo()),
            ),
          ),
          _buildMenuItem(
            context,
            '多文件选择',
            '一次选择多个文件',
            Icons.folder_open,
            () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const MultiFileDemo()),
            ),
          ),
          _buildMenuItem(
            context,
            '文件类型过滤',
            '按文件类型筛选选择',
            Icons.filter_list,
            () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const FileTypeFilterDemo()),
            ),
          ),
          _buildMenuItem(
            context,
            '目录选择',
            '选择目录路径',
            Icons.folder,
            () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const DirectorySelectorDemo()),
            ),
          ),
          _buildMenuItem(
            context,
            '文档导入',
            '导入并读取文本文件',
            Icons.description,
            () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const DocumentImportDemo()),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildMenuItem(
    BuildContext context,
    String title,
    String subtitle,
    IconData icon,
    VoidCallback onTap,
  ) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: ListTile(
        leading: Icon(icon, size: 32, color: Theme.of(context).primaryColor),
        title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
        subtitle: Text(subtitle),
        trailing: const Icon(Icons.arrow_forward_ios),
        onTap: onTap,
      ),
    );
  }
}

class SingleFileDemo extends StatefulWidget {
  const SingleFileDemo({super.key});

  @override
  State<SingleFileDemo> createState() => _SingleFileDemoState();
}

class _SingleFileDemoState extends State<SingleFileDemo> {
  XFile? _selectedFile;

  Future<void> _pickFile() async {
    final XFile? file = await openFile();
    if (file != null) {
      setState(() => _selectedFile = file);
    }
  }

  String _formatFileSize(int bytes) {
    if (bytes < 1024) return '$bytes B';
    if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
    return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
  }

  IconData _getFileIcon(String fileName) {
    final ext = fileName.split('.').last.toLowerCase();
    switch (ext) {
      case 'pdf':
        return Icons.picture_as_pdf;
      case 'doc':
      case 'docx':
        return Icons.description;
      case 'xls':
      case 'xlsx':
        return Icons.table_chart;
      case 'jpg':
      case 'jpeg':
      case 'png':
      case 'gif':
        return Icons.image;
      case 'mp4':
      case 'mov':
        return Icons.videocam;
      case 'mp3':
      case 'wav':
        return Icons.audiotrack;
      default:
        return Icons.insert_drive_file;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('单文件选择'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(
        child: _selectedFile == null
            ? Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.insert_drive_file, size: 64, color: Colors.grey),
                  const SizedBox(height: 16),
                  const Text('未选择文件'),
                  const SizedBox(height: 24),
                  ElevatedButton.icon(
                    onPressed: _pickFile,
                    icon: const Icon(Icons.folder_open),
                    label: const Text('选择文件'),
                  ),
                ],
              )
            : SingleChildScrollView(
                padding: const EdgeInsets.all(16),
                child: Column(
                  children: [
                    Card(
                      child: Padding(
                        padding: const EdgeInsets.all(16),
                        child: Column(
                          children: [
                            Icon(
                              _getFileIcon(_selectedFile!.name),
                              size: 64,
                              color: Theme.of(context).primaryColor,
                            ),
                            const SizedBox(height: 16),
                            Text(
                              _selectedFile!.name,
                              style: const TextStyle(
                                fontSize: 18,
                                fontWeight: FontWeight.bold,
                              ),
                              textAlign: TextAlign.center,
                            ),
                            const SizedBox(height: 8),
                            FutureBuilder<int>(
                              future: _selectedFile!.length(),
                              builder: (context, snapshot) {
                                if (snapshot.hasData) {
                                  return Text(
                                    '大小: ${_formatFileSize(snapshot.data!)}',
                                    style: TextStyle(color: Colors.grey[600]),
                                  );
                                }
                                return const Text('计算中...');
                              },
                            ),
                            const SizedBox(height: 8),
                            Text(
                              '路径: ${_selectedFile!.path}',
                              style: TextStyle(
                                fontSize: 12,
                                color: Colors.grey[600],
                              ),
                              textAlign: TextAlign.center,
                            ),
                          ],
                        ),
                      ),
                    ),
                    const SizedBox(height: 24),
                    ElevatedButton.icon(
                      onPressed: _pickFile,
                      icon: const Icon(Icons.folder_open),
                      label: const Text('重新选择'),
                    ),
                  ],
                ),
              ),
      ),
    );
  }
}

class MultiFileDemo extends StatefulWidget {
  const MultiFileDemo({super.key});

  @override
  State<MultiFileDemo> createState() => _MultiFileDemoState();
}

class _MultiFileDemoState extends State<MultiFileDemo> {
  List<XFile> _selectedFiles = [];

  Future<void> _pickFiles() async {
    final List<XFile> files = await openFiles();
    if (files.isNotEmpty) {
      setState(() => _selectedFiles = files);
    }
  }

  IconData _getFileIcon(String fileName) {
    final ext = fileName.split('.').last.toLowerCase();
    switch (ext) {
      case 'pdf':
        return Icons.picture_as_pdf;
      case 'doc':
      case 'docx':
        return Icons.description;
      case 'jpg':
      case 'jpeg':
      case 'png':
        return Icons.image;
      default:
        return Icons.insert_drive_file;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('多文件选择'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Column(
        children: [
          Expanded(
            child: _selectedFiles.isEmpty
                ? const Center(child: Text('未选择文件'))
                : ListView.builder(
                    padding: const EdgeInsets.all(8),
                    itemCount: _selectedFiles.length,
                    itemBuilder: (context, index) {
                      final file = _selectedFiles[index];
                      return Card(
                        child: ListTile(
                          leading: Icon(_getFileIcon(file.name)),
                          title: Text(file.name),
                          subtitle: Text(
                            file.path,
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                          ),
                        ),
                      );
                    },
                  ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: ElevatedButton.icon(
              onPressed: _pickFiles,
              icon: const Icon(Icons.folder_open),
              label: Text('选择文件 (${_selectedFiles.length})'),
            ),
          ),
        ],
      ),
    );
  }
}

class FileTypeFilterDemo extends StatefulWidget {
  const FileTypeFilterDemo({super.key});

  @override
  State<FileTypeFilterDemo> createState() => _FileTypeFilterDemoState();
}

class _FileTypeFilterDemoState extends State<FileTypeFilterDemo> {
  XFile? _selectedFile;
  String _selectedType = '图片';

  static const Map<String, XTypeGroup> _typeGroups = {
    '图片': XTypeGroup(
      label: '图片',
      extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
    ),
    '文档': XTypeGroup(
      label: '文档',
      extensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'],
    ),
    '视频': XTypeGroup(
      label: '视频',
      extensions: ['mp4', 'mov', 'avi', 'mkv'],
    ),
    '音频': XTypeGroup(
      label: '音频',
      extensions: ['mp3', 'wav', 'aac', 'flac'],
    ),
  };

  Future<void> _pickFile() async {
    final typeGroup = _typeGroups[_selectedType];
    final XFile? file = await openFile(
      acceptedTypeGroups: [typeGroup!],
    );
    if (file != null) {
      setState(() => _selectedFile = file);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('文件类型过滤'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            DropdownButtonFormField<String>(
              value: _selectedType,
              decoration: const InputDecoration(
                labelText: '文件类型',
                border: OutlineInputBorder(),
              ),
              items: _typeGroups.keys.map((type) {
                return DropdownMenuItem(value: type, child: Text(type));
              }).toList(),
              onChanged: (value) {
                setState(() {
                  _selectedType = value!;
                  _selectedFile = null;
                });
              },
            ),
            const SizedBox(height: 24),
            if (_selectedFile != null)
              Card(
                child: ListTile(
                  leading: const Icon(Icons.insert_drive_file),
                  title: Text(_selectedFile!.name),
                  subtitle: Text(_selectedFile!.path),
                ),
              )
            else
              const Text('未选择文件'),
            const SizedBox(height: 24),
            ElevatedButton.icon(
              onPressed: _pickFile,
              icon: const Icon(Icons.folder_open),
              label: Text('选择 $_selectedType'),
            ),
          ],
        ),
      ),
    );
  }
}

class DirectorySelectorDemo extends StatefulWidget {
  const DirectorySelectorDemo({super.key});

  @override
  State<DirectorySelectorDemo> createState() => _DirectorySelectorDemoState();
}

class _DirectorySelectorDemoState extends State<DirectorySelectorDemo> {
  String? _selectedDirectory;

  Future<void> _selectDirectory() async {
    final String? directory = await getDirectoryPath();
    if (directory != null) {
      setState(() => _selectedDirectory = directory);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('目录选择'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.folder, size: 64, color: Colors.amber),
            const SizedBox(height: 16),
            if (_selectedDirectory != null)
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 32),
                child: Column(
                  children: [
                    const Text(
                      '已选择目录:',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    const SizedBox(height: 8),
                    Container(
                      padding: const EdgeInsets.all(12),
                      decoration: BoxDecoration(
                        color: Colors.grey[200],
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text(
                        _selectedDirectory!,
                        textAlign: TextAlign.center,
                        style: const TextStyle(fontFamily: 'monospace'),
                      ),
                    ),
                  ],
                ),
              )
            else
              const Text('未选择目录'),
            const SizedBox(height: 24),
            ElevatedButton.icon(
              onPressed: _selectDirectory,
              icon: const Icon(Icons.folder_open),
              label: const Text('选择目录'),
            ),
          ],
        ),
      ),
    );
  }
}

class DocumentImportDemo extends StatefulWidget {
  const DocumentImportDemo({super.key});

  @override
  State<DocumentImportDemo> createState() => _DocumentImportDemoState();
}

class _DocumentImportDemoState extends State<DocumentImportDemo> {
  XFile? _importedFile;
  String? _fileContent;
  bool _isLoading = false;

  static const XTypeGroup documentTypes = XTypeGroup(
    label: '文档',
    extensions: ['txt', 'json', 'xml', 'csv', 'md'],
  );

  Future<void> _importDocument() async {
    final XFile? file = await openFile(acceptedTypeGroups: [documentTypes]);
    if (file != null) {
      setState(() {
        _isLoading = true;
        _importedFile = file;
      });

      try {
        final content = await file.readAsString();
        setState(() {
          _fileContent = content;
          _isLoading = false;
        });
      } catch (e) {
        setState(() {
          _fileContent = '读取文件失败: $e';
          _isLoading = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('文档导入'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          IconButton(
            icon: const Icon(Icons.folder_open),
            onPressed: _importDocument,
          ),
        ],
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _importedFile == null
              ? Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.description, size: 64, color: Colors.grey[400]),
                      const SizedBox(height: 16),
                      const Text('点击右上角按钮导入文档'),
                    ],
                  ),
                )
              : SingleChildScrollView(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        '文件名: ${_importedFile!.name}',
                        style: const TextStyle(fontWeight: FontWeight.bold),
                      ),
                      const SizedBox(height: 8),
                      const Divider(),
                      const SizedBox(height: 8),
                      const Text(
                        '文件内容:',
                        style: TextStyle(fontWeight: FontWeight.bold),
                      ),
                      const SizedBox(height: 8),
                      Container(
                        width: double.infinity,
                        padding: const EdgeInsets.all(12),
                        decoration: BoxDecoration(
                          color: Colors.grey[200],
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Text(
                          _fileContent ?? '',
                          style: const TextStyle(fontFamily: 'monospace'),
                        ),
                      ),
                    ],
                  ),
                ),
    );
  }
}

九、参考资源

相关推荐
世人万千丶2 小时前
Flutter 框架跨平台鸿蒙开发 - 数独游戏应用开发文档
学习·flutter·游戏·华为·harmonyos·鸿蒙
AI_零食2 小时前
开源鸿蒙跨平台Flutter开发:研究生科研贡献雷达矩阵架构
学习·flutter·ui·华为·矩阵·开源·harmonyos
小雨天気.3 小时前
Flutter 框架跨平台鸿蒙开发 - 人生角色卡应用
flutter·华为·生活·harmonyos·鸿蒙
程序员Ctrl喵3 小时前
Flutter 第三阶段:基础 Widget 全面指南
开发语言·javascript·flutter
独特的螺狮粉3 小时前
开源鸿蒙跨平台Flutter开发:微波射频阻抗匹配系统-极坐标史密斯圆图与天线信号渲染架构
开发语言·flutter·华为·架构·开源·harmonyos
小雨天気.3 小时前
Flutter 框架跨平台鸿蒙开发 - 选择困难终结者应用
flutter·华为·生活·harmonyos·鸿蒙
世人万千丶4 小时前
开源鸿蒙跨平台Flutter开发:儿童语文认知与语义皮层映射引擎_视觉词形区与布洛卡网络渲染架构
flutter·华为·开源·harmonyos
小雨天気.4 小时前
Flutter 框架跨平台鸿蒙开发 - 每日一问应用
flutter·华为·生活·harmonyos·鸿蒙
Utopia^4 小时前
Flutter 框架跨平台鸿蒙开发 - 闪回相机
数码相机·flutter·华为·harmonyos