news 2026/6/25 14:21:47

第49篇|从系统相册导入:photoAccessHelper 与沙箱复制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第49篇|从系统相册导入:photoAccessHelper 与沙箱复制

第49篇|从系统相册导入:photoAccessHelper 与沙箱复制

相机项目只会拍照还不够,用户已经存在系统相册里的照片也应该能进入作品流。第 49 篇聚焦“导入”这件事:系统相册负责选择,应用负责复制、建模、刷新和后续复用。

双镜记忆相机的相册记录不是一条图片路径,而是一份带地点、时间、标题、可见性和双图字段的GalleryMoment。所以导入系统照片时,最关键的不是拿到photoUri,而是把外部媒体变成项目自己的稳定记录。

这一篇继续围绕 21 天「智能相机开发实战」训练营展开。阅读时可以先看界面效果,再顺着函数名回到 DevEco Studio 定位实现,最后把成功态、取消态和失败态串成一个可复现闭环。

本篇目标

  • 掌握PhotoViewPicker选择系统照片的入口。
  • 理解为什么外部 Uri 需要复制到应用沙箱。
  • 把导入照片转换为GalleryMoment并刷新相册状态。
  • 处理取消选择、空 Uri、复制失败和批量导入结果。

对应源码位置

  • entry/src/main/ets/pages/Index.ets
  • entry/src/main/ets/services/GalleryRecordService.ets

一、导入不是引用外部 Uri,而是建立项目记录

系统相册返回的是外部媒体 Uri,它适合读取,不适合长期作为项目内部数据源直接保存。用户后续可能移动、删除或权限变化,应用如果只保存外部 Uri,详情页、导出、分享和云同步都会变得不稳定。

项目的做法是先打开系统相册选择器,再把选中的照片复制到沙箱目录,最后用本地路径创建GalleryMoment。这样后续相册列表、详情页、地图、保险箱和分享都读取同一份项目数据。

导入能力在项目相册页中的使用位置和数据流向

二、选择器只限制图片类型和数量

buildPhotoSelectOptions很短,但它划清了入口职责:选择器只关心“让用户选图片”和“最多选几张”。真正的数据建模、复制和刷新不放在这里,避免入口函数变成业务大杂烩。

训练营里建议把系统能力入口写得克制一点。因为它通常是权限、设备能力和用户取消操作最多的地方,业务逻辑越薄,后面排查越容易。

PhotoSelectOptions 限定图片类型和最大选择数

private buildPhotoSelectOptions(maxSelectNumber: number): photoAccessHelper.PhotoSelectOptions { const options = new photoAccessHelper.PhotoSelectOptions(); options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE; options.maxSelectNumber = maxSelectNumber; return options; } private buildImportedPhotoFilePath(createdAt: number, index: number, sourceUri: string): string { const extension = this.getExportFileExtension(sourceUri); return `${this.ensureCaptureDirectory()}/import_${createdAt}_${index}.${extension}`; }

这里的maxSelectNumber传参也给后续扩展留下空间。普通导入可以是 9 张,头像导入可以是 1 张,入口配置复用同一套函数。

三、复制到沙箱后再参与相册闭环

copyPickedPhotoToSandbox用文件读写完成复制,并在finally中关闭源文件和目标文件。相册导入属于 I/O 密集操作,任何一个句柄泄漏都可能在批量导入时放大成稳定性问题。

复制失败时抛出带业务含义的错误,页面层可以直接展示“导入系统照片失败”,用户知道问题发生在导入环节,而不是看到一段无法理解的底层异常。

外部照片 Uri 被复制到应用沙箱文件

private async copyPickedPhotoToSandbox(sourceUri: string, targetPath: string): Promise<void> { let sourceFile: fs.File | undefined = undefined; let targetFile: fs.File | undefined = undefined; try { sourceFile = await fs.open(sourceUri, fs.OpenMode.READ_ONLY); targetFile = fs.openSync( targetPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC ); await fs.copyFile(sourceFile.fd, targetFile.fd); } catch (error) { const err = error as BusinessError; throw new Error(`导入系统照片失败:${err.message ?? err.code ?? 'unknown'}`); } finally { this.closeFileQuietly(sourceFile, 'picked photo source'); this.closeFileQuietly(targetFile, 'picked photo target'); } }

