HarmonyOS开发中文件选择器:FilePicker集成

一、小知识

你肯定用过这种功能——点击"上传头像"按钮,弹出个文件选择框,选张照片上传;或者点击"导出数据",选择保存位置,文件就存到那儿了。这些看似简单的交互,背后都离不开文件选择器

在HarmonyOS里,文件选择器不是简单的UI组件,而是系统级的安全服务。为什么这么设计?因为文件访问涉及用户隐私,应用不能随意读取用户文件。通过FilePicker,用户主动选择文件,系统再把选中的文件权限临时授予应用——这就是所谓的SAF(Storage Access Framework)机制。

HarmonyOS提供了两类选择器:

  • PhotoPicker:专门用于选择照片和视频,集成相册管理
  • DocumentPicker:通用文件选择器,支持文档、下载等

这两者用法相似,但权限模型和返回数据有差异。用错了可能导致权限问题或者功能异常——咱们这篇就把这些细节掰开揉碎讲清楚。

二、核心原理

2.1 SAF安全机制

传统Android应用可以直接读取外部存储,导致隐私泄露风险。HarmonyOS采用SAF机制,应用访问用户文件必须通过Picker:

graph TD
    A[应用请求文件]:::primary --> B[启动FilePicker]:::info
    B --> C[用户选择文件]:::warning
    C --> D{用户确认?}:::warning
    D -->|是| E[系统授予临时URI权限]:::success
    D -->|否| F[返回取消]:::danger
    E --> G[应用通过URI访问文件]:::primary
    
    classDef primary fill:#2196F3,stroke:#1976D2,color:#fff
    classDef info fill:#00BCD4,stroke:#0097A7,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef success fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef danger fill:#F44336,stroke:#D32F2F,color:#fff

2.2 URI权限模型

Picker返回的不是文件路径,而是content URI

content://media/external/images/media/123

这种URI有几个特点:

  • 临时权限:只在当前会话有效,应用重启后失效
  • 安全隔离:应用无法推断出实际文件路径
  • 跨进程访问:通过ContentProvider访问文件内容

2.3 Picker类型对比

特性PhotoPickerDocumentPicker
适用场景照片、视频选择文档、通用文件
权限要求READ_MEDIA无需声明权限
返回数据URI数组URI数组
支持多选
支持保存是(Save模式)
graph LR
    subgraph Picker类型选择
        A[需要选择照片/视频?]:::warning
        B[使用PhotoPicker]:::success
        C[使用DocumentPicker]:::info
    end
    
    A -->|是| B
    A -->|否| C
    
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef success fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef info fill:#00BCD4,stroke:#0097A7,color:#fff

三、代码实战

3.1 PhotoPicker基础用法

选择照片是最常见的场景,PhotoPicker专门为此优化:

import picker from '@ohos.file.picker';
import image from '@ohos.multimedia.image';
import fs from '@ohos.file.fs';

/**
 * 照片选择器封装
 */
export class PhotoSelector {
  /**
   * 选择单张照片
   * @returns 照片URI,取消返回null
   */
  async selectSingle(): Promise<string | null> {
    const photoPicker = new picker.PhotoViewPicker();
    
    try {
      const selectOption: picker.PhotoSelectOptions = {
        maxSelectNumber: 1,  // 只选一张
        MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE  // 只选图片
      };
      
      const result = await photoPicker.select(selectOption);
      
      if (result && result.photoUris && result.photoUris.length > 0) {
        return result.photoUris[0];
      }
      
      return null;
    } catch (error) {
      console.error(`[PhotoSelector] Select failed: ${error.message}`);
      throw error;
    }
  }

  /**
   * 选择多张照片
   * @param maxCount 最大选择数量
   * @returns 照片URI数组
   */
  async selectMultiple(maxCount: number = 9): Promise<string[]> {
    const photoPicker = new picker.PhotoViewPicker();
    
    const selectOption: picker.PhotoSelectOptions = {
      maxSelectNumber: maxCount,
      MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE
    };
    
    const result = await photoPicker.select(selectOption);
    return result?.photoUris ?? [];
  }

