HarmonyOS开发中文件选择器:FilePicker集成
一、小知识
你肯定用过这种功能——点击"上传头像"按钮,弹出个文件选择框,选张照片上传;或者点击"导出数据",选择保存位置,文件就存到那儿了。这些看似简单的交互,背后都离不开文件选择器。
在HarmonyOS里,文件选择器不是简单的UI组件,而是系统级的安全服务。为什么这么设计?因为文件访问涉及用户隐私,应用不能随意读取用户文件。通过FilePicker,用户主动选择文件,系统再把选中的文件权限临时授予应用——这就是所谓的**SAF(Storage Access Framework)**机制。
HarmonyOS提供了两类选择器:
- PhotoPicker:专门用于选择照片和视频,集成相册管理
- DocumentPicker:通用文件选择器,支持文档、下载等
这两者用法相似,但权限模型和返回数据有差异。用错了可能导致权限问题或者功能异常——咱们这篇就把这些细节掰开揉碎讲清楚。
二、核心原理
2.1 SAF安全机制
传统Android应用可以直接读取外部存储,导致隐私泄露风险。HarmonyOS采用SAF机制,应用访问用户文件必须通过Picker:
2.2 URI权限模型
Picker返回的不是文件路径,而是content URI:
content://media/external/images/media/123这种URI有几个特点:
- 临时权限:只在当前会话有效,应用重启后失效
- 安全隔离:应用无法推断出实际文件路径
- 跨进程访问:通过ContentProvider访问文件内容
2.3 Picker类型对比
| 特性 | PhotoPicker | DocumentPicker |
|---|---|---|
| 适用场景 | 照片、视频选择 | 文档、通用文件 |
| 权限要求 | READ_MEDIA | 无需声明权限 |
| 返回数据 | URI数组 | URI数组 |
| 支持多选 | 是 | 是 |
| 支持保存 | 否 | 是(Save模式) |
三、代码实战
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; // 播放器直接处理URI4.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 } };六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐⭐ |
| 调试难度 | ⭐⭐ |
文件选择器是用户与应用交互的关键入口。通过本文的学习,你应该掌握了:
核心收获:
- SAF安全机制:理解为什么必须通过Picker访问用户文件
- PhotoPicker用法:照片、视频选择的完整实现
- DocumentPicker用法:文档选择和文件保存的正确姿势
- URI处理技巧:临时权限、持久化、文件读取等实战经验
最佳实践建议:
- PhotoPicker需要声明READ_MEDIA权限,DocumentPicker无需声明
- 不要持久化保存临时URI,应复制文件或上传服务器
- 大文件分块处理,避免内存溢出
- 正确处理用户取消操作,检查返回值是否为空
- 文件类型过滤注意大小写问题
文件选择器看似简单,实则暗藏玄机。记住一个原则:用户选择的文件,权限是临时的;想要长期访问,要么复制,要么上传。