这一步完成后,照片就从“系统相册里的外部资源”变成“当前应用可以稳定管理的项目资源”。

四、导入后补齐地点、标题和可见性

主流程先记录当前位置快照,再循环处理每个selectedUri。每张照片都有独立createdAt、目标文件路径和记录 ID,批量导入不会互相覆盖。

导入结果最终通过persistGalleryRecords写入本地记录,并调用refreshGalleryMediaStateAfterMutation刷新页面。用户看到的是相册更新,工程里完成的是文件、模型、状态和 UI 的闭环。

importSystemAlbumPhotos 把选中照片写成 GalleryMoment

private async importSystemAlbumPhotos(scope: 'gallery' | 'vault'): Promise<void> { if (this.mediaImportBusy) { return; } const vaultHadRecords = this.getVaultRecords().length > 0; const vaultWasUnlocked = this.vaultUnlocked; this.mediaImportBusy = true; this.updateRecordExportStatus(scope, '正在打开系统相册...'); try { const picker = new photoAccessHelper.PhotoViewPicker(); const result = await picker.select(this.buildPhotoSelectOptions(9)); const selectedUris = result.photoUris ?? []; if (selectedUris.length === 0) { this.updateRecordExportStatus(scope, '已取消导入'); return; } const locationSnapshot = await this.buildCaptureLocationSnapshot(); const importedRecords: Array<GalleryMoment> = []; const basePairCount = this.galleryRecords.length; const baseCreatedAt = Date.now(); for (let index = 0; index < selectedUris.length; index++) { const sourceUri = selectedUris[index]; if (!sourceUri || sourceUri.trim().length === 0) { continue; } const createdAt = baseCreatedAt + index; const targetPath = this.buildImportedPhotoFilePath(createdAt, index, sourceUri); await this.copyPickedPhotoToSandbox(sourceUri, targetPath); const record = GalleryRecordService.createRecord({ id: `import_${createdAt}_${index}`, createdAt: createdAt, pairIndex: basePairCount + index + 1, place: locationSnapshot.place, memoryTitle: locationSnapshot.memoryTitle, latitude: locationSnapshot.latitude, longitude: locationSnapshot.longitude, backPath: targetPath, frontPath: targetPath, watermarkStyle: 'none', watermarkText: '' }); const readyRecord = GalleryRecordService.applyLocalInsight(record); readyRecord.visibility = scope === 'vault' ? 'private' : 'public'; importedRecords.push(readyRecord); } if (importedRecords.length === 0) { this.updateRecordExportStatus(scope, '没有可导入的照片'); return; } const importedIds = importedRecords.map((record: GalleryMoment) => record.id); const nextRecords = importedRecords.concat( this.galleryRecords.filter((record: GalleryMoment) => !importedIds.includes(record.id)) ); await this.persistGalleryRecords(nextRecords); await this.refreshGalleryMediaStateAfterMutation(importedRecords[0].id, scope); if (scope === 'vault') { this.cloudSyncStatusText = this.cloudSyncIdentity ? '保险箱照片已保存,正在同步' : '登录华为账号后同步保险箱'; this.vaultSelectedId = importedRecords[0].id; this.vaultUnlocked = vaultWasUnlocked || !vaultHadRecords; this.vaultStatusText = this.vaultUnlocked ? `已导入 ${importedRecords.length} 张私密照片` : `已导入 ${importedRecords.length} 张私密照片,解锁后查看`; } else { this.gallerySelectedId = importedRecords[0].id; this.selectedGalleryGroupKey = this.buildGalleryRecordGroupKey(importedRecords[0]); this.galleryNoticeText = `已导入 ${importedRecords.length} 张系统相册照片`; }

注意readyRecord.visibility的写入位置。第 50 篇会继续沿着这个点讲普通相册和保险箱如何共用同一个导入口。

工程检查清单

  • 外部 Uri 不直接作为长期数据源保存。
  • 复制文件后关闭源文件和目标文件句柄。
  • 批量导入时每张照片生成独立文件名和记录 ID。
  • 取消选择和空结果有明确提示,不进入错误流程。
  • 导入完成后刷新列表、选中项、分组和拍摄计数。

今日练习

  1. 在真机上导入 1 张和 3 张照片,对比记录数量和选中项变化。
  2. 搜索importSystemAlbumPhotos,标出文件复制、记录创建、页面刷新三段代码。
  3. 模拟取消选择,确认页面提示不会留下 busy 状态。

训练营后面的文章会继续按“真实页面效果 -> 源码定位 -> 状态闭环 -> 可验证结果”的节奏推进。每一篇都尽量让你能拿着代码直接回到项目里复现,而不是只停留在概念说明。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/25 14:19:37

Python玩转游戏辅助?聊聊pyautogui实现自动化的原理与边界

Python玩转游戏辅助&#xff1f;深入解析pyautogui的自动化原理与技术边界在数字时代&#xff0c;自动化技术正以前所未有的速度渗透到各个领域。作为Python生态中备受瞩目的自动化工具&#xff0c;pyautogui以其独特的定位和易用性&#xff0c;在开发者社区中引发了广泛讨论。…

作者头像 李华
网站建设 2026/6/14 5:43:00

5分钟掌握暗黑破坏神2存档编辑器:终极可视化编辑指南

5分钟掌握暗黑破坏神2存档编辑器&#xff1a;终极可视化编辑指南 【免费下载链接】d2s-editor 项目地址: https://gitcode.com/gh_mirrors/d2/d2s-editor 还在为暗黑破坏神2复杂的存档编辑而烦恼吗&#xff1f;d2s-editor这款开源Web工具将彻底改变你的游戏体验。这个暗…

作者头像 李华
网站建设 2026/6/13 16:25:32

HTTPS原理全面介绍【备查】

应用层协议&#xff1a;HTTPS 一、 HTTPS定义 Hyper Text Transfer Protocol over Secure Socket Layer&#xff0c;安全的超文本传输协议&#xff0c;网景公式设计了SSL(Secure Sockets Layer)协议用于对Http 协议传输的数据进行加密&#xff0c;保证会话过程中的安全性。 缩…

作者头像 李华
网站建设 2026/6/14 5:43:19

MuleSoft企业级AI编排:LLM集成的工业级封装实践

1. 项目概述&#xff1a;当企业级集成平台遇上大语言模型“AI Orchestration in Action: How MuleSoft and LLMs Fuel the Future of Enterprise AI”——这个标题不是一句空泛的宣传口号&#xff0c;而是我在过去18个月里亲手落地的三个核心生产系统的真实写照。它讲的不是“用…

作者头像 李华
网站建设 2026/6/14 5:43:18

工程师如何构建技术情报体系:从FPGA选型到供应链管理的实战指南

1. 从“绝密”到“公开”&#xff1a;工程师如何构建自己的技术情报体系在技术领域&#xff0c;我们常常会遇到一些被冠以“内部消息”、“行业秘闻”或“绝密资料”的信息。这些信息往往真假难辨&#xff0c;却总能激起从业者巨大的好奇心。作为一名在电子硬件和嵌入式系统领域…

作者头像 李华
网站建设 2026/6/14 5:43:20

FPGA自定义硬件加速器集成:从Avalon接口到SOPC系统实战

1. 项目概述&#xff1a;从零到一&#xff0c;将自定义硬件模块嵌入SOPC系统在上一篇文章里&#xff0c;我们详细拆解了如何为一个自定义的硬件加速器&#xff08;比如我们例子里的Checksum校验和计算器&#xff09;设计符合Avalon总线规范的端口。这就像是给一个功能强大的“黑…

作者头像 李华