HarmonyOS APP开发中的系统选择器

举报
Jack20 发表于 2026/06/20 16:11:07 2026/06/20
【摘要】 HarmonyOS APP开发中的系统选择器:PhotoPicker、DocumentPicker、AudioPicker、CameraPicker、选择器配置与回调📌 核心要点:通过系统级选择器(Photo/Document/Audio/Camera Picker)安全地访问用户数据,无需申请敏感权限即可实现文件选取与拍照功能 一、背景与动机想象一下这个场景:你的应用需要让用户选一张头...

HarmonyOS APP开发中的系统选择器:PhotoPicker、DocumentPicker、AudioPicker、CameraPicker、选择器配置与回调

📌 核心要点:通过系统级选择器(Photo/Document/Audio/Camera Picker)安全地访问用户数据,无需申请敏感权限即可实现文件选取与拍照功能


一、背景与动机

想象一下这个场景:你的应用需要让用户选一张头像。按照传统思路,你需要申请 ohos.permission.READ_IMAGEVIDEO 权限,然后自己写一个图片浏览界面,处理各种文件格式,还要考虑性能优化……这一套下来,少说也得几百行代码。

更麻烦的是,从 HarmonyOS 开始,系统对权限管控越来越严格。用户看到"读取相册"的权限弹窗,大概率会点"拒绝"。你的应用还没开始用,就已经被用户"判了死刑"。

系统选择器就是为了解决这个问题而生的。它就像一个"中间人"——你的应用不需要直接访问用户的相册或文件,而是让系统帮你打开一个选择界面,用户自己选好文件后,系统把结果安全地传递给你的应用。不需要权限申请,不需要自己写UI,用户体验还好

这就像你去银行取钱——你不需要直接进金库(申请权限),只需要在柜台告诉工作人员你要取多少(调用选择器),工作人员帮你把钱拿出来(回调返回结果)。


二、核心原理

2.1 系统选择器架构

图片.png

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 选择器的异步特性

所有选择器的 selectsave 方法都是异步的,必须使用 async/awaitPromise.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() 拍照、录像

系统选择器的核心理念是"最小权限原则"——你的应用不需要访问用户的整个相册或文件系统,只需要访问用户选择的那几个文件。这不仅保护了用户隐私,也简化了开发流程。能用选择器解决的,就别申请权限了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。