前四期把 ReconRoom 从“能创建 Session”推进到了“能筛帧、能暂停恢复、遇到系统压力还能把任务接回来”。做到这里以后,我原本以为剩下只是把结果保存一下。

实际跑完整条链路才发现,重建结束和结果可用,中间还隔着一层很容易被忽略的工程问题:输出文件到底有没有真的写完整。

这次我没有继续改采集页,而是把注意力放到重建完成后的 40 多秒。任务 recon_20261002_05 已经到 100%,应用仍然要完成 PLY 写出、MP4 预览生成、目录固化和完整性校验。最终这轮生成 1,284,560 个高斯点,PLY 文件 84.6 MB,预览视频 27.4 MB,整个结果写出阶段耗时 46 秒。

一、重建 100% 以后,我不再立刻显示“已完成”

前一版最明显的问题,是重建回调结束以后页面直接切到 COMPLETED。用户看到的是“完成”,此时结果文件可能还没有写完。

这个差别在短任务里不明显。真正跑到 80 MB 以上的模型后,保存阶段已经足够长:页面先提示完成,用户马上离开,下一次进入结果页却发现文件还没出现,看起来就像结果丢了。

所以 ReconRoom 现在把后半段状态拆开:

RECONSTRUCTING
→ RECON_FINISHED
→ SAVING_PLY
→ SAVING_MP4
→ VERIFYING
→ RESULT_SAVED

RECON_FINISHED 只表示计算阶段结束,不代表业务任务结束。真正允许进入模型查看页的是 RESULT_SAVED。

这次任务的数据固定为:

taskId: recon_20261002_05
reconstruction: 100%
gaussian points: 1,284,560
PLY: 84.6 MB
MP4: 27.4 MB
save cost: 46 s
verify: PASSED

这些数字同时放进正文、日志和最终运行图里。后面如果结果页出现大小不一致,不需要猜“是不是配图写错”,先查 manifest 就能定位。

二、结果写出仍然属于 Session 生命周期的一部分

官方的 Spatial Recon Kit 链路里,重建完成后可以通过 HMS_SpatialRecon_SaveResultToFile 保存结果,技术解读中给出了 PLY 与 MP4 两类输出。这里我没有让页面直接拼 Native 参数,而是继续沿用前几期的 ReconManager → Native Adapter 结构。

这段代码解决的是“同一个 Session 被两个保存动作同时抢占”的问题。项目里我把 PLY 和 MP4 串行写出:

#include "spatial/spatial_recon_interface.h"

HMS_SpatialReconStatus SaveResult(
    HMS_SpatialRecon_Session* session,
    bool saveVideo)
{
    if (session == nullptr) {
        return SPATIAL_RECON_STATUS_INVALID_PARAM;
    }

    HMS_SpatialRecon_ModelWriteInfo info {};
    info.modelFormat = saveVideo
        ? SPATIAL_RECON_OUTPUT_FORMAT_MP4
        : SPATIAL_RECON_OUTPUT_FORMAT_PLY;

    return HMS_SpatialRecon_SaveResultToFile(
        session,
        &info,
        nullptr
    );
}

枚举和值以当前 SDK 头文件为准,工程里不把这些常量重新复制到 ArkTS 层。

这里有一个很实际的限制:保存结果时不要把它当成完全独立于重建 Session 的文件工具。当前工程仍然把它视为 Session 生命周期的尾部操作。只有 PLY、MP4 都结束,并完成校验以后,Manager 才允许销毁 Session。

这和第一篇的“页面退出不能顺手 DestroySession”是同一件事:资源真正结束的时机由任务决定,不由 UI 决定。

三、目录结构必须从“能写进去”升级到“以后还能认出来”

最开始所有文件都放在:

/data/storage/el2/base/files/ReconRoom/result/

跑到第五期时这个结构已经不够用了。一次失败重试、一次 MP4 重新生成,就可能把前一轮结果覆盖掉。

现在每个任务使用独立目录:

ReconRoom/
└── recon_20261002_05/
    └── result/
        ├── room_20261002_05.ply
        ├── preview_20261002_05.mp4
        └── manifest.json

这个结构有三个好处。

一是 taskId 就是磁盘上的业务边界;二是 PLY、预览视频和描述文件天然属于同一个结果;三是后面做清理时可以整目录回收,不需要在一个公共目录里猜哪些文件属于同一任务。

我没有把临时文件和最终文件混在一起。写出过程先进入 staging,全部成功后才把结果视为正式资产。即使应用在中途被终止,下一次启动看到 staging 目录,也知道它是“不完整任务”,不会把半个 PLY 当成正常模型。

四、文件存在并不代表文件已经可用

这一轮真正让我改代码的,是一次“文件明明存在,模型却加载失败”。

原因并不神秘:保存过程刚创建文件时,目录里已经能看到文件名,但文件大小还在增长。旧代码只是 accessSync() 成功就认为保存完成,判断太早。