  /**
   * 选择视频
   * @returns 视频URI数组
   */
  async selectVideo(maxCount: number = 1): Promise<string[]> {
    const photoPicker = new picker.PhotoViewPicker();
    
    const selectOption: picker.PhotoSelectOptions = {
      maxSelectNumber: maxCount,
      MIMEType: picker.PhotoViewMIMETypes.VIDEO_TYPE  // 只选视频
    };
    
    const result = await photoPicker.select(selectOption);
    return result?.photoUris ?? [];
  }

  /**
   * 选择照片和视频混合
   * @returns 媒体URI数组
   */
  async selectMixed(maxCount: number = 9): Promise<string[]> {
    const photoPicker = new picker.PhotoViewPicker();
    
    const selectOption: picker.PhotoSelectOptions = {
      maxSelectNumber: maxCount,
      MIMEType: picker.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE  // 图片和视频
    };
    
    const result = await photoPicker.select(selectOption);
    return result?.photoUris ?? [];
  }

  /**
   * 将URI转换为PixelMap用于显示
   */
  async uriToPixelMap(uri: string): Promise<image.PixelMap | null> {
    try {
      const imageSource = image.createImageSource(uri);
      const pixelMap = await imageSource.createPixelMap();
      return pixelMap;
    } catch (error) {
      console.error(`[PhotoSelector] Failed to create PixelMap: ${error.message}`);
      return null;
    }
  }

  /**
   * 将URI转换为ArrayBuffer用于上传
   */
  async uriToArrayBuffer(uri: string): Promise<ArrayBuffer | null> {
    try {
      const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
      const stat = fs.statSync(uri);
      const buffer = new ArrayBuffer(stat.size);
      fs.readSync(file.fd, buffer);
      fs.closeSync(file);
      return buffer;
    } catch (error) {
      console.error(`[PhotoSelector] Failed to read file: ${error.message}`);
      return null;
    }
  }
}

3.2 DocumentPicker进阶用法

DocumentPicker更通用,支持选择文档和保存文件:

import picker from '@ohos.file.picker';
import fs from '@ohos.file.fs';

/**
 * 文档选择器封装
 */
export class DocumentSelector {
  /**
   * 选择单个文档
   * @param fileSuffix 文件后缀过滤,如 ['.pdf', '.doc']
   * @returns 文档URI
   */
  async selectSingle(fileSuffix?: string[]): Promise<string | null> {
    const documentPicker = new picker.DocumentViewPicker();
    
    const selectOption: picker.DocumentSelectOptions = {
      maxSelectNumber: 1,
      defaultFilePathUri: '',  // 默认打开路径
      fileSuffixFilters: fileSuffix  // 文件类型过滤
    };
    
    try {
      const result = await documentPicker.select(selectOption);
      
      if (result && result.length > 0) {
        return result[0];
      }
      
      return null;
    } catch (error) {
      console.error(`[DocumentSelector] Select failed: ${error.message}`);
      throw error;
    }
  }

  /**
   * 选择多个文档
   * @param maxCount 最大数量
   * @param fileSuffix 文件后缀过滤
   * @returns 文档URI数组
   */
  async selectMultiple(
    maxCount: number = 10,
    fileSuffix?: string[]
  ): Promise<string[]> {
    const documentPicker = new picker.DocumentViewPicker();
    
    const selectOption: picker.DocumentSelectOptions = {
      maxSelectNumber: maxCount,
      fileSuffixFilters: fileSuffix
    };
    
    const result = await documentPicker.select(selectOption);
    return result ?? [];
  }

  /**
   * 保存文件(选择保存位置)
   * @param defaultName 默认文件名
   * @param fileSuffix 文件后缀
   * @returns 保存位置的URI
   */
  async saveFile(defaultName: string, fileSuffix: string): Promise<string | null> {
    const documentPicker = new picker.DocumentViewPicker();
    
    const saveOption: picker.DocumentSaveOptions = {
      defaultFilePathUri: '',  // 默认保存路径
      defaultFileName: defaultName,
      fileSuffixChoices: [fileSuffix]
    };
    
    try {
      const result = await documentPicker.save(saveOption);
      
      if (result && result.length > 0) {
        return result[0];
      }
      
      return null;
    } catch (error) {
      console.error(`[DocumentSelector] Save failed: ${error.message}`);
      throw error;
    }
  }

