Flutter for OpenHarmony 实战:file_selector — 原生文件选择指南

前言

在移动应用开发中,让用户选择文件(如图片、文档、视频)是一个非常高频的需求。虽然 image_picker 可以处理图片和视频,但当我们需要选择任意类型的文件(如 PDF、JSON、ZIP)时,就需要更通用的解决方案。

file_selector 是 Flutter 官方提供的插件,旨在提供跨平台(iOS, Android, Web, Desktop, OpenHarmony)的统一文件选择能力。在 OpenHarmony 上,它对接了系统的 FilePicker 接口,能够拉起原生的文件选择器,用户体验非常流畅。

本文将介绍如何在 OpenHarmony 项目中集成 file_selector,实现单文件选择、多文件选择以及保存文件(另存为)的功能。

一、核心功能

file_selector 的 API 设计非常简洁,主要包含以下几个核心方法:

  • openFile: 选择单个文件。
  • openFiles: 选择多个文件。
  • path_provider: 保存文件
  • getSaveLocation: 获取文件保存路径(即“另存为”对话框)。

二、安装与配置

2.1 添加依赖

pubspec.yaml 中添加:

dependencies:
  flutter:
    sdk: flutter
  # 请务必检查 pub.dev 或 OpenHarmony SIG 仓库获取适配 OHOS 的版本
  file_selector: ^1.0.0
  file_selector_ohos: any
  path_provider_ohos: any
  path_provider: ^2.1.5

dependency_overrides:
 # File Selector 全套覆盖
  file_selector:
    git:
      url: "https://atomgit.com/openharmony-tpc/flutter_packages.git"
      path: "packages/file_selector/file_selector"
  file_selector_platform_interface:
    git:
      url: "https://atomgit.com/openharmony-tpc/flutter_packages.git"
      path: "packages/file_selector/file_selector_platform_interface"
  file_selector_ohos:
    git:
      url: "https://atomgit.com/openharmony-tpc/flutter_packages.git"
      path: "packages/file_selector/file_selector_ohos"
        # Path Provider Adapter
  path_provider_ohos:
    git:
      url: "https://atomgit.com/openharmony-tpc/flutter_packages.git"
      path: "packages/path_provider/path_provider_ohos"

在这里插入图片描述

2.2 OpenHarmony 权限配置

使用系统的 Picker 接口选择文件通常是一个“用户授权”的过程(用户主动选择文件即视为授权),因此通常不需要像 Android 那样申请 READ_EXTERNAL_STORAGE 权限。OpenHarmony 的沙箱机制会自动授权应用读取用户选中的那个文件。

但是,如果你需要访问特定公共目录,可以按需检查 module.json5 中的权限配置,一般来说,基础的文件选择无需额外配置。

三、代码实现

3.1 选择单个文件

我们可以指定 XTypeGroup 来过滤文件类型,例如只允许选择文本文件或图片。

import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';

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

  Future<void> _pickSingleFile(BuildContext context) async {
    // 定义文件类型过滤器
    const XTypeGroup typeGroup = XTypeGroup(
      label: 'images',
      extensions: <String>['jpg', 'png'],
    );
    
    // 打开选择器
    final XFile? file = await openFile(
      acceptedTypeGroups: <XTypeGroup>[typeGroup],
    );

    if (file != null) {
      // 获取文件名和路径
      print('文件名: ${file.name}');
      print('路径: ${file.path}');
      
      // 读取文件内容
      final int size = await file.length();
      print('文件大小: $size 字节');
      
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('已选择: ${file.name}')),
      );
    } else {
      // 用户取消了选择
      print('用户取消操作');
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('文件选择演示')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => _pickSingleFile(context),
          child: const Text('选择一张图片'),
        ),
      ),
    );
  }
}

在这里插入图片描述

3.2 选择多个文件

使用 openFiles 方法即可,返回的是 List<XFile>

Future<void> _pickMultipleFiles() async {
  final List<XFile> files = await openFiles(
    acceptedTypeGroups: <XTypeGroup>[
      const XTypeGroup(label: 'All Files'), // 允许所有类型
    ],
  );

  print('选择了 ${files.length} 个文件');
  for (var file in files) {
    print('路径: ${file.path}');
  }
}

在这里插入图片描述

3.3 保存文件(另存为)

