前言

在上一篇文章中,我们详细解析了沙箱机制的严格限制。这自然引出了一个高频问题:“在如此严格的沙箱隔离下,应用如何读取用户相册的照片?或者如何将生成的报表导出到用户可见的下载目录?”

在早期的 Android 开发中,这通常需要申请 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限。这种宽泛的授权方式存在严重的隐私隐患:用户只是想上传一张头像,应用却获得了扫描整个文件系统的能力。

鸿蒙 HarmonyOS 6(API 20)引入了意图驱动的访问理念。系统判定,只要是用户通过系统界面(Picker)主动选择的文件,即视为用户对该特定文件进行了临时授权。这种方式无需在 module.json5 中申请任何权限,既简化了开发流程,又保护了用户隐私。

我们这次将深入解析 Picker 的底层逻辑、URI 生命周期管理以及流式写入优化。

一、 从权限申请到意图驱动

1. 权限模型的变革

传统的权限模型申请的是能力(Capability),例如“读取相册的能力”,这往往导致权限过载(Over-Privileged)。而 Picker 模式申请的是数据项(Item)。应用发起请求,系统弹窗供用户选择,应用最终仅获得用户选中的那一个文件的临时读写凭证。

2. 平台特性对比
  • Android (SAF): 存储访问框架,机制类似,但 API 碎片化较严重。
  • iOS (PHPicker): 运行于独立进程,应用与相册完全隔离,安全性极高。
  • HarmonyOS (Picker): 结合了二者优势,通过 CoreFileKit 提供统一的 Promise 风格 API,原生 ArkUI 体验。

使用 Picker 模式,你的 module.json5 将变得非常干净,无需声明敏感权限。

// module.json5
{
  "module": {
    // 以前可能需要申请如下权限,现在使用 Picker 则完全不需要:
    // "requestPermissions": [
    //   { "name": "ohos.permission.READ_IMAGEVIDEO" }
    // ]
  }
}

二、 PhotoViewPicker 全场景图片与视频选择

PhotoViewPicker 是处理媒体文件的核心组件,支持图片和视频的单选与多选。

1. 基础用法 拉起系统相册

实例化 Picker 并配置 MIME 类型,即可拉起系统选择器。应用界面会被系统遮罩覆盖,保证交互安全。

import { picker } from '@kit.CoreFileKit';

async function pickOneImage() {
  const photoPicker = new picker.PhotoViewPicker();
  // 拉起选择器
  const result = await photoPicker.select({
    MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE, // 只看图片
    maxSelectNumber: 1 // 单选
  });
  
  if (result.photoUris.length > 0) {
    // 返回的 URI 格式:file://media/Photo/1/IMG_xxx.jpg
    return result.photoUris[0];
  }
  return '';
}
2. 进阶配置 混合选择与拍照

通过 PhotoSelectOptions 可以控制更精细的行为,例如同时选择图片和视频,或者允许用户直接在 Picker 中拍照。

const options: picker.PhotoSelectOptions = {
  // 同时显示图片和视频
  MIMEType: picker.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE,
  // 允许在选择器中直接开启相机拍摄
  isPhotoTakingSupported: true,
  maxSelectNumber: 9
};
3. 核心机制 URI 持久化与沙箱拷贝

Picker 返回的 URI 是临时的,且只具备读取权限。如果应用需要长期持有该图片(例如设置为用户头像),必须将其拷贝到应用的私有沙箱(filesDir)中。

import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';