  /**
   * 导出数据到文件
   * 用户选择保存位置后写入数据
   */
  async exportData(
    data: ArrayBuffer | string,
    defaultName: string,
    fileSuffix: string
  ): Promise<boolean> {
    // 选择保存位置
    const saveUri = await this.saveFile(defaultName, fileSuffix);
    
    if (!saveUri) {
      console.info('[DocumentSelector] User cancelled save');
      return false;
    }
    
    try {
      // 写入数据
      const file = fs.openSync(saveUri, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE);
      
      if (typeof data === 'string') {
        fs.writeSync(file.fd, data);
      } else {
        fs.writeSync(file.fd, data);
      }
      
      fs.closeSync(file);
      console.info(`[DocumentSelector] Data exported to: ${saveUri}`);
      return true;
    } catch (error) {
      console.error(`[DocumentSelector] Export failed: ${error.message}`);
      return false;
    }
  }

  /**
   * 读取文档内容
   */
  async readDocument(uri: string): Promise<ArrayBuffer | null> {
    try {
      const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
      const stat = fs.statSync(uri);
      const buffer = new ArrayBuffer(stat.size);
      fs.readSync(file.fd, buffer);
      fs.closeSync(file);
      return buffer;
    } catch (error) {
      console.error(`[DocumentSelector] Read failed: ${error.message}`);
      return null;
    }
  }

  /**
   * 读取文本文档
   */
  async readTextDocument(uri: string): Promise<string | null> {
    try {
      const content = fs.readTextSync(uri);
      return content;
    } catch (error) {
      console.error(`[DocumentSelector] Read text failed: ${error.message}`);
      return null;
    }
  }
}

3.3 实战案例:头像上传

结合PhotoPicker实现完整的头像选择和上传流程:

import { PhotoSelector } from './PhotoSelector';
import http from '@ohos.net.http';
import image from '@ohos.multimedia.image';

/**
 * 头像上传管理器
 */
export class AvatarUploader {
  private photoSelector: PhotoSelector = new PhotoSelector();
  private uploadUrl: string;

  constructor(uploadUrl: string) {
    this.uploadUrl = uploadUrl;
  }

  /**
   * 选择并上传头像
   * @param onProgress 上传进度回调
   * @returns 上传后的头像URL
   */
  async selectAndUpload(
    onProgress?: (progress: number) => void
  ): Promise<string | null> {
    // 1. 选择照片
    const uri = await this.photoSelector.selectSingle();
    
    if (!uri) {
      console.info('[AvatarUploader] User cancelled selection');
      return null;
    }

    // 2. 压缩图片
    const compressedBuffer = await this.compressImage(uri, 300, 300, 80);
    
    if (!compressedBuffer) {
      throw new Error('Image compression failed');
    }

    // 3. 上传到服务器
    const avatarUrl = await this.uploadImage(compressedBuffer, onProgress);
    
    return avatarUrl;
  }

  /**
   * 压缩图片
   * @param uri 图片URI
   * @param maxWidth 最大宽度
   * @param maxHeight 最大高度
   * @param quality 压缩质量 0-100
   */
  private async compressImage(
    uri: string,
    maxWidth: number,
    maxHeight: number,
    quality: number
  ): Promise<ArrayBuffer | null> {
    try {
      // 创建ImageSource
      const imageSource = image.createImageSource(uri);
      
      // 获取原图信息
      const imageInfo = await imageSource.getImageInfo();
      
      // 计算缩放比例
      let scale = 1;
      if (imageInfo.size.width > maxWidth || imageInfo.size.height > maxHeight) {
        const widthScale = maxWidth / imageInfo.size.width;
        const heightScale = maxHeight / imageInfo.size.height;
        scale = Math.min(widthScale, heightScale);
      }
      
      // 解码为PixelMap
      const decodingOptions: image.DecodingOptions = {
        desiredSize: {
          width: Math.floor(imageInfo.size.width * scale),
          height: Math.floor(imageInfo.size.height * scale)
        },
        editable: true
      };
      
      const pixelMap = await imageSource.createPixelMap(decodingOptions);
      
      // 打包为JPEG
      const packingOptions: image.PackingOption = {
        format: 'image/jpeg',
        quality: quality
      };
      
      const imagePackerApi = image.createImagePacker();
      const packedBuffer = await imagePackerApi.packing(pixelMap, packingOptions);
      
      // 释放资源
      pixelMap.release();
      imagePackerApi.release();
      
      return packedBuffer;
    } catch (error) {
      console.error(`[AvatarUploader] Compress failed: ${error.message}`);
      return null;
    }
  }

