文章目录
- 前言
- 一、 从权限申请到意图驱动
- 1. 权限模型的变革
- 2. 平台特性对比
- 二、 PhotoViewPicker 全场景图片与视频选择
- 1. 基础用法 拉起系统相册
- 2. 进阶配置 混合选择与拍照
- 3. 核心机制 URI 持久化与沙箱拷贝
- 三、 DocumentViewPicker 文件导出的保存逻辑
- 1. 导出文件到手机存储
- 2. 性能优化 配合网络流直接写入
- 四、 AudioViewPicker 音频资源选择
- 五、 避坑指南与使用边界
- 1. 适用性判断
- 2. 权限不对等
- 3. 状态保持
- 六、 完整实战代码:Picker 工具箱
- 总结
前言
在上一篇文章中,我们详细解析了沙箱机制的严格限制。这自然引出了一个高频问题:“在如此严格的沙箱隔离下,应用如何读取用户相册的照片?或者如何将生成的报表导出到用户可见的下载目录?”
在早期的 Android 开发中,这通常需要申请READ_EXTERNAL_STORAGE和WRITE_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 文件导出的保存逻辑
在文件导出场景中,应用不能直接写入公共目录。DocumentViewPicker的save模式允许用户指定保存位置,从而授予应用对该路径的写入权限。
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 进程被挂起。建议在Ability的onSaveState中缓存关键业务状态,确保 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 模式,意味着你的应用已经遵循了鸿蒙生态的隐私设计规范。