async function saveToSandbox(context: common.UIAbilityContext, srcUri: string) {
  // 1. 定义沙箱目标路径
  const destPath = context.filesDir + '/avatar_copy.jpg';
  
  // 2. 以只读模式打开源 URI
  const srcFile = await fs.open(srcUri, fs.OpenMode.READ_ONLY);
  // 3. 以读写创建模式打开目标文件
  const destFile = await fs.open(destPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
  
  // 4. 高效拷贝
  await fs.copyFile(srcFile.fd, destFile.fd);
  
  // 5. 释放资源
  fs.closeSync(srcFile);
  fs.closeSync(destFile);
  
  return destPath; // 后续业务使用这个沙箱路径
}

三、 DocumentViewPicker 文件导出的保存逻辑

在文件导出场景中,应用不能直接写入公共目录。DocumentViewPickersave 模式允许用户指定保存位置,从而授予应用对该路径的写入权限。

1. 导出文件到手机存储

以下代码演示如何将内存中的文本数据保存为用户指定位置的 PDF 文件。

import { picker } from '@kit.CoreFileKit';
import { fileIo as fs } from '@kit.CoreFileKit';

async function exportDocument(content: string) {
  const docPicker = new picker.DocumentViewPicker();
  // 配置保存选项
  const saveOptions = new picker.DocumentSaveOptions();
  saveOptions.newFileNames = ['Report_2024.txt']; // 预设文件名
  
  // 拉起“另存为”视图
  const uris = await docPicker.save(saveOptions);
  
  if (uris.length > 0) {
    // 获取的 URI 具备写入权限
    const targetUri = uris[0];
    const file = await fs.open(targetUri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    
    // 写入内容
    await fs.write(file.fd, content);
    fs.closeSync(file);
    console.info('文件导出成功');
  }
}
2. 性能优化 配合网络流直接写入

对于大文件下载(如安装包),应避免将数据完全加载到内存。可以获取 Picker 返回的 FileDescriptor (FD),配合网络库进行流式写入。

// 伪代码示例:结合 NetworkKit 与 FileDescriptor
// const targetFile = await fs.open(targetUri, ...);
// httpRequest.requestInStream(url, options, (err, data) => {
//    fs.write(targetFile.fd, data); // 将网络流直接管导入文件系统
// });

四、 AudioViewPicker 音频资源选择

音频选择器的使用逻辑与图片选择器完全一致,适用于上传录音或导入音乐素材的场景。

import { picker } from '@kit.CoreFileKit';

async function pickAudioFile() {
  const audioPicker = new picker.AudioViewPicker();
  const result = await audioPicker.select({
    maxSelectNumber: 1
  });
  
  if (result.audioUris.length > 0) {
    console.info(`选中音频 URI: ${result.audioUris[0]}`);
  }
}

五、 避坑指南与使用边界

Picker 并非万能,开发者需明确其适用边界。

1. 适用性判断
  • 适用:头像上传、发送图片消息、导出报表、导入文档。
  • 不适用:文件管理器、自定义相册、云备份工具。这些场景需要管理全量文件,必须申请 ohos.permission.READ_IMAGEVIDEO 并使用 PhotoAccessHelper
2. 权限不对等

Picker 仅提供读取(Select)或创建(Save)权限,不提供删除权限。若需删除用户手机中的原文件,必须走 PhotoAccessHelper 的流程并触发系统二次确认弹窗。

3. 状态保持

Picker 是跨进程 UI,在低内存设备上可能导致调用方 App 进程被挂起。建议在 AbilityonSaveState 中缓存关键业务状态,确保 Picker 返回后能恢复上下文。

// 在 EntryAbility 中
onSaveState(reason: AbilityConstant.StateType, want: Want) {
  // 保存当前业务状态,防止 Picker 占用内存过大导致主进程被回收
  want.parameters = { "current_step": "picking_avatar" };
  return 0;
}

六、 完整实战代码:Picker 工具箱

以下代码整合了图片选择、文件保存和沙箱拷贝功能。

import { picker } from '@kit.CoreFileKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct PickerExamplePage {
  @State imgUri: string = '';
  @State logMsg: string = '准备就绪';
  private context = getContext(this) as common.UIAbilityContext;

  // 1. 选择图片并显示
  async selectImage() {
    try {
      const photoPicker = new picker.PhotoViewPicker();
      const result = await photoPicker.select({
        MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
        maxSelectNumber: 1
      });

      if (result.photoUris.length > 0) {
        this.imgUri = result.photoUris[0];
        this.logMsg = `选中图片 URI:\n${this.imgUri}`;
      }
    } catch (err) {
      const error = err as BusinessError;
      this.logMsg = `选择取消或失败: ${error.message}`;
    }
  }

  // 2. 将选中的图片持久化到沙箱
  async saveToSandbox() {
    if (!this.imgUri) {
      promptAction.showToast({ message: '请先选择图片' });
      return;
    }

    try {
      const srcFile = await fs.open(this.imgUri, fs.OpenMode.READ_ONLY);
      const destPath = `${this.context.filesDir}/saved_image_${Date.now()}.jpg`;
      const destFile = await fs.open(destPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);

      await fs.copyFile(srcFile.fd, destFile.fd);
      fs.closeSync(srcFile);
      fs.closeSync(destFile);

      this.logMsg = `已拷贝至沙箱:\n${destPath}`;
      promptAction.showToast({ message: '持久化成功' });
    } catch (err) {
      const error = err as BusinessError;
      this.logMsg = `拷贝失败: ${error.message}`;
    }
  }

  // 3. 导出文本文件到用户目录
  async exportFile() {
    try {
      const docPicker = new picker.DocumentViewPicker();
      const uris = await docPicker.save({
        newFileNames: ['HarmonyOS_Note.txt']
      });

      if (uris.length > 0) {
        const targetUri = uris[0];
        const file = await fs.open(targetUri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
        const content = "Hello HarmonyOS 6 Picker! \n这是导出的测试内容。";
        
        await fs.write(file.fd, content);
        fs.closeSync(file);

        this.logMsg = `文件已导出至:\n${targetUri}`;
        promptAction.showToast({ message: '导出成功' });
      }
    } catch (err) {
      const error = err as BusinessError;
      this.logMsg = `导出失败: ${error.message}`;
    }
  }

  build() {
    Column() {
      Text('Picker 无感授权实战')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 40, bottom: 20 })

      // 图片预览区
      if (this.imgUri) {
        Image(this.imgUri)
          .width(200)
          .height(200)
          .objectFit(ImageFit.Contain)
          .borderRadius(12)
          .border({ width: 1, color: '#E0E0E0' })
          .margin({ bottom: 20 })
      } else {
        Column() {
          Text('暂无图片').fontColor('#999')
        }
        .width(200)
        .height(200)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#F5F5F5')
        .borderRadius(12)
        .margin({ bottom: 20 })
      }

      // 按钮操作区
      Button('1. 选择系统相册图片')
        .width('80%')
        .onClick(() => this.selectImage())
        .margin({ bottom: 12 })

      Button('2. 拷贝图片到应用沙箱')
        .width('80%')
        .backgroundColor('#F0A732')
        .onClick(() => this.saveToSandbox())
        .margin({ bottom: 12 })

      Button('3. 导出文本文件到手机')
        .width('80%')
        .backgroundColor('#10C16C')
        .onClick(() => this.exportFile())

      // 日志输出区
      Text(this.logMsg)
        .width('90%')
        .padding(10)
        .margin({ top: 30 })
        .backgroundColor('#F1F3F5')
        .borderRadius(8)
        .fontSize(12)
        .fontColor('#666')
    }
    .width('100%')
    .height('100%')
  }
}

总结

Picker 模式是 HarmonyOS 6 隐私安全体系的重要组成部分。通过 PhotoViewPicker 和 DocumentViewPicker,应用可以在不申请敏感权限的前提下,流畅地完成媒体选取和文件导出功能。

  • PhotoViewPicker:解决“读”的问题,配合沙箱拷贝实现持久化。
  • DocumentViewPicker:解决“写”的问题,让用户指定数据出口。

掌握 Picker 模式,意味着你的应用已经遵循了鸿蒙生态的隐私设计规范。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