  /**
   * 上传图片到服务器
   */
  private async uploadImage(
    imageData: ArrayBuffer,
    onProgress?: (progress: number) => void
  ): Promise<string> {
    const httpRequest = http.createHttp();
    
    try {
      const response = await httpRequest.request(this.uploadUrl, {
        method: http.RequestMethod.POST,
        header: {
          'Content-Type': 'multipart/form-data'
        },
        extraData: imageData,
        expectDataType: http.HttpDataType.OBJECT
      });
      
      if (response.responseCode === 200) {
        const result = response.result as UploadResult;
        return result.url;
      } else {
        throw new Error(`Upload failed: ${response.responseCode}`);
      }
    } finally {
      httpRequest.destroy();
    }
  }
}

/**
 * 上传结果
 */
interface UploadResult {
  url: string;
  success: boolean;
}

3.4 完整UI示例

import { PhotoSelector } from './PhotoSelector';
import { DocumentSelector } from './DocumentSelector';
import { AvatarUploader } from './AvatarUploader';
import image from '@ohos.multimedia.image';

@Entry
@Component
struct FilePickerDemoPage {
  @State selectedImages: image.PixelMap[] = [];
  @State selectedFiles: string[] = [];
  @State avatarUrl: string = '';
  @State message: string = '文件选择器演示';
  
  private photoSelector: PhotoSelector = new PhotoSelector();
  private documentSelector: DocumentSelector = new DocumentSelector();
  private avatarUploader: AvatarUploader = new AvatarUploader('https://api.example.com/upload');

  /**
   * 选择照片
   */
  async selectPhotos(): Promise<void> {
    try {
      const uris = await this.photoSelector.selectMultiple(9);
      
      // 清空之前的选择
      this.selectedImages = [];
      
      // 转换为PixelMap用于显示
      for (const uri of uris) {
        const pixelMap = await this.photoSelector.uriToPixelMap(uri);
        if (pixelMap) {
          this.selectedImages.push(pixelMap);
        }
      }
      
      this.message = `已选择 ${this.selectedImages.length} 张照片`;
    } catch (error) {
      this.message = `选择失败: ${error.message}`;
    }
  }

  /**
   * 选择文档
   */
  async selectDocuments(): Promise<void> {
    try {
      const uris = await this.documentSelector.selectMultiple(5, ['.pdf', '.doc', '.txt']);
      this.selectedFiles = uris;
      this.message = `已选择 ${uris.length} 个文档`;
    } catch (error) {
      this.message = `选择失败: ${error.message}`;
    }
  }

  /**
   * 上传头像
   */
  async uploadAvatar(): Promise<void> {
    this.message = '处理中...';
    
    try {
      const url = await this.avatarUploader.selectAndUpload((progress) => {
        this.message = `上传中: ${progress.toFixed(0)}%`;
      });
      
      if (url) {
        this.avatarUrl = url;
        this.message = '头像上传成功';
      } else {
        this.message = '已取消';
      }
    } catch (error) {
      this.message = `上传失败: ${error.message}`;
    }
  }

  /**
   * 导出数据
   */
  async exportData(): Promise<void> {
    const sampleData = JSON.stringify({
      title: '导出数据示例',
      timestamp: Date.now(),
      items: [
        { id: 1, name: '项目A' },
        { id: 2, name: '项目B' }
      ]
    }, null, 2);
    
    const success = await this.documentSelector.exportData(sampleData, 'export_data', '.json');
    this.message = success ? '数据导出成功' : '导出已取消';
  }

  build() {
    Column() {
      // 标题
      Text(this.message)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 20 })
        .textAlign(TextAlign.Center)

