【寻迹校园 HarmonyOS NEXT 实战 12】临时 URI 不能直接长期保存:把 Photo Picker 图片复制到应用沙箱
【寻迹校园 HarmonyOS NEXT 实战 12】临时 URI 不能直接长期保存:把 Photo Picker 图片复制到应用沙箱
这是“寻迹校园 HarmonyOS NEXT 实战”系列第 12 篇。本文结合
ReportPhotoRepository.ets,拆解 Photo Picker URI 的规范化、去重、扩展名回退、应用沙箱复制、失败回滚与引用感知清理。

上图为本文原创生成的工程插画,不是应用截图。系统选择结果只是输入来源,长期草稿和正式记录最终应引用由应用管理的文件,而不是把临时访问能力当成永久资源。
一、为什么拿到 URI 不等于完成图片保存
Photo Picker 返回的是本次选择结果。页面能够马上展示,并不自动证明这个 URI 在以下场景仍然可用:
- 应用进程被杀死后重新启动;
- 草稿保存数小时后再次打开;
- 原图被移动、删除或系统媒体状态变化;
- 正式记录从列表进入详情;
- 用户编辑记录并替换部分图片;
- 删除草稿或记录时执行资源清理。
如果直接把返回 URI 字符串写进 RelationalStore,数据库可能保存了一条合法文本,但它指向的内容已经不可读。这是典型的“字段写入成功,不代表业务资源持久化成功”。
项目因此增加 ReportPhotoRepository:把图片 URI 从页面输入转换为应用管理 URI,再交给草稿或正式记录保存。
二、materialize 的输入输出契约
核心方法保持很小:materialize(uris: string[], context?: common.UIAbilityContext): string[]。
它接收选图 URI,返回经过规范化后的 URI。存在 UIAbilityContext 时,外部 URI 会复制到 filesDir/report_photos;没有 Context 时,方法只返回规范化列表,支持纯业务路径运行。
这并不表示两种结果拥有相同的持久化能力。无 Context 分支是开发和测试回退,不能证明真实设备文件复制、目录权限、磁盘异常或冷启动恢复。
三、第一步先规范化、去重并限制最多 3 张
Repository 不信任上游一定传入干净数组:
private normalize(uris: string[]): string[] {
const result: string[] = [];
for (let index: number = 0; index < uris.length && result.length < 3; index++) {
const uri: string = uris[index].trim();
if (uri.length > 0 && !result.includes(uri)) result.push(uri);
}
return result;
}
这段逻辑同时完成四件事:
- 去掉首尾空白;
- 丢弃空字符串;
- 保留首次出现顺序并去重;
- 最终最多保留 3 个 URI。
“最多 3 张”在 Photo Picker、表单校验、Repository 和序列化层重复守护,是跨层契约。任何入口绕过页面时,数据层仍不会无限复制文件。
四、应用管理目录为什么放在 filesDir
存在 Context 时,Repository 计算目录:
const directory: string = `${context.filesDir}/report_photos`;
if (!fileIo.accessSync(directory)) {
fileIo.mkdirSync(directory, true);
}
目录位于应用文件空间,生命周期由应用掌控。相比继续引用外部临时内容,它更适合草稿恢复和正式记录展示。
report_photos 作为固定子目录还有两个维护收益:图片资源不会散落在整个 filesDir;清理逻辑可以先验证 URI 是否属于受管目录,避免误删其他业务文件。
五、已经受管的 URI 不要重复复制
编辑记录或反复保存草稿时,输入数组里可能已经包含应用目录 URI。项目先判断:
private isManagedUri(uri: string, context: common.UIAbilityContext): boolean {
return uri.startsWith(`file://${context.filesDir}/report_photos/`);
}
如果属于受管目录,直接加入返回列表,不再复制。这样可以避免每次保存都产生一份相同图片,降低磁盘增长和后续引用清理难度。
这里的前缀检查只服务当前固定目录契约。如果未来引入多个媒体目录或更复杂文件提供器,应把“是否受管”升级为清晰的路径规范化和来源模型,不能继续堆叠字符串判断。
六、扩展名识别必须有安全回退
外部 URI 可能携带查询参数,也可能没有可识别后缀。项目先去掉 ? 后内容,再只允许 jpg/jpeg/png/webp/heic/gif 六类扩展名。
命中列表就沿用;未命中时回退为 jpg。这避免把任意 URI 尾部文本直接拼进文件名。
需要注意,后缀名并不能证明实际编码格式。生产级方案还应在解码或上传前检查真实媒体类型,并为损坏文件提供占位和错误提示。当前实现解决的是文件命名与落盘路径,不宣称完成完整媒体安全检测。
七、复制成功后再返回新的 file URI
每张外部图片使用 report_${Date.now()}_${index}.${extension} 作为目标文件名;fileIo.copyFileSync(uri, destination) 成功后,再把 file:// 形式的受管 URI 加入 saved。
调用方拿到的 saved 才应该进入草稿或正式记录。页面原 URI 与持久化 URI 是两个阶段:前者用于本轮交互,后者用于长期业务引用。
文件名使用时间戳和本轮索引降低同一批次冲突,但它不是跨设备全局 ID。如果以后接入账号同步或云上传,应增加稳定资源 ID、哈希或服务端对象键,而不是把本地路径当成跨端主键。
八、复制一半失败时必须回滚本轮文件
假设三张图片中前两张复制成功,第三张失败。如果直接抛错,前两张会成为没有数据库引用的孤儿文件。
项目把本轮已保存 URI 放入 saved,异常时执行:
catch (error) {
this.cleanup(saved, normalized, context);
throw new Error('照片保存失败');
}
normalized 作为保留集合传入,目的是避免误删原本已经受管、且来自输入的图片。失败回滚只清理本轮新产生、又不属于保留引用的文件。
这不是数据库事务,但建立了文件侧的补偿动作。Service 仍要捕获错误、阻止正式记录写入,并把页面恢复到可重试状态。

