HarmonyOS APP开发中的系统选择器
HarmonyOS APP开发中的系统选择器:PhotoPicker、DocumentPicker、AudioPicker、CameraPicker、选择器配置与回调
📌 核心要点:通过系统级选择器(Photo/Document/Audio/Camera Picker)安全地访问用户数据,无需申请敏感权限即可实现文件选取与拍照功能
一、背景与动机
想象一下这个场景:你的应用需要让用户选一张头像。按照传统思路,你需要申请 ohos.permission.READ_IMAGEVIDEO 权限,然后自己写一个图片浏览界面,处理各种文件格式,还要考虑性能优化……这一套下来,少说也得几百行代码。
更麻烦的是,从 HarmonyOS 开始,系统对权限管控越来越严格。用户看到"读取相册"的权限弹窗,大概率会点"拒绝"。你的应用还没开始用,就已经被用户"判了死刑"。
系统选择器就是为了解决这个问题而生的。它就像一个"中间人"——你的应用不需要直接访问用户的相册或文件,而是让系统帮你打开一个选择界面,用户自己选好文件后,系统把结果安全地传递给你的应用。不需要权限申请,不需要自己写UI,用户体验还好。
这就像你去银行取钱——你不需要直接进金库(申请权限),只需要在柜台告诉工作人员你要取多少(调用选择器),工作人员帮你把钱拿出来(回调返回结果)。
二、核心原理
2.1 系统选择器架构