      // 已选照片展示
      if (this.selectedImages.length > 0) {
        Text('已选照片')
          .fontSize(16)
          .margin({ bottom: 10 })

        Grid() {
          ForEach(this.selectedImages, (pixelMap: image.PixelMap, index: number) => {
            GridItem() {
              Image(pixelMap)
                .width('100%')
                .aspectRatio(1)
                .objectFit(ImageFit.Cover)
                .borderRadius(8)
            }
          }, (pixelMap: image.PixelMap, index: number) => index.toString())
        }
        .columnsTemplate('1fr 1fr 1fr')
        .rowsGap(10)
        .columnsGap(10)
        .width('90%')
        .height(200)
        .margin({ bottom: 20 })
      }

      // 已选文档列表
      if (this.selectedFiles.length > 0) {
        Text('已选文档')
          .fontSize(16)
          .margin({ bottom: 10 })

        List() {
          ForEach(this.selectedFiles, (uri: string) => {
            ListItem() {
              Text(uri.substring(uri.lastIndexOf('/') + 1))
                .fontSize(14)
                .padding(10)
                .backgroundColor('#F0F0F0')
                .borderRadius(5)
                .width('100%')
            }
          }, (uri: string) => uri)
        }
        .width('90%')
        .height(120)
        .margin({ bottom: 20 })
      }

      // 操作按钮
      Button('选择照片')
        .width('80%')
        .onClick(() => this.selectPhotos())

      Button('选择文档')
        .width('80%')
        .margin({ top: 15 })
        .onClick(() => this.selectDocuments())

      Button('上传头像')
        .width('80%')
        .margin({ top: 15 })
        .onClick(() => this.uploadAvatar())

      Button('导出数据')
        .width('80%')
        .margin({ top: 15 })
        .onClick(() => this.exportData())
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Start)
    .padding({ top: 50, left: 20, right: 20 })
  }
}

四、踩坑与注意事项

4.1 权限配置问题

PhotoPicker和DocumentPicker的权限要求不同:

// PhotoPicker:需要在module.json5中声明权限
"requestPermissions": [
  { "name": "ohos.permission.READ_MEDIA" }
]

// DocumentPicker:不需要声明权限
// 因为通过Picker选择文件时,系统会临时授予URI权限

// 错误示范:忘记声明权限导致PhotoPicker失败
// 正确做法:检查权限并动态申请
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';

async function checkAndRequestPermission(): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  const grantStatus = await atManager.checkAccessToken(
    await atManager.getAccessTokenId(),
    'ohos.permission.READ_MEDIA'
  );
  
  if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
    // 申请权限
    const result = await atManager.requestPermissionsFromUser(
      getContext(),
      ['ohos.permission.READ_MEDIA']
    );
    return result.authResults[0] === 0;
  }
  
  return true;
}

4.2 URI持久化问题

Picker返回的URI是临时的,应用重启后失效:

// 错误示范:保存URI到数据库,下次启动直接使用
await db.save({ avatarUri: uri });  // 重启后URI失效

// 正确做法:
// 方案1:将文件复制到应用私有目录
const privatePath = getContext().filesDir + '/avatar.jpg';
fs.copyFileSync(uri, privatePath);
await db.save({ avatarPath: privatePath });

// 方案2:上传到服务器,保存服务器URL
const serverUrl = await uploadToServer(uri);
await db.save({ avatarUrl: serverUrl });

4.3 文件类型过滤

文件后缀过滤要注意大小写:

// 问题:用户上传.JPG文件被过滤掉
const fileSuffix = ['.jpg', '.png'];  // 不包含.JPG

// 正确做法:包含大小写或统一转小写判断
const fileSuffix = ['.jpg', '.JPG', '.jpeg', '.JPEG', '.png', '.PNG'];

// 或者后置校验
function isValidImageType(fileName: string): boolean {
  const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
  return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext);
}

4.4 大文件处理

选择大文件(如视频)时,直接读取到内存可能OOM:

// 错误示范:直接读取整个视频文件
const videoUri = await picker.selectVideo();
const buffer = await readFile(videoUri);  // 可能OOM

