第51篇|导出到系统相册:把沙箱照片交还给用户
第51篇导出到系统相册把沙箱照片交还给用户导入解决的是“把系统相册照片纳入项目”导出解决的是“把项目沙箱照片交还给系统相册”。第 51 篇看导出链路重点是沙箱文件、保存授权、图片资产配置和用户反馈。很多相机项目导出失败的原因不是保存接口本身而是源文件路径、Uri 转换、重复前后摄图片和用户取消保存没有处理清楚。项目把这些边界拆成几个小函数读起来比较容易定位。这一篇继续围绕 21 天「智能相机开发实战」训练营展开。阅读时可以先看界面效果再顺着函数名回到 DevEco Studio 定位实现最后把成功态、取消态和失败态串成一个可复现闭环。本篇目标整理GalleryMoment中可导出的后摄和前摄图片。把本地沙箱路径转换为系统可保存的 Uri。理解保存授权流程和MediaAssetChangeRequest。覆盖文件缺失、取消保存、保存失败和 busy 状态。对应源码位置entry/src/main/ets/pages/Index.ets一、导出前先整理候选项一条双镜记录可能同时有后摄图和前摄图也可能因为导入系统照片而两者指向同一张图。导出前如果不去重用户会在系统相册里看到重复照片。buildRecordExportItems用sourceKey去重并为每个候选项生成标题。这个函数不做保存动作只负责把“记录里有什么可以导出”说清楚。详情页中的导出能力和系统相册保存流程二、后摄和前摄分别进入 RecordExportItemRecordExportItem里同时保留sourcePath、sourceUri和title。其中sourcePath是沙箱文件路径sourceUri是后续保存授权要用的 Uri标题则给系统相册资产命名。去重逻辑用数组保存已经出现过的来源这样导入照片这种“前后图相同”的记录只导出一次。buildRecordExportItems 去重后生成导出候选项}, { sourcePath: record.frontPath, sourceUri: this.buildExportSourceUri(record.frontPath, record.frontUri), title: superimage_${record.createdAt}_front } ]; const exportItems: ArrayRecordExportItem []; const seenSources: Arraystring []; for (const candidate of candidates) { const sourceKey candidate.sourcePath.length 0 ? candidate.sourcePath : candidate.sourceUri; if (candidate.sourceUri.length 0 || sourceKey.length 0 || seenSources.includes(sourceKey)) { continue; } seenSources.push(sourceKey); exportItems.push(candidate); } return exportItems; }这里的候选项是导出流程的输入契约。后面如果要支持 Live Photo 或短片封面也可以先扩展这个结构。三、创建系统相册资产配置保存到图库时系统需要知道文件名扩展和媒体类型。createPhotoCreationConfig根据源 Uri 提取扩展名并把photoType写成图片。getRecordExportSandboxUri负责确认源文件是否真的在沙箱里只有可访问路径才继续导出。这个检查可以避免把还没恢复完成的云端照片直接交给保存接口。PhotoCreationConfig 和沙箱 Uri 检查private createPhotoCreationConfig(item: RecordExportItem): photoAccessHelper.PhotoCreationConfig { const config: photoAccessHelper.PhotoCreationConfig { title: item.title, fileNameExtension: this.getExportFileExtension(item.sourceUri), photoType: photoAccessHelper.PhotoType.IMAGE }; return config; } private getRecordExportSandboxUri(item: RecordExportItem): string { const sourcePath item.sourcePath.trim(); if (sourcePath.length 0 this.pathExists(sourcePath)) { return this.buildSandboxFileUri(sourcePath); } const sourceUri item.sourceUri.trim(); if (sourceUri.startsWith(file://${this.getAbilityContext().abilityInfo.bundleName})) { return sourceUri; } return ; } private async copyUriFile(sourceUri: string, targetUri: 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 await fs.open(targetUri, fs.OpenMode.READ_WRITE); 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, source); this.closeFileQuietly(targetFile, target); } }导出链路里不要假设每条记录都有本地文件。云同步恢复、导入异常和用户清理缓存都可能让文件暂时不可用。四、保存授权成功后写入系统相册exportRecordToSystemAlbumWithSaveGrant先检查忙碌态和候选项再在保险箱场景下要求本地认证。真正保存时它为每个沙箱 Uri 创建图片资产请求最后更新导出状态。失败态没有被吞掉没有照片、认证失败、源文件不可用、保存失败都会进入不同提示。用户知道下一步该重试、解锁还是等待文件恢复。保存授权流程把沙箱照片写回系统相册private async exportRecordToSystemAlbumWithSaveGrant( record: GalleryMoment, scope: gallery | vault ): Promisevoid { if (this.mediaExportBusy) { return; } const exportItems this.buildRecordExportItems(record); if (exportItems.length 0) { this.updateRecordExportStatus(scope, 没有可保存的照片); return; } if (scope vault) { const authReady await this.requireLocalAuthForSensitiveAction(scope, 保存私密照片); if (!authReady) { return; } } this.mediaExportBusy true; this.updateRecordExportStatus(scope, 正在保存 ${exportItems.length} 张照片到系统相册...); const context this.getAbilityContext(); let photoHelper: photoAccessHelper.PhotoAccessHelper | undefined undefined; try { photoHelper photoAccessHelper.getPhotoAccessHelper(context); let exportedCount 0; for (const item of exportItems) { const sourceUri this.getRecordExportSandboxUri(item); if (sourceUri.length 0) { console.warn(Skip non-sandbox export source: ${item.sourceUri}); continue; } const assetChangeRequest photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, sourceUri); await photoHelper.applyChanges(assetChangeRequest); exportedCount; } if (exportedCount 0) { this.updateRecordExportStatus(scope, 照片文件还在恢复中请稍后再保存); return; } this.updateRecordExportStatus(scope, 已保存 ${exportedCount} 张到系统相册); } catch (error) { const err error as BusinessError; this.updateRecordExportStatus(scope, 保存失败${this.getFriendlyMediaExportError(err)}); console.error(Failed to export record with save grant: ${JSON.stringify(error)});导出能力的质量感来自边界处理。能成功保存只是基础取消和失败时页面不乱才是用户真正能感受到的稳定。工程检查清单导出前去重后摄和前摄来源。只导出当前应用可访问的沙箱文件。保险箱照片保存前要求敏感操作认证。保存过程中维护mediaExportBusy避免重复点击。所有失败态都更新用户可见文案。今日练习选择一条双拍记录导出确认系统相册中是两张还是一张。把导入照片导出一次观察重复路径去重逻辑。阅读getRecordExportSandboxUri写出它返回空字符串的场景。训练营后面的文章会继续按“真实页面效果 - 源码定位 - 状态闭环 - 可验证结果”的节奏推进。每一篇都尽量让你能拿着代码直接回到项目里复现而不是只停留在概念说明。