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,应复制文件或上传服务器
- 大文件分块处理,避免内存溢出
- 正确处理用户取消操作,检查返回值是否为空
- 文件类型过滤注意大小写问题
文件选择器看似简单,实则暗藏玄机。记住一个原则:用户选择的文件,权限是临时的;想要长期访问,要么复制,要么上传。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。