
第49篇从系统相册导入photoAccessHelper 与沙箱复制相机项目只会拍照还不够用户已经存在系统相册里的照片也应该能进入作品流。第 49 篇聚焦“导入”这件事系统相册负责选择应用负责复制、建模、刷新和后续复用。双镜记忆相机的相册记录不是一条图片路径而是一份带地点、时间、标题、可见性和双图字段的GalleryMoment。所以导入系统照片时最关键的不是拿到photoUri而是把外部媒体变成项目自己的稳定记录。这一篇继续围绕 21 天「智能相机开发实战」训练营展开。阅读时可以先看界面效果再顺着函数名回到 DevEco Studio 定位实现最后把成功态、取消态和失败态串成一个可复现闭环。本篇目标掌握PhotoViewPicker选择系统照片的入口。理解为什么外部 Uri 需要复制到应用沙箱。把导入照片转换为GalleryMoment并刷新相册状态。处理取消选择、空 Uri、复制失败和批量导入结果。对应源码位置entry/src/main/ets/pages/Index.etsentry/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): Promisevoid { 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 把选中照片写成 GalleryMomentprivate async importSystemAlbumPhotos(scope: gallery | vault): Promisevoid { 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: ArrayGalleryMoment []; 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 张和 3 张照片对比记录数量和选中项变化。搜索importSystemAlbumPhotos标出文件复制、记录创建、页面刷新三段代码。模拟取消选择确认页面提示不会留下 busy 状态。训练营后面的文章会继续按“真实页面效果 - 源码定位 - 状态闭环 - 可验证结果”的节奏推进。每一篇都尽量让你能拿着代码直接回到项目里复现而不是只停留在概念说明。