// 正确做法:分块读取或直接传递URI
// 方案1:分块读取
async function readInChunks(uri: string, chunkSize: number = 1024 * 1024): Promise<void> {
  const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
  const stat = fs.statSync(uri);
  const buffer = new ArrayBuffer(chunkSize);
  
  for (let offset = 0; offset < stat.size; offset += chunkSize) {
    const readLen = fs.readSync(file.fd, buffer, { offset: 0, length: chunkSize });
    // 处理这一块数据
    await processChunk(buffer.slice(0, readLen));
  }
  
  fs.closeSync(file);
}

// 方案2:直接传URI给播放器
videoPlayer.src = videoUri;  // 播放器直接处理URI

4.5 取消操作处理

用户取消选择时,Picker返回空数组或null,要正确处理:

// 问题:用户取消时报错
const uris = await picker.select();
const firstUri = uris[0];  // 用户取消时uris为空,报错

// 正确做法:检查返回值
const uris = await picker.select();
if (!uris || uris.length === 0) {
  console.info('User cancelled');
  return;
}
// 继续处理

五、HarmonyOS 6适配说明

5.1 API接口调整

HarmonyOS 6对Picker API进行了统一和增强:

// HarmonyOS 5写法
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select(options);

// HarmonyOS 6适配
// 新增统一的Picker工厂方法
const photoPicker = picker.createPicker(picker.PickerType.PHOTO);
const result = await photoPicker.launch(options);

// DocumentPicker同理
const docPicker = picker.createPicker(picker.PickerType.DOCUMENT);

5.2 新增选择模式

HarmonyOS 6支持更多选择模式:

// HarmonyOS 6新增:选择模式配置
const options: picker.PhotoSelectOptions = {
  maxSelectNumber: 9,
  MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
  
  // 新增:选择模式
  selectMode: picker.SelectMode.MULTIPLE,  // SINGLE | MULTIPLE
  
  // 新增:是否显示相机入口
  showCamera: true,
  
  // 新增:预选中的URI
  preSelectedUris: ['content://media/.../123']
};

5.3 权限持久化

HarmonyOS 6支持URI权限的持久化:

// HarmonyOS 6新增:持久化URI权限
import fileUri from '@ohos.file.fileuri';

// 选择文件时请求持久权限
const uri = await picker.select();
await fileUri.takePersistableUriPermission(uri, fileUri.UriPermission.READ_WRITE);

// 应用重启后仍可访问
const persistedUris = await fileUri.getPersistedUriPermissions();
for (const persisted of persistedUris) {
  // 可以继续访问
  const content = fs.readTextSync(persisted.uri);
}

// 不再需要时释放权限
await fileUri.releasePersistableUriPermission(uri);

5.4 文件预览增强

HarmonyOS 6的Picker支持文件预览:

// HarmonyOS 6新增:预览配置
const options: picker.DocumentSelectOptions = {
  maxSelectNumber: 1,
  fileSuffixFilters: ['.pdf'],
  
  // 新增:启用预览
  enablePreview: true,
  
  // 新增:预览窗口配置
  previewConfig: {
    width: 800,
    height: 600,
    showToolbar: true
  }
};

六、总结

维度评价
学习难度⭐⭐
使用频率⭐⭐⭐⭐⭐
重要程度⭐⭐⭐⭐⭐
调试难度⭐⭐

文件选择器是用户与应用交互的关键入口。通过本文的学习,你应该掌握了:

核心收获

  1. SAF安全机制:理解为什么必须通过Picker访问用户文件
  2. PhotoPicker用法:照片、视频选择的完整实现
  3. DocumentPicker用法:文档选择和文件保存的正确姿势
  4. URI处理技巧:临时权限、持久化、文件读取等实战经验

最佳实践建议

  • PhotoPicker需要声明READ_MEDIA权限,DocumentPicker无需声明
  • 不要持久化保存临时URI,应复制文件或上传服务器
  • 大文件分块处理,避免内存溢出
  • 正确处理用户取消操作,检查返回值是否为空
  • 文件类型过滤注意大小写问题

文件选择器看似简单,实则暗藏玄机。记住一个原则:用户选择的文件,权限是临时的;想要长期访问,要么复制,要么上传


蓝胖子样样好
79 声望702 粉丝

Never give up,and you will be successful