HarmonyOS 7 Spatial Recon Kit 开发实录 05:PLY 与 MP4 结果写出、任务目录与完整性校验【鸿蒙心迹】
前四期把 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
更多推荐




所有评论(0)