第30篇图片文件落盘沙箱路径、Uri 与后续读取第 30 篇关注照片落盘。相机项目最容易把“拍到照片”和“能长期使用照片”混在一起实际上它们是两件事拍到的是内存里的 JPEG 数据长期使用需要把数据写入应用沙箱并让相册、地图、分享、云同步都能用同一种路径规则读取。双镜记忆相机把拍摄文件统一放在filesDir/dual_captures再由记录服务转换为可展示的file://Uri。本文是 21 天「智能相机开发实战」训练营中的一篇实操记录。所有代码片段都来自当前项目配图围绕运行页面和源码关键路径展开读完以后可以直接回到工程里按函数名定位。本篇目标理解应用沙箱路径和相册展示 Uri 的区别。读懂目录创建、文件路径拼接、byteBuffer 写入三个动作。知道为什么记录模型里同时保留 path 和 uri。把文件保存失败和相册入库失败拆成两个可排查问题。代码位置entry/src/main/ets/pages/Index.etsentry/src/main/ets/services/GalleryRecordService.ets一、效果不是“有图”而是重启后还有图相册页能看到缩略图只能说明当前记录可以被页面读取。真正合格的落盘链路还要保证 App 重启后记录仍然存在图片路径仍能被转换成可展示 Uri后续导出、分享或云同步也能继续引用同一份文件。项目把图片文件放在固定目录里不把临时路径塞进 UI 层。图1 图片落盘链路沙箱目录、文件写入、file Uri 和相册缩略图二、路径生成所有拍摄文件先进入 dual_captures路径生成由ensureCaptureDirectory和buildCaptureFilePath负责。前者拿到UIAbilityContext.filesDir并确保dual_captures目录存在后者根据拍摄角色和时间戳生成文件名。文件名里带 role可以区分后摄、前摄和合成图时间戳则和captureId对齐方便从日志追踪一次拍摄。图2 ensureCaptureDirectory 与 buildCaptureFilePath 定义本地保存位置private ensureCaptureDirectory(): string { const hostContext this.getUIContext().getHostContext() as common.UIAbilityContext; const captureDir ${hostContext.filesDir}/dual_captures; this.ensureDirectoryExists(captureDir); return captureDir; } private ensureDirectoryExists(targetPath: string): void { if (this.pathExists(targetPath)) { return; } try { fs.mkdirSync(targetPath); } catch (error) { if (!this.pathExists(targetPath)) { console.error(Failed to create capture directory: ${JSON.stringify(error)}); } } } private pathExists(targetPath: string): boolean { try { return fs.accessSync(targetPath); } catch (error) { return false; } } private unlinkLocalFileQuietly(targetPath: string): void { if (targetPath.trim().length 0 || !this.pathExists(targetPath)) { return; } try { fs.unlinkSync(targetPath); } catch (error) { console.error(Failed to delete local file: ${JSON.stringify(error)}); } } private buildCaptureFilePath(role: back | front, timestamp: string): string { return ${this.ensureCaptureDirectory()}/${timestamp}_${role}.jpg; } private buildCompositeCaptureFilePath(timestamp: string): string { return ${this.ensureCaptureDirectory()}/${timestamp}_dual.jpg; }这一层不要放到组件模板里临时拼字符串。路径规则一旦集中后面无论是单拍、双拍还是顺序双拍都只需要告诉它角色和时间戳。三、文件写入把 JPEG byteBuffer 变成可持久保存的文件writeCaptureFile使用fs.openSync创建或覆盖目标文件再把 JPEG 的byteBuffer写进去。这里的返回值是布尔值调用方可以在失败时中断入库。它没有吞掉错误而是记录日志并返回 false这样 UI 层可以继续走统一失败处理。图3 writeCaptureFile 将 JPEG byteBuffer 写入目标文件private writeCaptureFile(targetPath: string, buffer: ArrayBuffer): boolean { let file: fs.File | undefined undefined; try { file fs.openSync( targetPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC ); fs.writeSync(file.fd, buffer); return true; } catch (error) { console.error(Failed to write capture file: ${JSON.stringify(error)}); return false; } finally { if (file) { try { fs.closeSync(file); } catch (error) { console.error(Failed to close capture file: ${JSON.stringify(error)}); } } } }写入成功并不等于用户已经看到了照片它只是完成了底层文件动作。后续还要创建GalleryMoment让相册页知道这张图片应该如何展示。四、Uri 规范化展示层读取 file://存储层保留原始 path记录服务里有一个小但关键的转换toFileUri。当路径已经是file://时直接返回当路径是沙箱绝对路径时补上file://前缀路径为空时返回空字符串。这样相册组件、详情页、导出逻辑都不需要重复判断路径格式。图4 GalleryRecordService 将本地路径规范化为 file Uriprivate static toFileUri(path: string): string { if (!path || path.trim().length 0) { return ; } if (path.startsWith(file://)) { return path; } return file://${path}; } private static toPhotoImageUri(path: string, storedUri: string): string { const normalizedUri storedUri ? storedUri.trim() : ; if (normalizedUri.length file://.length) { return normalizedUri; } return GalleryRecordService.toFileUri(path); }保留backPath/frontPath是为了文件操作生成backUri/frontUri是为了页面展示。两者边界清楚后续做导出或云同步时不会把 UI Uri 当成本地文件路径误用。工程检查清单文件目录必须从UIAbilityContext.filesDir推导避免写到不可控位置。路径规则集中在一个函数里单拍、双拍和合成图复用同一套命名。写文件失败要停止入库不能创建指向空文件的相册记录。展示 Uri 由服务层规范化UI 不重复拼接file://。日志里要保留 role、targetPath 和 captureId方便真机排查。今日练习搜索buildCaptureFilePath的调用点确认单拍、双拍、合成图分别传入什么 role。在真机拍一张照片后通过日志确认目标路径是否进入dual_captures。思考如果文件写入成功但记录保存失败用户侧应该看到什么提示。下一篇会继续沿着同一条工程链路往下拆先看用户能看到的效果再回到源码确认状态、文件和服务边界是否闭合。