作者:李游

一份 3DGS 重建结果从“算法已经算完”到“用户下一次还能打开”,中间隔着一段很容易被忽略的工程距离。空间重建管线关心输入、计算与输出,应用层还要处理目录、断点、校验、进程终止和最终可见性。只要在错误时机把半成品目录当成成品展示,重建成功率再高,用户看到的仍然是损坏场景。

本文围绕示例工程 SceneCommit 展开。页面名为 ReconArchivePage,演示任务为 RECON-0047:任务在 74% 时被中断,恢复后继续补齐 50 个分块,最后把 184 MB 产物发布为 scene_0047。这些数字用于说明状态与文图一致性,不是某台设备的性能实测。文中的 model.bin、preview.jpg 与 manifest.json 也是应用层归档约定,并非 Spatial Recon Kit 强制规定的输出格式。

一、真正危险的窗口在“完成”之后

重建页面通常有一个很顺眼的状态流:RECONSTRUCTING → READY。问题是,算法回调返回和产物可用并不是同一个瞬间。应用可能还在写预览图、刷新清单、同步文件、计算摘要或者搬移目录。若此时直接把任务标记为 READY,下一页就可能读到缺文件的目录。

SceneCommit 把中间状态拆成 RECONSTRUCTING → VERIFYING → COMMITTING → READY。只有最终目录改名成功,任务才进入 READY。任何中断都只会留下 .pending 目录,不会污染成品列表。

任务目录的约定如下:

  • 临时目录:recon_0047.pending
  • 最终目录:scene_0047
  • 分块总数:50
  • 中断点:37/50,也就是界面显示的 74%
  • 最终产物:184 MB
  • 摘要短码:c9a8...72e1

这里刻意不用“存在目录就算成功”的判断。目录存在只能说明创建动作发生过,不能证明分块齐全,也不能证明写入已经落盘,更不能证明清单与实际文件相符。

1. 状态必须能跨进程重建

页面里的 @State 适合驱动 UI,却不是恢复依据。进程被系统回收后,内存状态消失;真正能让任务续跑的是磁盘上的检查点。检查点至少需要任务 ID、总分块数、已完成分块、阶段、文件清单与更新时间。它不需要保存每一帧 UI 信息,但必须足以回答三个问题:现在写到哪里、还缺什么、这个目录能不能发布。

这段代码解决什么问题:定义一个可以落盘、可以校验、也可以向前兼容的检查点结构。

export type ReconStage =
  'RECONSTRUCTING' | 'VERIFYING' | 'COMMITTING' | 'READY' | 'FAILED';

export interface ArtifactEntry {
  name: string;
  size: number;
  sha256: string;
}

export interface ReconCheckpoint {
  schemaVersion: 1;
  taskId: 'RECON-0047' | string;
  stage: ReconStage;
  completedChunks: number;
  totalChunks: number;
  outputBytes: number;
  files: ArtifactEntry[];
  updatedAt: number;
}

export function progressOf(cp: ReconCheckpoint): number {
  if (cp.totalChunks <= 0) return 0;
  return Math.floor(cp.completedChunks * 100 / cp.totalChunks);
}

这里没有把进度单独当作可信字段,因为 74% 可以由 37/50 推导出来。减少冗余字段,能避免“分块数更新了、百分比没更新”的双写不一致。schemaVersion 也不是装饰,它给后续增加字段留下迁移入口。真实项目还应记录重建算法版本和产物格式版本,避免新版本误读旧目录。

易错点在于把 stage 当成唯一事实。COMMITTING 只表示流程走到提交阶段,并不证明改名已经完成;恢复时仍要观察 .pending 与最终目录谁存在,再结合清单做判断。

二、检查点要写成“旧的或新的”,不能写成“一半新的”

直接覆盖 manifest.json 很省代码,但崩溃窗口也最大:JSON 写到一半时进程终止,下一次启动连已完成分块都读不出来。更稳妥的办法是先写 manifest.json.tmp,同步到存储,再用同目录改名替换正式文件。这样读取者看到的是上一版完整清单或下一版完整清单,而不是半截字符串。

这段代码解决什么问题:用临时文件、fsyncSync 和同目录改名更新检查点,缩小断电或进程终止造成的清单损坏窗口。

import { fileIo } from '@kit.CoreFileKit';