在 OpenHarmony 上,这会拉起系统的“保存文件”面板,让用户选择保存的位置和文件名。

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';
import 'package:path_provider/path_provider.dart';

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

  Future<void> _saveFile(BuildContext context) async {
    final String fileName =
        'my_export_data_${DateTime.now().millisecondsSinceEpoch}.txt';

    try {
      debugPrint('准备保存文件...');

      // 1. 获取应用私有文档目录 (Sandboxed)
      // 由于 file_selector_ohos 暂未实现 getSaveLocation,
      // 我们推荐使用标准路径保存数据
      final Directory directory = await getApplicationDocumentsDirectory();
      final String path = '${directory.path}/$fileName';

      debugPrint('目标路径: $path');

      // 2. 写入文件
      final File file = File(path);
      await file.writeAsString(
          'Hello OpenHarmony from Flutter! \nSaved at ${DateTime.now()}');

      debugPrint('文件写入成功');

      if (context.mounted) {
        // 3. 提示用户
        showDialog(
          context: context,
          builder: (ctx) => AlertDialog(
            title: const Text('保存成功'),
            content: SingleChildScrollView(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Text('文件已保存至应用沙箱目录:'),
                  const SizedBox(height: 8),
                  Container(
                    padding: const EdgeInsets.all(8),
                    color: Colors.grey[200],
                    child: Text(path, style: const TextStyle(fontSize: 12)),
                  ),
                  const SizedBox(height: 16),
                  const Text(
                      '注:由于 file_selector 暂未适配 OpenHarmony "另存为" 接口,此处演示使用 path_provider 保存到应用私有目录。',
                      style: TextStyle(color: Colors.grey, fontSize: 12)),
                ],
              ),
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(ctx),
                child: const Text('好的'),
              ),
            ],
          ),
        );
      }
    } catch (e) {
      debugPrint('保存出错: $e');
      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('保存失败: $e')),
        );
      }
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('3.3 保存文件 (替代方案)')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Padding(
              padding: EdgeInsets.all(16.0),
              child: Text(
                '注:OpenHarmony 暂不支持 file_selector 的 "另存为" (getSaveLocation)。\n'
                '本页演示使用 path_provider 保存文件到应用沙箱。',
                textAlign: TextAlign.center,
                style: TextStyle(color: Colors.grey),
              ),
            ),
            ElevatedButton(
              onPressed: () => _saveFile(context),
              child: const Text('保存 text 文件到沙箱'),
            ),
          ],
        ),
      ),
    );
  }
}

在这里插入图片描述

四、OpenHarmony 平台适配细节

4.1 路径与沙箱

OpenHarmony 采用严格的沙箱机制。当用户通过 Picker 选择文件后,系统通常会授予应用对该文件的临时读写权限。file_selector 返回的 path 通常是一个可以直接供 Dart File 对象读取的路径。

4.2 MIME Type 过滤

XTypeGroup 支持 extensions(扩展名)、mimeTypes(MIME类型)和 macUTIs(iOS专用)。在 OpenHarmony 上,建议优先使用 extensions,兼容性最好。

// 推荐做法:使用扩展名
const XTypeGroup(extensions: ['pdf', 'doc']);

// 可能存在兼容性差异:使用 MIME
// const XTypeGroup(mimeTypes: ['application/pdf']); 

4.3 UI 差异

file_selector 在 OpenHarmony 上调用的是系统级的 UI(FilePicker)。这意味着 UI 样式(如列表视图、网格视图、排序方式)是由系统决定的,Flutter 无法修改其外观,只能控制可选的文件类型。

五、完整示例代码

下面是一个综合示例,包含单选、多选和保存功能。

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';
import 'package:path_provider/path_provider.dart';

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

  
  State<FileSelectorPage> createState() => _FileSelectorPageState();
}

class _FileSelectorPageState extends State<FileSelectorPage> {
  String _statusText = '请选择操作';
  List<XFile> _selectedFiles = [];