上图展示两条路径:成功时保存受管 URI,失败时只清理本轮创建文件;记录删除或图片替换时,再根据其他引用决定是否删除旧文件。
九、cleanup 不能看到 URI 就直接删除
清理方法接收 uris 和 keepUris。遍历时先判断 isManagedUri(),命中 keep.includes(uri) 就跳过;只有属于应用目录且不在保留集合中的目标,才去掉 file:// 前缀并调用 unlinkSync()。
它先确认目标属于应用管理目录,再检查保留集合。这样可避免两个危险操作:
- 删除 Photo Picker 外部 URI 指向的用户原图;
- 删除仍被当前草稿或新记录引用的应用文件。
更复杂系统还要做跨记录引用计数或反向查询。例如同一张受管图片被草稿和正式记录同时引用时,删除草稿不能把正式记录图片一起删掉。当前项目通过 Service 编排保留集合,后续规模扩大时应考虑独立媒体表。
十、保存草稿、发布和编辑的生命周期不同
同一个物化方法会进入多条业务路径:
| 场景 | 新图片处理 | 旧图片处理 | 成功后的权威引用 |
|---|---|---|---|
| 保存草稿 | 物化外部 URI | 清理不再引用项 | 草稿 Repository |
| 发布新记录 | 物化当前 URI | 发布成功后清对应草稿 | Report Repository |
| 编辑记录 | 复用未变受管 URI,物化新增项 | 写入成功后清被替换项 | 更新后的 Report |
| 删除记录 | 不再创建文件 | 清理无其他引用的受管文件 | 无 |
顺序非常关键:先写新数据成功,再清旧资源。若先删旧文件、后续数据库更新失败,用户会同时失去旧图片和新提交。
十一、用户体验上要保留失败后的草稿
图片复制失败通常发生在提交阶段。页面应该:
- 退出 saving/loading;
- 保留标题、描述、日期和当前图片槽位;
- 显示“照片保存失败,请重试”一类可行动文案;
- 允许移除问题图片或无图继续;
- 不跳转成功页,不递增跨页面数据版本;
- 不把底层路径或异常堆栈展示给用户。
文件层失败不能伪装成数据库成功。只有图片物化和 Repository 写入都完成,Service 才能返回发布成功。
十二、验证要区分代码契约与真实设备证据
可在无 Context 测试中验证:
- 空值、空格和重复 URI 会被规范化;
- 最多保留 3 张,顺序稳定;
- 无 Context 时返回克隆后的业务结果,不执行文件 I/O;
- Service 在物化抛错时不会写入记录。
但以下内容必须依赖真实设备与 Context:
filesDir/report_photos能否创建;- Photo Picker URI 是否可被
copyFileSync读取; - 三种常见图片格式是否可显示;
- 冷启动后受管 URI 是否仍可展示;
- 磁盘不足或文件损坏时的错误路径;
- 编辑、删除后是否没有误删与孤儿文件。
当前项目尚未取得“真实选择图片 + 物化 + 冷启动展示”的新证据,因此这些项目应标记为 not run,而不是依据代码推断为已通过。
十三、未来接入后端时不能直接上传本地路径
本地 file:// URI 只对当前应用环境有意义。接入后端后,应把媒体流程拆成:本地物化、上传任务、服务端对象键、记录绑定、失败重试和本地缓存清理。
同时要补充:
- 上传幂等 ID,防止重试产生重复对象;
- 文件大小、格式和内容安全检查;
- 弱网续传与失败队列;
- 记录删除后的服务端资源回收;
- 用户隐私说明和最短保留期限。
不要把设备本地绝对路径写进远端业务表,也不要在上传完成前删除唯一可用的本地副本。
十四、本文小结
Photo Picker URI 是输入,不是长期资产。ReportPhotoRepository.materialize() 先规范化、去重并限制 3 张,再把外部内容复制到 filesDir/report_photos;已受管 URI 直接复用,复制失败则回滚本轮新文件。
资源清理必须确认受管目录并尊重保留引用,Service 还要保证“新写入成功后再清旧资源”。下一篇将进入草稿数据层,说明如何用 report_type 主键隔离 LOST 与 FOUND 两类发布草稿。
系列导航:第 12 篇 / 共 50 篇。上一篇:《Photo Picker 最小权限选图》;下一篇:《按 LOST/FOUND 隔离草稿恢复》。
更多推荐



所有评论(0)