export function saveCheckpointAtomically(
  pendingDir: string,
  checkpoint: ReconCheckpoint
): void {
  const target = `${pendingDir}/manifest.json`;
  const temp = `${pendingDir}/manifest.json.tmp`;
  const payload = JSON.stringify(checkpoint);
  const file = fileIo.openSync(
    temp,
    fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC
  );
  try {
    fileIo.writeSync(file.fd, payload);
    fileIo.fsyncSync(file.fd);
  } finally {
    fileIo.closeSync(file);
  }
  fileIo.renameSync(temp, target);
}

先写临时文件的目的,是让正式清单始终保持可解析状态;fsyncSync 用来明确文件内容同步点;finally 保证句柄成对关闭。TRUNC 很关键,没有它时短的新内容可能残留旧尾巴。实际项目需要确认目标文件系统对同目录改名的语义,并处理目标已存在时的替换策略;不同 API 版本的覆盖行为应以当前 SDK 文档和真机验证为准。

这段同步 I/O 不应运行在频繁刷新的 UI 主路径上。示例只在分块提交完成后写检查点;若分块很密,可以节流为每 N 个分块或每个稳定阶段写一次。写得越频繁,恢复粒度越细,但闪存写放大和调度开销也越高。

三、发布前校验的是“契约”,不是一个文件名

任务 RECON-0047 在 74% 中断时,model.bin 已经存在。如果恢复逻辑只检查这个文件,它会把 37 个分块误判为完整产物。SceneCommit 的校验顺序是:先校验清单结构,再确认 completedChunks === totalChunks,再逐项比对大小和摘要,最后才允许进入 COMMITTING。

摘要计算被封装在 HashService 中。它可以由经过验证的原生模块或平台可用的加密能力实现;本文不虚构一个 Spatial Recon Kit 的哈希接口。重点是调用边界:摘要结果必须与清单比较,失败时保持 pending,不要边校验边发布。

这段代码解决什么问题:把分块完整性、文件大小和摘要检查收敛为一个明确的发布门槛。

export interface HashService {
  sha256File(path: string): Promise<string>;
}

export async function verifyArtifacts(
  pendingDir: string,
  cp: ReconCheckpoint,
  hash: HashService
): Promise<void> {
  if (cp.completedChunks !== cp.totalChunks) {
    throw new Error(`CHUNKS_INCOMPLETE:${cp.completedChunks}/${cp.totalChunks}`);
  }
  for (const entry of cp.files) {
    const path = `${pendingDir}/${entry.name}`;
    const stat = fileIo.statSync(path);
    if (stat.size !== entry.size) {
      throw new Error(`SIZE_MISMATCH:${entry.name}`);
    }
    const actual = await hash.sha256File(path);
    if (actual !== entry.sha256) {
      throw new Error(`HASH_MISMATCH:${entry.name}`);
    }
  }
}

状态变化必须发生在验证边界之外:进入函数前写入 VERIFYING,函数全部成功后才能写 COMMITTING。任何异常都回到可恢复状态,并把错误码记录到诊断日志。不要在第一个文件校验通过后就更新局部 READY,否则列表页和详情页会得到不同结论。

对 184 MB 产物做全量摘要会消耗时间与电量。工程上可以在分块写入时增量记录摘要,最终只验证清单与尾部;也可以只对关键文件做强校验、对可再生预览图做大小检查。取舍的依据不是“快不快”,而是文件损坏后的代价:不可重算的几何数据应更严格,可重建缓存可以更宽松。

图中的 DevEco Studio 画面是与本文字段一致的演示配图,不是一次真实 IDE 执行截图。左侧工程目录把检查点、校验与恢复拆开;中间标出 fsyncSync 与 renameSync;右侧模拟器停在 VERIFYING 74%;底部 HiLog 使用任务 RECON-0047 和 chunks=37/50。它要表达的是可观察性设计,而不是宣称某台设备已跑出这些数据。

四、恢复扫描不能把所有 pending 都一股脑续跑

应用启动后发现 .pending 目录,只能说明上次流程没有正常收尾。它可能是可续跑任务,也可能是格式过旧、清单损坏、最终目录已经存在,甚至是另一个进程留下的孤儿目录。恢复扫描应该先分类,再决定续跑、补提交、隔离还是清理。

SceneCommit 采用四条规则:

  1. 最终目录存在:不再续跑 pending,先比较任务 ID 和清单版本。
  2. 清单不可解析:移动到隔离区,避免自动删除造成数据损失。
  3. 分块不足:从 completedChunks 继续,示例为 37/50。
  4. 分块齐全且校验通过:跳过重建,直接补做原子提交。

这段代码解决什么问题:在启动恢复时区分“继续计算”和“补做提交”,并保证只有最终改名成功才进入 READY。

