第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 定义本地保存位置
ts
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 写入目标文件
ts
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 Uri
ts
private 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。 - 思考如果文件写入成功但记录保存失败,用户侧应该看到什么提示。
下一篇会继续沿着同一条工程链路往下拆:先看用户能看到的效果,再回到源码确认状态、文件和服务边界是否闭合。