flowchart TD
A[应用层] --> B{选择器类型}
B --> C[PhotoPicker<br/>图片/视频选择]
B --> D[DocumentPicker<br/>文档选择]
B --> E[AudioPicker<br/>音频选择]
B --> F[CameraPicker<br/>拍照/录像]
C --> G[PhotoViewPicker API]
D --> H[DocumentViewPicker API]
E --> I[AudioViewPicker API]
F --> J[CameraViewPicker API]
G --> K[select / save]
H --> K
I --> K
J --> L[capture]
K --> M[返回 URI 列表]
L --> M
M --> N[通过 URI 访问文件]
N --> O[fs.open / fs.read]
style A fill:#4CAF50,stroke:#388E3C,color:#fff
style B fill:#2196F3,stroke:#1976D2,color:#fff
style C fill:#FF9800,stroke:#F57C00,color:#fff
style D fill:#FF9800,stroke:#F57C00,color:#fff
style E fill:#FF9800,stroke:#F57C00,color:#fff
style F fill:#FF9800,stroke:#F57C00,color:#fff
style G fill:#9C27B0,stroke:#7B1FA2,color:#fff
style H fill:#9C27B0,stroke:#7B1FA2,color:#fff
style I fill:#9C27B0,stroke:#7B1FA2,color:#fff
style J fill:#9C27B0,stroke:#7B1FA2,color:#fff
style K fill:#F44336,stroke:#D32F2F,color:#fff
style L fill:#F44336,stroke:#D32F2F,color:#fff
style M fill:#4CAF50,stroke:#388E3C,color:#fff
style N fill:#2196F3,stroke:#1976D2,color:#fff
style O fill:#2196F3,stroke:#1976D2,color:#fff
2.2 选择器 vs 权限申请
| 对比项 | 传统权限方式 | 系统选择器方式 |
|---|---|---|
| 权限需求 | 需要申请敏感权限 | 无需任何权限 |
| 用户感知 | 弹出权限申请弹窗 | 直接打开系统选择界面 |
| 访问范围 | 可访问整个相册/文件系统 | 只能访问用户选择的文件 |
| 安全性 | 低(过度获取数据) | 高(最小化数据访问) |
| 审核风险 | 可能被应用市场拒绝 | 无风险 |
| UI开发 | 需要自己开发 | 系统提供 |
| 灵活性 | 高 | 中(受系统UI限制) |
2.3 URI生命周期
选择器返回的 URI 是临时授权的,不是永久有效的。URI 的有效期通常到应用进程结束或用户主动撤销为止。如果需要长期访问,应该在获取 URI 后立即读取文件内容并保存到应用沙箱。
三、代码实战
3.1 PhotoPicker:图片与视频选择
PhotoPicker 是最常用的选择器,用于选取图片和视频。支持单选、多选、以及选择后保存。
// PhotoPickerDemo.ets
// PhotoPicker:图片与视频选择
import { picker } from '@kit.CoreFileKit';
import { fileIo as fs } from '@kit.CoreFileKit';
@Entry
@Component
struct PhotoPickerDemo {
// 选中的图片URI列表
@State selectedUris: string[] = [];
// 选中的图片数量
@State selectedCount: number = 0;
// 操作状态
@State statusMessage: string = '等待选择图片...';
// 单选图片
private async selectSinglePhoto(): Promise<void> {
try {
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select({
MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE, // 只选图片
maxSelectNumber: 1, // 最多选1张
});
if (result.photoUris.length > 0) {
this.selectedUris = result.photoUris;
this.selectedCount = result.photoUris.length;
this.statusMessage = `已选择 ${this.selectedCount} 张图片 ✓`;
} else {
this.statusMessage = '用户取消了选择';
}
} catch (error) {
this.statusMessage = `选择失败: ${(error as Error).message}`;
console.error(`PhotoPicker select failed: ${JSON.stringify(error)}`);
}
}
// 多选图片
private async selectMultiplePhotos(): Promise<void> {
try {
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select({
MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
maxSelectNumber: 9, // 最多选9张
});
if (result.photoUris.length > 0) {
this.selectedUris = result.photoUris;
this.selectedCount = result.photoUris.length;
this.statusMessage = `已选择 ${this.selectedCount} 张图片 ✓`;
} else {
this.statusMessage = '用户取消了选择';
}
} catch (error) {
this.statusMessage = `选择失败: ${(error as Error).message}`;
console.error(`PhotoPicker select failed: ${JSON.stringify(error)}`);
}
}
// 选择视频
private async selectVideo(): Promise<void> {
try {
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select({
MIMEType: picker.PhotoViewMIMETypes.VIDEO_TYPE, // 只选视频
maxSelectNumber: 1,
});
if (result.photoUris.length > 0) {
this.selectedUris = result.photoUris;
this.selectedCount = result.photoUris.length;
this.statusMessage = `已选择 ${this.selectedCount} 个视频 ✓`;
} else {
this.statusMessage = '用户取消了选择';
}
} catch (error) {
this.statusMessage = `选择失败: ${(error as Error).message}`;
console.error(`PhotoPicker select failed: ${JSON.stringify(error)}`);
}
}
// 保存图片到相册
private async savePhotoToAlbum(): Promise<void> {
if (this.selectedUris.length === 0) {
this.statusMessage = '请先选择图片';
return;
}
try {
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.save({
newFileNames: ['saved_photo.jpg'],
});
if (result.length > 0) {
this.statusMessage = `图片已保存到相册 ✓`;
}
} catch (error) {
this.statusMessage = `保存失败: ${(error as Error).message}`;
console.error(`PhotoPicker save failed: ${JSON.stringify(error)}`);
}
}
build() {
Column() {
// 标题区域
Column() {
Text('PhotoPicker 实战')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('图片与视频选择器')
.fontSize(14)
.fontColor('#FFFFFFAA')
.margin({ top: 4 })
}
.width('100%')
.padding({ top: 56, left: 20, right: 20, bottom: 20 })
.linearGradient({
direction: GradientDirection.BottomRight,
colors: [['#1A1A2E', 0], ['#16213E', 1]]
})
// 状态信息
Column() {
Text(this.statusMessage)
.fontSize(15)
.fontColor(this.statusMessage.includes('✓') ? '#4CAF50' : '#E0E0E0')
.fontWeight(FontWeight.Medium)
Text(`已选择: ${this.selectedCount} 个文件`)
.fontSize(13)
.fontColor('#9E9E9E')
.margin({ top: 4 })
}
.width('100%')
.padding(14)
.margin({ left: 16, right: 16, top: 12 })
.borderRadius(10)
.backgroundColor('#1E1E2E')
.alignItems(HorizontalAlign.Start)
// 图片预览
if (this.selectedUris.length > 0) {
Scroll() {
Row({ space: 8 }) {
ForEach(this.selectedUris, (uri: string) => {
Image(uri)
.width(80)
.height(80)
.borderRadius(8)
.objectFit(ImageFit.Cover)
.border({ width: 1, color: '#FFFFFF22' })
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 12 })
}
// 选择按钮
Text('选择模式')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#E0E0E0')
.margin({ left: 20, top: 20, bottom: 8 })
Column({ space: 8 }) {
// 单选图片
Row() {
Text('🖼️')
.fontSize(18)
Text('选择单张图片')
.fontSize(14)
.fontColor('#E0E0E0')
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.selectSinglePhoto())
// 多选图片
Row() {
Text('🖼️🖼️')
.fontSize(18)
Text('选择多张图片(最多9张)')
.fontSize(14)
.fontColor('#E0E0E0')
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.selectMultiplePhotos())
// 选择视频
Row() {
Text('🎬')
.fontSize(18)
Text('选择视频')
.fontSize(14)
.fontColor('#E0E0E0')
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.selectVideo())
// 保存到相册
Row() {
Text('💾')
.fontSize(18)
Text('保存图片到相册')
.fontSize(14)
.fontColor('#E0E0E0')
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.savePhotoToAlbum())
}
.padding({ left: 16, right: 16 })
// MIME类型说明
Column() {
Text('📋 MIMEType 说明')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#2196F3')
.margin({ bottom: 8 })
Text('• IMAGE_TYPE: 只选图片 (image/*)')
.fontSize(12)
.fontColor('#BDBDBD')
Text('• VIDEO_TYPE: 只选视频 (video/*)')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
Text('• IMAGE_VIDEO_TYPE: 图片和视频都可选')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
Text('• maxSelectNumber: 最大选择数量')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
}
.width('100%')
.padding(14)
.margin({ left: 16, right: 16, top: 16 })
.borderRadius(10)
.backgroundColor('#1E1E2E')
.border({ width: 1, color: '#2196F333' })
}
.width('100%')
.height('100%')
.backgroundColor('#121212')
}
}
3.2 DocumentPicker 与 AudioPicker:文档与音频选择
DocumentPicker 用于选取文档文件(PDF、Word、Excel等),AudioPicker 用于选取音频文件。
// DocumentAudioPicker.ets
// DocumentPicker 与 AudioPicker 实战
import { picker } from '@kit.CoreFileKit';
import { fileIo as fs } from '@kit.CoreFileKit';
@Entry
@Component
struct DocumentAudioPickerDemo {
// 选中的文档URI
@State documentUri: string = '';
// 选中的文档名称
@State documentName: string = '';
// 选中的音频URI
@State audioUri: string = '';
// 选中的音频名称
@State audioName: string = '';
// 操作日志
@State logMessage: string = '等待操作...';
// 选择文档
private async selectDocument(): Promise<void> {
try {
const documentPicker = new picker.DocumentViewPicker();
const result = await documentPicker.select({
maxSelectNumber: 1,
fileSuffixFilters: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt'],
});
if (result.length > 0) {
this.documentUri = result[0];
// 从URI中提取文件名
const uriParts = result[0].split('/');
this.documentName = uriParts[uriParts.length - 1] || '未知文件';
this.logMessage = `文档选择成功 ✓`;
} else {
this.logMessage = '用户取消了文档选择';
}
} catch (error) {
this.logMessage = `文档选择失败: ${(error as Error).message}`;
console.error(`DocumentPicker select failed: ${JSON.stringify(error)}`);
}
}
// 保存文档
private async saveDocument(): Promise<void> {
try {
const documentPicker = new picker.DocumentViewPicker();
const result = await documentPicker.save({
newFileNames: ['export_data.txt'],
});
if (result.length > 0) {
this.logMessage = `文档保存成功 ✓ URI: ${result[0]}`;
} else {
this.logMessage = '用户取消了保存';
}
} catch (error) {
this.logMessage = `文档保存失败: ${(error as Error).message}`;
console.error(`DocumentPicker save failed: ${JSON.stringify(error)}`);
}
}
// 选择音频
private async selectAudio(): Promise<void> {
try {
const audioPicker = new picker.AudioViewPicker();
const result = await audioPicker.select({
MIMEType: picker.AudioViewMIMETypes.AUDIO_TYPE,
maxSelectNumber: 1,
});
if (result.length > 0) {
this.audioUri = result[0];
const uriParts = result[0].split('/');
this.audioName = uriParts[uriParts.length - 1] || '未知音频';
this.logMessage = `音频选择成功 ✓`;
} else {
this.logMessage = '用户取消了音频选择';
}
} catch (error) {
this.logMessage = `音频选择失败: ${(error as Error).message}`;
console.error(`AudioPicker select failed: ${JSON.stringify(error)}`);
}
}
// 读取文档内容(示例)
private async readDocumentContent(): Promise<void> {
if (!this.documentUri) {
this.logMessage = '请先选择文档';
return;
}
try {
const file = fs.openSync(this.documentUri, fs.OpenMode.READ_ONLY);
const stat = fs.statSync(file.fd);
const buffer = new ArrayBuffer(stat.size);
fs.readSync(file.fd, buffer);
fs.closeSync(file);
// 将ArrayBuffer转为字符串
const textDecoder = util.TextDecoder.create('utf-8');
const content = textDecoder.decodeToString(new Uint8Array(buffer));
this.logMessage = `文档内容(前100字): ${content.substring(0, 100)}`;
} catch (error) {
this.logMessage = `读取失败: ${(error as Error).message}`;
}
}
build() {
Column() {
// 标题区域
Column() {
Text('文档与音频选择器')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('DocumentPicker & AudioPicker')
.fontSize(14)
.fontColor('#FFFFFFAA')
.margin({ top: 4 })
}
.width('100%')
.padding({ top: 56, left: 20, right: 20, bottom: 20 })
.linearGradient({
direction: GradientDirection.BottomRight,
colors: [['#1A1A2E', 0], ['#16213E', 1]]
})
// 操作日志
Column() {
Text(this.logMessage)
.fontSize(14)
.fontColor(this.logMessage.includes('✓') ? '#4CAF50' : '#E0E0E0')
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding(14)
.margin({ left: 16, right: 16, top: 12 })
.borderRadius(10)
.backgroundColor('#1E1E2E')
.alignItems(HorizontalAlign.Start)
// 文档选择区域
Text('📄 文档选择')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#E0E0E0')
.margin({ left: 20, top: 20, bottom: 8 })
Column({ space: 8 }) {
// 选择文档
Row() {
Text('📄')
.fontSize(18)
Column() {
Text('选择文档')
.fontSize(14)
.fontColor('#E0E0E0')
Text(this.documentName || '未选择')
.fontSize(12)
.fontColor('#9E9E9E')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.selectDocument())
// 保存文档
Row() {
Text('💾')
.fontSize(18)
Text('保存文档')
.fontSize(14)
.fontColor('#E0E0E0')
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.saveDocument())
// 读取文档内容
Row() {
Text('📖')
.fontSize(18)
Text('读取文档内容')
.fontSize(14)
.fontColor('#E0E0E0')
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.readDocumentContent())
}
.padding({ left: 16, right: 16 })
// 音频选择区域
Text('🎵 音频选择')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#E0E0E0')
.margin({ left: 20, top: 20, bottom: 8 })
Column({ space: 8 }) {
Row() {
Text('🎵')
.fontSize(18)
Column() {
Text('选择音频文件')
.fontSize(14)
.fontColor('#E0E0E0')
Text(this.audioName || '未选择')
.fontSize(12)
.fontColor('#9E9E9E')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Blank()
Text('→')
.fontSize(14)
.fontColor('#9E9E9E')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor('#1E1E2E')
.onClick(() => this.selectAudio())
}
.padding({ left: 16, right: 16 })
// 选择器对比
Column() {
Text('📊 选择器对比')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9800')
.margin({ bottom: 8 })
Text('• PhotoPicker: 图片/视频,支持多选和保存')
.fontSize(12)
.fontColor('#BDBDBD')
Text('• DocumentPicker: 文档,支持文件后缀过滤')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
Text('• AudioPicker: 音频,支持MIME类型过滤')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
Text('• 所有选择器都无需申请权限')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
}
.width('100%')
.padding(14)
.margin({ left: 16, right: 16, top: 16 })
.borderRadius(10)
.backgroundColor('#1E1E2E')
.border({ width: 1, color: '#FF980033' })
}
.width('100%')
.height('100%')
.backgroundColor('#121212')
}
}
注意:上面代码中
readDocumentContent方法使用了util.TextDecoder,需要额外引入import { util } from '@kit.ArkTS'。
3.3 CameraPicker:拍照与录像
CameraPicker 用于调用系统相机进行拍照或录像,同样无需申请相机权限。
// CameraPickerDemo.ets
// CameraPicker:拍照与录像
import { picker } from '@kit.CoreFileKit';
@Entry
@Component
struct CameraPickerDemo {
// 拍照结果URI
@State photoUri: string = '';
// 录像结果URI
@State videoUri: string = '';
// 操作状态
@State statusMessage: string = '等待操作...';
// 拍照次数
@State photoCount: number = 0;
// 拍照
private async takePhoto(): Promise<void> {
try {
const cameraPicker = new picker.CameraViewPicker();
const result = await cameraPicker.capture();
if (result.length > 0) {
this.photoUri = result[0];
this.photoCount++;
this.statusMessage = `拍照成功 ✓ (第${this.photoCount}张)`;
} else {
this.statusMessage = '用户取消了拍照';
}
} catch (error) {
this.statusMessage = `拍照失败: ${(error as Error).message}`;
console.error(`CameraPicker capture failed: ${JSON.stringify(error)}`);
}
}
// 录像
private async recordVideo(): Promise<void> {
try {
const cameraPicker = new picker.CameraViewPicker();
const result = await cameraPicker.capture({
// 录像模式配置(如果API支持)
});
if (result.length > 0) {
this.videoUri = result[0];
this.statusMessage = `录像成功 ✓`;
} else {
this.statusMessage = '用户取消了录像';
}
} catch (error) {
this.statusMessage = `录像失败: ${(error as Error).message}`;
console.error(`CameraPicker record failed: ${JSON.stringify(error)}`);
}
}
build() {
Column() {
// 标题区域
Column() {
Text('CameraPicker 实战')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('拍照与录像,无需相机权限')
.fontSize(14)
.fontColor('#FFFFFFAA')
.margin({ top: 4 })
}
.width('100%')
.padding({ top: 56, left: 20, right: 20, bottom: 20 })
.linearGradient({
direction: GradientDirection.BottomRight,
colors: [['#1A1A2E', 0], ['#16213E', 1]]
})
// 状态信息
Column() {
Text(this.statusMessage)
.fontSize(15)
.fontColor(this.statusMessage.includes('✓') ? '#4CAF50' : '#E0E0E0')
.fontWeight(FontWeight.Medium)
Text(`累计拍照: ${this.photoCount} 张`)
.fontSize(13)
.fontColor('#9E9E9E')
.margin({ top: 4 })
}
.width('100%')
.padding(14)
.margin({ left: 16, right: 16, top: 12 })
.borderRadius(10)
.backgroundColor('#1E1E2E')
.alignItems(HorizontalAlign.Start)
// 拍照预览
if (this.photoUri) {
Column() {
Text('最近拍摄')
.fontSize(14)
.fontColor('#9E9E9E')
.margin({ bottom: 8 })
Image(this.photoUri)
.width(200)
.height(200)
.borderRadius(12)
.objectFit(ImageFit.Cover)
.border({ width: 2, color: '#4CAF50' })
}
.width('100%')
.padding(16)
.margin({ left: 16, right: 16, top: 12 })
.borderRadius(10)
.backgroundColor('#1E1E2E')
.alignItems(HorizontalAlign.Center)
}
// 操作按钮
Text('相机操作')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#E0E0E0')
.margin({ left: 20, top: 20, bottom: 8 })
Column({ space: 12 }) {
// 拍照按钮
Row() {
Text('📸')
.fontSize(24)
Text('拍照')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.margin({ left: 12 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 16, bottom: 16 })
.borderRadius(12)
.linearGradient({
direction: GradientDirection.Right,
colors: [['#4CAF50', 0], ['#2196F3', 1]]
})
.shadow({ radius: 12, color: '#4CAF5033', offsetY: 4 })
.onClick(() => this.takePhoto())
// 录像按钮
Row() {
Text('🎥')
.fontSize(24)
Text('录像')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.margin({ left: 12 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 16, bottom: 16 })
.borderRadius(12)
.backgroundColor('#F44336')
.shadow({ radius: 12, color: '#F4433633', offsetY: 4 })
.onClick(() => this.recordVideo())
}
.padding({ left: 16, right: 16 })
// CameraPicker 优势
Column() {
Text('✨ CameraPicker 优势')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#9C27B0')
.margin({ bottom: 8 })
Text('• 无需申请 ohos.permission.CAMERA 权限')
.fontSize(12)
.fontColor('#BDBDBD')
Text('• 系统级相机UI,体验一致')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
Text('• 返回的URI可直接用于Image组件显示')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
Text('• 适合头像上传、证件拍照等场景')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
Text('• 如需自定义相机UI,仍需申请权限')
.fontSize(12)
.fontColor('#BDBDBD')
.margin({ top: 4 })
}
.width('100%')
.padding(14)
.margin({ left: 16, right: 16, top: 16 })
.borderRadius(10)
.backgroundColor('#1E1E2E')
.border({ width: 1, color: '#9C27B033' })
}
.width('100%')
.height('100%')
.backgroundColor('#121212')
}
}
四、踩坑与注意事项
4.1 URI的临时性
选择器返回的URI是临时授权的,不是永久有效的。如果你在应用重启后还想访问之前选中的文件,URI很可能已经失效了。
// ❌ 错误:保存URI供后续使用
this.savedUri = result.photoUris[0];
// 应用重启后,savedUri可能已失效
// ✅ 正确:获取URI后立即读取文件内容并保存到沙箱
const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const destPath = getContext(this).filesDir + '/copied_image.jpg';
const destFile = fs.openSync(destPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
fs.copyFileSync(srcFile.fd, destFile.fd);
fs.closeSync(srcFile);
fs.closeSync(destFile);
// 之后使用 destPath 访问文件,永久有效
4.2 选择器的异步特性
所有选择器的 select 和 save 方法都是异步的,必须使用 async/await 或 Promise.then 处理。如果在选择器未关闭时再次调用,会抛出异常。
4.3 PhotoPicker的maxSelectNumber
maxSelectNumber 设置的是最大选择数量,不是必须选择的数量。用户可以选择0到maxSelectNumber之间的任意数量。如果用户没有选择任何文件就关闭了选择器,返回的数组为空。
4.4 DocumentPicker的文件后缀过滤
fileSuffixFilters 参数可以限制用户只能选择特定后缀的文件。但这个过滤是在系统UI层面进行的,用户可能通过其他方式绕过。不要依赖这个过滤做安全校验,在获取文件后还需要再次验证文件类型。
4.5 CameraPicker在模拟器上的限制
CameraPicker 需要真实的摄像头硬件,在模拟器上无法正常工作。开发测试时需要使用真机。
4.6 选择器回调的错误处理
用户可能随时取消选择器的操作,这时不会抛出错误,而是返回空数组。务必在代码中处理这种情况:
const result = await photoPicker.select({ ... });
if (result.photoUris.length === 0) {
// 用户取消了选择,不是错误,但需要处理
this.statusMessage = '用户取消了选择';
return;
}
// 正常处理选中的文件...
五、HarmonyOS 6适配
5.1 API变更
| 变更项 | HarmonyOS 5 | HarmonyOS 6 |
|---|---|---|
| PhotoPicker | PhotoViewPicker |
新增 PhotoBrowserPicker 浏览模式 |
| DocumentPicker | DocumentViewPicker |
新增文件夹选择模式 |
| AudioPicker | AudioViewPicker |
新增音频预览功能 |
| CameraPicker | CameraViewPicker |
新增滤镜和美颜配置 |
| URI持久化 | 无 | 新增 persistUri 持久化授权 |
5.2 迁移指南
// HarmonyOS 5 写法:URI临时有效
const result = await photoPicker.select({ ... });
// 必须立即复制文件到沙箱
// HarmonyOS 6 写法:URI可持久化
const result = await photoPicker.select({ ... });
if (result.photoUris.length > 0) {
// 请求持久化授权
await photoPicker.persistUri(result.photoUris[0]);
// 之后可以长期使用此URI
}
5.3 安全增强
HarmonyOS 6 对选择器的安全性做了进一步强化:
- 选择器返回的URI默认只能在当前会话中使用
- 需要显式调用
persistUri才能获得长期访问权限 - 用户可以在系统设置中随时撤销持久化授权
六、总结
mindmap
root((系统选择器))
PhotoPicker
图片/视频选择
MIMEType过滤
maxSelectNumber多选
save保存到相册
无需READ_IMAGEVIDEO权限
DocumentPicker
文档选择
fileSuffixFilters过滤
save保存文档
无需READ_WRITE权限
AudioPicker
音频选择
AUDIO_TYPE MIME
无需音频权限
CameraPicker
拍照/录像
capture方法
无需CAMERA权限
模拟器不可用
通用要点
URI临时授权
异步调用
空数组=用户取消
立即复制到沙箱
不依赖后缀过滤做安全校验
HarmonyOS 6
persistUri持久化
PhotoBrowserPicker
文件夹选择
滤镜美颜配置
安全增强
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
classDef purple fill:#9C27B0,stroke:#7B1FA2,color:#fff
| 选择器 | 核心API | 适用场景 | 权限需求 |
|---|---|---|---|
| PhotoPicker | PhotoViewPicker.select() |
选头像、选图片、选视频 | 无 |
| DocumentPicker | DocumentViewPicker.select() |
选PDF、选Word、选Excel | 无 |
| AudioPicker | AudioViewPicker.select() |
选音乐、选录音 | 无 |
| CameraPicker | CameraViewPicker.capture() |
拍照、录像 | 无 |
系统选择器的核心理念是"最小权限原则"——你的应用不需要访问用户的整个相册或文件系统,只需要访问用户选择的那几个文件。这不仅保护了用户隐私,也简化了开发流程。能用选择器解决的,就别申请权限了。
- 点赞
- 收藏
- 关注作者
评论(0)