export async function recoverOrCommit(
  root: string,
  cp: ReconCheckpoint,
  hash: HashService,
  resume: (fromChunk: number) => Promise<ReconCheckpoint>
): Promise<ReconCheckpoint> {
  const pending = `${root}/recon_0047.pending`;
  const finalDir = `${root}/scene_0047`;
  let current = cp;

  if (current.completedChunks < current.totalChunks) {
    current = await resume(current.completedChunks); // 37 -> 50
    saveCheckpointAtomically(pending, current);
  }

  current.stage = 'VERIFYING';
  saveCheckpointAtomically(pending, current);
  await verifyArtifacts(pending, current, hash);

  current.stage = 'COMMITTING';
  saveCheckpointAtomically(pending, current);
  fileIo.renameSync(pending, finalDir);
  return { ...current, stage: 'READY' };
}

为什么 READY 没有再写回 pending?因为目录改名后,pending 已不存在。实际实现应在最终目录内写一份 ready.marker 或更新清单,再让索引器读取;也可以把 READY 存在独立任务数据库中。关键是确定单一事实源,避免目录名说 READY、清单仍是 COMMITTING 的歧义。

函数里的 resume 是应用对空间重建管线的适配层。它接收恢复位置并返回新的检查点,不代表 Spatial Recon Kit 原生接口就接受 fromChunk。若底层能力不支持分块续算,应用仍可保留已完成输入与中间缓存,但必须从官方支持的边界重新启动重建,不能假装有不存在的断点参数。

五、把 74% 中断变成可解释的用户状态

恢复体验不该只是一个旋转进度条。用户需要知道系统是在继续计算、验证旧产物,还是整理目录。示例运行页把状态、任务 ID、分块数、产物大小和摘要短码放在同一屏,并把“恢复点 74%”作为来源说明。

页面显示 READY、100%、50/50、184 MB 与 c9a8...72e1。这些字段都能从检查点或校验结果推导,不使用随手拼接的 UI 文案。红色标注只指出“从 74% 恢复”这一工程事实,避免把整张图标成故障海报。

更细的诊断页则按事件顺序展示:检测到 pending、读取 manifest、确认 37/50、恢复分块、校验通过、原子提交、孤儿文件为 0。这样遇到“明明 100% 却打不开”的反馈时,开发者能判断任务是否真的跨过提交边界。

诊断页的 recon_0047.pending → scene_0047 是本方案最重要的一条线。箭头左边可以失败、暂停、重试;箭头右边才允许进入成品索引。若业务需要取消任务,取消动作也只清理 pending,并在关闭重建回调、文件句柄和后台任务之后执行。资源释放必须成对:注册回调就要注销,打开文件就要关闭,创建任务就要有取消与完成路径。

六、哪些结论能直接采用,哪些必须真机验证

可以直接采用的是工程原则:半成品与成品分目录、检查点原子更新、发布前校验、最终提交作为可见性边界、恢复扫描先分类。它们不依赖具体 3DGS 算法实现。

必须在目标设备和当前 SDK 上验证的,是文件系统细节与性能成本:同目录改名是否满足预期覆盖语义,fsyncSync 在目标存储上的耗时,184 MB 全量摘要是否影响温升,以及空间重建输出什么时候真正关闭写入。官方 API 文档是接口依据,真机故障注入才是行为依据。

建议至少做四类故障注入:写清单前终止、写清单后终止、校验中终止、改名前后分别终止。每次重启后检查三件事:成品列表是否只出现完整目录,恢复点是否单调前进,孤儿文件是否为 0。若底层重建不支持续算,也要验证“重新开始但不误发布旧目录”的退化路径。

本文参考的官方一手资料包括 Spatial Recon Kit 的空间重建管线指南与 HarmonyOS Core File Kit 文件管理 API:

  • https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline
  • https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-file-fs

七、收束:把 READY 留给真正可打开的场景

3DGS 端侧重建的可靠性,不只发生在相机、点云和渲染器里,也发生在最后几十毫秒的文件提交中。SceneCommit 的做法并不神秘:让 pending 可以被打断,让 manifest 可以被替换,让 verify 有明确门槛,让 rename 成为唯一发布动作。

当任务 RECON-0047 从 37/50 恢复到 50/50 时,UI 的 100% 只是计算完成;当 recon_0047.pending 原子切换为 scene_0047,READY 才真正成立。把这两个时刻分开,能消除大量“偶发损坏”与“重启后消失”的模糊问题,也让后续诊断有据可查。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