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