  // 选择图片
  Future<void> _pickImage() async {
    const XTypeGroup typeGroup = XTypeGroup(
      label: 'images',
      extensions: <String>['jpg', 'png', 'gif'],
    );
    // 捕获异常,防止在不支持的平台上崩溃
    try {
      final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]);
      if (file != null) {
        setState(() {
          _statusText = '已选择: ${file.name}';
          _selectedFiles = [file];
        });
      } else {
        // 用户取消
      }
    } catch (e) {
      setState(() {
        _statusText = '错误: $e';
      });
    }
  }

  // 选择多个文档
  Future<void> _pickDocs() async {
    const XTypeGroup typeGroup = XTypeGroup(
      label: 'documents',
      extensions: <String>['pdf', 'txt', 'json'],
    );
    try {
      final List<XFile> files =
          await openFiles(acceptedTypeGroups: [typeGroup]);
      if (files.isNotEmpty) {
        setState(() {
          _statusText = '已选择 ${files.length} 个文件';
          _selectedFiles = files;
        });
      }
    } catch (e) {
      setState(() {
        _statusText = '错误: $e';
      });
    }
  }

  // 另存为 (模拟导出功能)
  // 注: 由于 file_selector_ohos 暂未实现 getSaveLocation,这里使用 path_provider 替代
  Future<void> _saveText() async {
    try {
      final Directory directory = await getApplicationDocumentsDirectory();
      final String fileName =
          'example_${DateTime.now().millisecondsSinceEpoch}.txt';
      final String path = '${directory.path}/$fileName';

      // 写入数据
      final file = File(path);
      await file.writeAsString(
          '这是通过 Flutter file_selector 页面演示保存的文件内容。\nTime: ${DateTime.now()}');

      if (!mounted) return;
      setState(() {
        _statusText = '文件已保存至沙箱: $path';
      });

      // 弹出详细信息的 Dialog
      showDialog(
        context: context,
        builder: (ctx) => AlertDialog(
          title: const Text('保存成功 (path_provider)'),
          content: SingleChildScrollView(
            child: Text('文件已写入应用私有目录:\n\n$path\n\n(OpenHarmony 暂不支持原生“另存为”面板)'),
          ),
          actions: [
            TextButton(
                onPressed: () => Navigator.pop(ctx), child: const Text('OK'))
          ],
        ),
      );
    } catch (e) {
      setState(() {
        _statusText = '保存失败: $e';
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('File Selector OHOS')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            Container(
              padding: const EdgeInsets.all(12),
              width: double.infinity,
              decoration: BoxDecoration(
                color: Colors.grey[200],
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(_statusText, style: const TextStyle(fontSize: 16)),
            ),
            const SizedBox(height: 20),

            // 选中文件列表区域
            Expanded(
              child: _selectedFiles.isEmpty
                  ? Center(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Icon(Icons.folder_open,
                              size: 64, color: Colors.indigo[100]),
                          const SizedBox(height: 16),
                          const Text('暂无选中文件',
                              style: TextStyle(color: Colors.grey)),
                        ],
                      ),
                    )
                  : ListView.builder(
                      itemCount: _selectedFiles.length,
                      itemBuilder: (ctx, index) {
                        final file = _selectedFiles[index];
                        return Card(
                          elevation: 2,
                          margin: const EdgeInsets.only(bottom: 8),
                          child: ListTile(
                            leading: Icon(
                              _getFileIcon(file.name),
                              color: Colors.indigo,
                            ),
                            title: Text(file.name),
                            subtitle: Text(file.path,
                                maxLines: 1, overflow: TextOverflow.ellipsis),
                            trailing: Text(
                              _formatSize(
                                  index), // 仅为演示,实际需异步获取 await file.length()
                              style: const TextStyle(
                                  fontSize: 12, color: Colors.grey),
                            ),
                          ),
                        );
                      },
                    ),
            ),

            const Divider(),

            // 操作按钮区域
            Wrap(
              spacing: 16,
              runSpacing: 16,
              alignment: WrapAlignment.center,
              children: [
                _ActionButton(
                  icon: Icons.image,
                  label: '选图片',
                  color: Colors.purple,
                  onPressed: _pickImage,
                ),
                _ActionButton(
                  icon: Icons.library_books,
                  label: '选文档(多选)',
                  color: Colors.orange,
                  onPressed: _pickDocs,
                ),
                _ActionButton(
                  icon: Icons.save,
                  label: '另存为',
                  color: Colors.blue,
                  onPressed: _saveText,
                ),
              ],
            ),
            const SizedBox(height: 20),
          ],
        ),
      ),
    );
  }

  IconData _getFileIcon(String name) {
    final lowerName = name.toLowerCase();
    if (lowerName.endsWith('.jpg') || lowerName.endsWith('.png'))
      return Icons.image;
    if (lowerName.endsWith('.pdf')) return Icons.picture_as_pdf;
    if (lowerName.endsWith('.txt')) return Icons.description;
    if (lowerName.endsWith('.json')) return Icons.code;
    return Icons.insert_drive_file;
  }

  String _formatSize(int index) {
    // 真实场景下 XFile.length() 是 Future,这里为了 UI 简洁未调用
    return 'Tap to read';
  }
}

class _ActionButton extends StatelessWidget {
  final IconData icon;
  final String label;
  final Color color;
  final VoidCallback onPressed;

  const _ActionButton({
    required this.icon,
    required this.label,
    required this.color,
    required this.onPressed,
  });

  
  Widget build(BuildContext context) {
    return ElevatedButton.icon(
      style: ElevatedButton.styleFrom(
        backgroundColor: color,
        foregroundColor: Colors.white,
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
      ),
      onPressed: onPressed,
      icon: Icon(icon),
      label: Text(label),
    );
  }
}

在这里插入图片描述

六、总结

file_selector 为 OpenHarmony 应用提供了标准化的文件交互能力。相比于直接调用原生 Channel,使用此插件能保持代码的跨平台一致性。

在处理大文件读取时,建议配合 Dart 的 StreamIsolate 来避免阻塞 UI 线程。


🌐 欢迎加入开源鸿蒙跨平台社区:开源鸿蒙跨平台开发者社区

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