【寻迹校园 HarmonyOS NEXT 实战 12】临时 URI 不能直接长期保存:把 Photo Picker 图片复制到应用沙箱

这是“寻迹校园 HarmonyOS NEXT 实战”系列第 12 篇。本文结合 ReportPhotoRepository.ets,拆解 Photo Picker URI 的规范化、去重、扩展名回退、应用沙箱复制、失败回滚与引用感知清理。

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;
}

这段逻辑同时完成四件事:

  1. 去掉首尾空白;
  2. 丢弃空字符串;
  3. 保留首次出现顺序并去重;
  4. 最终最多保留 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 就直接删除

清理方法接收 uriskeepUris。遍历时先判断 isManagedUri(),命中 keep.includes(uri) 就跳过;只有属于应用目录且不在保留集合中的目标,才去掉 file:// 前缀并调用 unlinkSync()

它先确认目标属于应用管理目录,再检查保留集合。这样可避免两个危险操作:

  • 删除 Photo Picker 外部 URI 指向的用户原图;
  • 删除仍被当前草稿或新记录引用的应用文件。

更复杂系统还要做跨记录引用计数或反向查询。例如同一张受管图片被草稿和正式记录同时引用时,删除草稿不能把正式记录图片一起删掉。当前项目通过 Service 编排保留集合,后续规模扩大时应考虑独立媒体表。

十、保存草稿、发布和编辑的生命周期不同

同一个物化方法会进入多条业务路径:

场景 新图片处理 旧图片处理 成功后的权威引用
保存草稿 物化外部 URI 清理不再引用项 草稿 Repository
发布新记录 物化当前 URI 发布成功后清对应草稿 Report Repository
编辑记录 复用未变受管 URI,物化新增项 写入成功后清被替换项 更新后的 Report
删除记录 不再创建文件 清理无其他引用的受管文件

顺序非常关键:先写新数据成功,再清旧资源。若先删旧文件、后续数据库更新失败,用户会同时失去旧图片和新提交。

十一、用户体验上要保留失败后的草稿

图片复制失败通常发生在提交阶段。页面应该:

  • 退出 saving/loading;
  • 保留标题、描述、日期和当前图片槽位;
  • 显示“照片保存失败,请重试”一类可行动文案;
  • 允许移除问题图片或无图继续;
  • 不跳转成功页,不递增跨页面数据版本;
  • 不把底层路径或异常堆栈展示给用户。

文件层失败不能伪装成数据库成功。只有图片物化和 Repository 写入都完成,Service 才能返回发布成功。

十二、验证要区分代码契约与真实设备证据

可在无 Context 测试中验证:

  1. 空值、空格和重复 URI 会被规范化;
  2. 最多保留 3 张,顺序稳定;
  3. 无 Context 时返回克隆后的业务结果,不执行文件 I/O;
  4. 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 隔离草稿恢复》。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