我给 ResultArtifactStore.ets 加了一层结果检查。这个代码解决的是“文件存在但仍然为空或尺寸异常”的问题:

import { fileIo } from '@kit.CoreFileKit'

export interface ArtifactMeta {
  name: string
  path: string
  minBytes: number
}

export class ResultArtifactStore {
  verifyFile(meta: ArtifactMeta): number {
    const stat = fileIo.statSync(meta.path)

    if (stat.size < meta.minBytes) {
      throw new Error(
        `RESULT_FILE_INCOMPLETE: ${meta.name}, size=${stat.size}`
      )
    }

    return stat.size
  }

  verifyAll(files: ArtifactMeta[]): number {
    let total = 0

    files.forEach((item: ArtifactMeta) => {
      total += this.verifyFile(item)
    })

    return total
  }
}

这里故意没有把“84.6 MB”写成固定阈值。每次重建内容不同,模型大小本来就会变化。minBytes 只是用于拦截明显为空、只写了文件头、或者中途失败的结果。

正式产品还可以继续加入摘要校验、版本字段和格式检查。ReconRoom 当前阶段先做一个工程上足够明确的判断:三个预期文件都存在、大小合理、任务 ID 匹配,才进入 PASSED。

五、manifest 不是为了好看,它是结果资产的索引

第五期新增的另一个模块是 ResultManifestStore.ets。

之前项目恢复依赖 task.json,它描述的是“任务过程”。现在结果已经落盘,还需要一份只描述“结果资产”的文件。

本轮 manifest 的核心内容类似:

{
  "taskId": "recon_20261002_05",
  "state": "RESULT_SAVED",
  "pointCount": 1284560,
  "saveCostMs": 46000,
  "files": [
    {
      "name": "room_20261002_05.ply",
      "size": 88709530
    },
    {
      "name": "preview_20261002_05.mp4",
      "size": 28730982
    }
  ],
  "verify": "PASSED"
}

这里我更在意的是 state 和文件元数据,而不是把几十个运行指标都塞进去。

manifest 的职责很克制:告诉后续模块,这个任务最终产生了什么、文件在哪里、保存是否通过校验。像采集过程中被过滤了多少帧、系统压力升到过哪一级,这些属于任务日志,不该继续堆在结果清单里。

有了这层以后,第六期加载模型时就不需要重新扫描目录。结果页先读 manifest,再把确认过的模型 URI 交给渲染模块。

六、保存进度必须和重建进度分开显示

第五期的 DevEco 图里,我专门保留了保存过程,而不是只截一张 100% 的结果页。

截图中的任务仍然是:

recon_20261002_05

重建本身已经结束,但结果写出还在继续。HiLog 分开打印:

save PLY start
save PLY success
save MP4 start
save MP4 success
manifest committed
verify PASSED

这个拆分对排查很有用。

假如模型文件已经生成、MP4 失败,任务不需要重新跑一遍 3DGS 重建;只要保留 Session 和中间结果,就可以针对保存阶段继续处理。反过来,如果 PLY 本身校验没过,就不能因为 MP4 恰好生成成功而把任务标成可用。

实际产品里我会把 PLY 看成核心资产,把 MP4 当成便于分享和预览的派生资产。两者都记录状态,但失败策略可以不同。

七、页面退出时,结果写出不能被 UI 生命周期误杀

这一段是第五期里最容易遗漏的生命周期问题。

用户看到重建 100% 后,很可能直接返回首页。旧实现里 aboutToDisappear() 会把页面相关对象全部清掉,保存回调也随之丢失。文件其实还在后台写,但 UI 再回来时没有人知道写到哪一步。

现在页面不持有 Native 保存任务,只订阅 ReconManager 的 ResultSnapshot:

export interface ResultSnapshot {
  taskId: string
  state: string
  saveProgress: number
  verifyState: 'WAITING' | 'PASSED' | 'FAILED'
}

@Entry
@Component
struct ResultPage {
  @State state: string = 'RECON_FINISHED'
  @State saveProgress: number = 0
  @State verifyState: string = 'WAITING'

  private unsubscribe?: () => void

  aboutToAppear(): void {
    this.unsubscribe = ReconManager.shared()
      .subscribeResult('recon_20261002_05', (s: ResultSnapshot) => {
        this.state = s.state
        this.saveProgress = s.saveProgress
        this.verifyState = s.verifyState
      })
  }

  aboutToDisappear(): void {
    this.unsubscribe?.()
    this.unsubscribe = undefined
  }
}

这里页面消失只取消订阅,不取消结果写出。

真正的取消动作必须从任务层发起,而且要明确告诉用户:当前是在取消“保存结果”,不是普通的返回页面。这样状态语义才不会被路由行为污染。

八、这一轮结束,ReconRoom 才真正拥有“可交付结果”

最终运行页不是一句“导出成功”,而是把可用资产列出来:

最终统一数据是:

taskId: recon_20261002_05
state: RESULT_SAVED
progress: 100%
pointCount: 1,284,560
PLY: room_20261002_05.ply / 84.6 MB
MP4: preview_20261002_05.mp4 / 27.4 MB
manifest: 1.9 KB
saveCost: 00:00:46
verify: PASSED

做到这里,前面四期的采集、位姿、暂停恢复、压力保护才终于有一个可以被别的页面消费的结果。

下一期也是这个系列最后一期,我不会再回到重建流程里加功能,而是从 manifest → model uri → GSPlugin.loadGSNode() 开始,把已经保存的结果真正放进 ArkGraphics 3D 场景。重点会落在三个问题上:首次加载时间、重复进入页面后的资源释放,以及长时间旋转观察时的帧率和内存是否稳定。

九、staging 目录解决的是“半成品看起来像成品”

第五期还有一个问题,是我在第二次中断测试才看到的。

当保存过程被系统打断时,PLY 文件名已经出现,甚至大小也不是 0。单看目录会误以为它已经是完整结果。下一次启动如果直接扫描 result/,就可能把半成品挂到“查看模型”按钮上。

所以现在保存过程不直接写最终目录,而是先进入:

ReconRoom/
└── recon_20261002_05/
    ├── staging/
    │   ├── room_20261002_05.ply.part
    │   └── preview_20261002_05.mp4.part
    └── result/

只有 Native 写出结束、文件大小检查通过、manifest 能够正常生成之后,Manager 才把 staging 结果提交到 result/。

这里的“提交”不是为了模仿数据库事务,而是给业务一个明确边界:staging 里的任何东西都不能出现在用户结果列表;result 里的内容必须通过完整性校验。

这样做以后,启动恢复逻辑也简单很多。应用启动时先扫描 staging,如果发现上一次遗留的 .part,就把任务标记为 SAVE_INTERRUPTED。能恢复就继续,不能恢复就提示重新生成派生文件;不会把一个中断状态伪装成成功状态。

1. PLY 成功、MP4 失败时,不应该一刀切成“任务失败”

这次我也调整了结果状态的粒度。

PLY 是后续 3DGS 加载的核心资产,MP4 更像预览和分享用的派生资产。如果 PLY 已经完整写出、校验也通过,而 MP4 因为空间不足或保存过程被打断,直接把整个任务改成 FAILED 并不合理。

现在结果层把它拆成:

modelState: READY
previewState: READY / FAILED / NOT_GENERATED
verifyState: PASSED / FAILED

最终业务判断是:modelState=READY && verifyState=PASSED 才允许进入模型页;MP4 失败只影响预览按钮,并给用户提供“重新生成预览”的入口。

这套区分也让重试成本低很多。用户不需要重新扫描房间,更不需要重新跑一次 3DGS;只重做失败的派生输出。

十、磁盘空间不是保存失败以后才去检查

大模型保存时,最差的处理方式是写到 90% 才发现空间不够。

ReconRoom 当前会在结果写出前先做一次空间预算。预算不是简单拿“上一轮文件大小”乘一个固定系数,而是按当前任务计划至少预留:

PLY 预计占用
+ MP4 预计占用
+ staging 同期占用
+ manifest / 临时元数据
+ 一段安全余量

因为 staging 和最终文件在提交瞬间可能同时存在,真正的峰值空间会高于最终结果目录本身。

如果预算不足,任务停在 RECON_FINISHED,不进入 SAVING_PLY。这样虽然用户要先清理空间,但至少不会产生一个写到一半的大文件。

我还给结果目录加了“按任务清理”的入口。清理时不根据扩展名逐个删,而是以 taskId 为单位回收整个任务结果。这样不会出现 PLY 删了、manifest 还在,结果列表里继续残留一条假记录。

1. 结果页的验收不是只看绿色对勾

第五期最后我做了六组反向测试:

  • 保存过程中直接返回首页,确认任务继续;
  • 保存到一半强制终止应用,重启后能够识别 staging 残留;
  • 删除 MP4,确认 PLY 仍可以进入模型查看;
  • 把 PLY 截断成很小的文件,完整性校验必须失败;
  • 修改 manifest 中的 taskId,结果页必须拒绝加载;
  • 连续生成两个任务,目录和状态不能互相覆盖。

这些测试完成后,我才把 RESULT_SAVED 当成真正可用的业务状态。

从开发体验上看,这一篇没有前面“点云开始出现”那么直观,但它解决的是最接近线上数据安全的一段:用户花几分钟扫完一个空间,最后生成出来的资产不能因为一次返回、一次中断或者一个残留文件就失去可信度。

参考资料

  • Spatial Recon Kit 3DGS 端侧重建技术解读:https://developer.huawei.com/consumer/cn/forum/topics/
  • Spatial Recon Kit 文档入口:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-introduction
  • 3DGS 空间重建 Pipeline:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline
Logo

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

更多推荐