这次没有先做“看起来像 3D 重建”的页面,而是先把最短的一条工程链路跑通:设备支持检测、Native Session 创建、采集任务状态同步、开始重建、进度回传,以及页面退出后的资源释放。

项目叫 ReconRoom。这一轮的测试任务固定为 recon_20261001_01,场景类型是 living_room。跑到能稳定观察状态时,任务已经处理到 128 / 200 帧,页面显示 68%,状态为 RECONSTRUCTING。

一、第一版最容易犯的错,是把“按钮能点”当成“能力可用”

Spatial Recon Kit 的特殊之处在于,它不是一个适合拿模拟器先把全链路跑通的普通 UI API。当前官方资料对设备能力、芯片和运行环境都有明确约束,重建链路本身也偏 Native。也就是说,页面上出现“开始扫描”并不代表后面的 Session 一定能创建成功。

我一开始把支持检测放在按钮点击之后:用户点“开始”,ArkTS 再调用 Native。单次测试看不出问题,但一旦换到不支持的设备,UI 已经切换到扫描态,Native 才返回不支持,页面还要再倒退一次状态,整个体验很别扭。

后来我把顺序改成了:

页面进入 → 能力检测 → 创建工作目录 → 创建 Session → UI 进入 READY → 用户开始采集

这样 UI 只消费已经确认过的能力状态。

当前项目的状态枚举很简单:

IDLE
→ CHECKING
→ READY
→ CAPTURING
→ RECONSTRUCTING
→ COMPLETED / FAILED

这里没有把“暂停、恢复、热保护”塞进第一篇,因为这几个状态后面会单独处理。第一阶段只验证一件事:Session 的拥有者到底是谁,以及它什么时候能被安全销毁。

二、Native 层先做支持检测,再创建唯一 Session

这一段代码解决的是“设备不支持时仍然创建 Session”的问题。Spatial Recon Kit 的重建能力在 C++ 侧使用,项目里我把它收进 SpatialReconNative.cpp,ArkTS 页面不直接接触 HMS 的 Session 指针。

示例省略了 Node-API 参数转换,只保留和 Session 生命周期直接相关的部分:

#include "spatial/spatial_recon_interface.h"

namespace recon {

static HMS_SpatialRecon_Session* g_session = nullptr;

HMS_SpatialReconStatus PrepareSession(const char* workPath)
{
    if (g_session != nullptr) {
        return SPATIAL_RECON_STATUS_SUCCESS;
    }

    HMS_SpatialReconStatus status =
        HMS_SpatialRecon_IsSupport(SPATIAL_RECON_MODEL_TYPE_GS);

    if (status != SPATIAL_RECON_STATUS_SUCCESS) {
        return status;
    }

    status = HMS_SpatialRecon_CreateSession(
        SPATIAL_RECON_MODEL_TYPE_GS,
        workPath,
        &g_session
    );

    if (status != SPATIAL_RECON_STATUS_SUCCESS) {
        g_session = nullptr;
    }

    return status;
}

void ReleaseSession()
{
    if (g_session == nullptr) {
        return;
    }

    HMS_SpatialRecon_DestroySession(g_session);
    g_session = nullptr;
}

HMS_SpatialRecon_Session* GetSession()
{
    return g_session;
}

}

这段代码看起来很薄,但我刻意没有让 CreateSession 散落在页面事件里。

原因是 Session 具有明确的 Native 生命周期。页面重建、路由跳转甚至 UI 状态刷新,都不应该重复生成一个新 Session。当前实现用一个进程内指针约束“只持有一个重建会话”,页面拿到的是任务状态,不是裸指针。

真正产品化时还需要把这里改成线程安全的 Manager,并处理进程内多个业务模块竞争能力的情况;第一版先把所有权收紧,比一开始就在 ArkTS 页面到处调用要稳得多。

三、ArkTS 只维护任务,不维护 Native 指针

Native Session 建好以后,我没有让页面直接调用一串 start / progress / destroy。中间加了一层 ReconManager.ets,把页面状态和底层 Session 状态分开。

这一段解决的是“页面状态和 Native 状态互相覆盖”的问题:

export type ReconState =
  'IDLE' |
  'CHECKING' |
  'READY' |
  'CAPTURING' |
  'RECONSTRUCTING' |
  'COMPLETED' |
  'FAILED'

export interface ReconSnapshot {
  taskId: string
  sceneType: string
  state: ReconState
  progress: number
  capturedFrames: number
  totalFrames: number
}

export class ReconManager {
  private snapshot: ReconSnapshot = {
    taskId: 'recon_20261001_01',
    sceneType: 'living_room',
    state: 'IDLE',
    progress: 0,
    capturedFrames: 0,
    totalFrames: 200
  }

  async prepare(): Promise<ReconSnapshot> {
    this.snapshot.state = 'CHECKING'

    const supported = await reconNative.isSupported()
    if (!supported) {
      this.snapshot.state = 'FAILED'
      throw new Error('SPATIAL_RECON_DEVICE_NOT_SUPPORTED')
    }

    const workPath =
      '/data/storage/el1/base/spatial_recon_files/recon_20261001_01/'

    await reconNative.createSession(workPath)
    this.snapshot.state = 'READY'
    return this.getSnapshot()
  }

  getSnapshot(): ReconSnapshot {
    return { ...this.snapshot }
  }
}

这里有一个工程上的小取舍:taskId 不是 HMS Session ID,而是业务自己的任务 ID。Native Session 可以因为异常重建、前后台切换而重新创建,但业务任务 recon_20261001_01 不应该跟着变。

后面做任务恢复时,这个区分会非常重要。否则一旦把底层句柄当成业务标识,Session 重建之后,历史状态、文件目录和 UI 都会失去对应关系。

1. 工作目录不能只是一个临时字符串

Session 创建成功以后,我又补了一次目录检查。最开始的实现把所有重建结果都写进同一个 spatial_recon_files 目录,跑一次没有问题,第二次开始就很难区分中间文件属于哪个任务。

现在 ReconRoom 把 taskId 直接纳入目录:

spatial_recon_files/
└── recon_20261001_01/
    ├── temp/
    ├── result/
    └── task.json

task.json 不保存 Native 指针,只记录业务能恢复的数据:任务 ID、场景类型、创建时间、当前状态、最后进度和结果路径。这样即使进程被系统回收,下一次启动仍然能判断“这里曾经有一个任务”,而不是面对一堆不知道属于哪一轮扫描的文件。

还有一个细节是目录创建失败不能继续往下走。存储空间不足、路径不可写、历史残留文件冲突,这些问题都应该在 CreateSession 之前被发现。否则 Native Session 已经建立,工作目录却不可用,后面的保存失败会被误判成重建失败。

目前我把目录准备也算进 CHECKING 阶段。只有“能力支持 + 工作目录可用 + Session 创建成功”全部成立,页面才进入 READY。这个边界比“API 返回成功就算准备好”更接近真实工程状态。

2. 错误码不要原样扔给页面

Native 层返回的是能力错误,页面需要的是用户能理解的业务状态。两者直接绑定以后,UI 很快就会出现大量 if/else:设备不支持怎么提示、Session 创建失败怎么提示、目录异常怎么提示、输入帧异常怎么提示。

所以 Manager 里我又加了一层错误映射。页面只认识几类业务错误:

DEVICE_UNSUPPORTED
WORKDIR_UNAVAILABLE
SESSION_CREATE_FAILED
FRAME_INPUT_INVALID
RECON_FAILED

这样做不是隐藏底层信息。HiLog 里仍然保留原始 HMS status,便于定位;UI 只消费稳定的业务枚举。后续 SDK 如果增加或调整错误码,页面不需要跟着改一轮。

这个分层在连载后面做暂停恢复时会更明显:同一个 Native status,在“首次创建”和“恢复旧任务”两个场景里,用户提示可能完全不同。把错误码直接透传给 UI,会让业务层越来越难维护。

四、真正开始重建前,我先把“帧输入”和“启动”拆开

官方链路里有 HMS_SpatialRecon_PushFrame / HMS_SpatialRecon_PushARFrame、HMS_SpatialRecon_StartSession、进度查询和结果保存。第一轮我没有把采集帧的所有参数校验都展开,而是先把调用顺序固定下来:

CreateSession
→ Push valid frames
→ Register callback / progress observer
→ StartSession
→ Read progress
→ Save result
→ DestroySession

这样做是为了避免一个很典型的问题:页面点了开始就立刻 StartSession,但 Native 侧还没有拿到足够有效的数据帧,最终很难判断是 Session 本身失败,还是输入数据不符合要求。

ReconRoom 里把采集和重建视为两个阶段。CAPTURING 阶段只负责把合法帧送入队列;当输入满足条件后才切到 RECONSTRUCTING。

这次截图里的 128 / 200 并不是说 Spatial Recon Kit 必须固定 200 帧,而是 Demo 自己定义的采集目标。它只是用来让 UI 有一个可观察的工程状态,不应该误解成系统能力规格。

五、页面生命周期只做两件事:订阅状态、释放订阅

页面层我尽量不做 Native 管理。ScanPage.ets 只负责订阅 Manager 的快照,并在页面离开时停止 UI 轮询。

这一段解决的是“页面退出后计时器还在刷新”的问题:

@Entry
@Component
struct ScanPage {
  @State taskId: string = 'recon_20261001_01'
  @State progress: number = 0
  @State status: string = 'IDLE'
  @State capturedFrames: number = 0

  private manager: ReconManager = ReconManager.shared()
  private timerId: number = -1

  async aboutToAppear(): Promise<void> {
    const snapshot = await this.manager.prepare()
    this.applySnapshot(snapshot)

    this.timerId = setInterval(async () => {
      const latest = await this.manager.refreshProgress()
      this.applySnapshot(latest)
    }, 300)
  }

  aboutToDisappear(): void {
    if (this.timerId >= 0) {
      clearInterval(this.timerId)
      this.timerId = -1
    }
  }

  private applySnapshot(data: ReconSnapshot): void {
    this.taskId = data.taskId
    this.progress = data.progress
    this.status = data.state
    this.capturedFrames = data.capturedFrames
  }
}

这里故意没有在 aboutToDisappear() 里直接 DestroySession。

原因很简单:页面离开不等于重建任务结束。后面要做“退到任务列表再回来”“切后台再回来”“异常恢复”,如果页面销毁时就把 Session 一并销毁,任务连续性会被 UI 生命周期绑死。

现在的规则是:页面只释放自己的订阅;真正的 Session 由 ReconManager 在任务结束、取消或不可恢复异常时统一释放。

1. 进度采样不是越快越好

我一开始把轮询间隔设成 50ms,觉得这样进度条会更“实时”。真机跑起来以后发现,这个刷新频率没有给用户带来更多信息,反而让 ArkTS 层频繁更新状态,HiLog 也被进度输出刷屏。

当前改成 300ms。一方面足够让进度组件看起来连续,另一方面不会把 UI 刷新变成重建任务之外的额外负担。真正需要高频观察的 Native 指标可以单独记录,不必全部映射到界面。

这里还做了一次去重:如果新进度和上次完全一样,就不触发页面状态更新。比如 Native 连续三次返回 68%,页面只保留第一次。这个改动很小,但长时间重建时能明显减少无意义刷新。

另外,progress 只表示当前任务阶段的可视进度,不承担生命周期判断。不能因为进度到了 100 就直接在页面写 COMPLETED,最终状态仍然以 Manager 收到的完成回调为准。否则保存结果失败时,用户会先看到“完成”,随后又突然变成“失败”,状态语义会很混乱。

六、DevEco 里的模拟器只验证 UI,Native 能力仍然看真机

为了保持整个连载的配图结构一致,开发截图右侧仍然放了 HarmonyOS 模拟器,用来确认页面状态、布局和进度组件。

但这一点必须说清楚:Spatial Recon Kit 的真实重建链路不能靠模拟器证明跑通。 当前 68%、128 / 200 的 Native 状态以支持设备上的运行日志为准,模拟器只复现同一份业务 Snapshot。

我在 HiLog 里固定打印四类信息:

taskId
session state
progress
capturedFrames

本轮最终观察到的状态是:

taskId=recon_20261001_01
scene=living_room
state=RECONSTRUCTING
progress=68
frames=128/200

如果页面数字变了但日志没变,优先检查 ArkTS 状态同步;如果日志也没有推进,再看 Native Session 和帧输入。把两层日志分开以后,定位效率比只盯着 UI 高很多。

1. 这一轮我专门做了四次反向验证

链路跑通以后,我没有只保留一次“成功截图”,而是故意把几个失败条件重新测了一遍。

第一组是不支持设备。期望结果是页面停在能力检测阶段,不创建工作目录,也不出现扫描按钮可用的假象。

第二组是重复进入页面。连续进入、退出 ScanPage 三次,业务任务 ID 保持不变,Native Session 不重复创建;页面自己的轮询每次离开都能停止。

第三组是工作目录异常。我把目标目录改成不可用路径,Manager 应该在 READY 之前失败,HiLog 记录原始错误,页面只显示“任务准备失败”,而不是进入采集页面后再报错。

第四组是任务仍在运行时返回首页。页面消失,进度轮询停止,但 Manager 仍持有任务状态。再次进入后读取的是已有 Snapshot,不会重新生成 recon_20261001_01。

这四个测试没有任何一个是在验证 3DGS 模型“好不好看”,它们验证的是工程边界。第一篇如果连这些边界都没有定下来,后面继续加热保护、保存、渲染,只会把问题越堆越深。

3. prepare() 必须是幂等的,页面重建不能重复建 Session

ArkUI 页面在真实业务里并不是“创建一次然后永远不动”。路由返回、组件重建、窗口形态变化,都可能让 aboutToAppear() 再次执行。如果 prepare() 每次都无条件进入 Native CreateSession,很快就会出现“UI 只是重新出现,底层却多建了一次会话”的问题。

所以 ReconManager 当前还维护了一个内部阶段:

EMPTY → PREPARING → READY

只有 EMPTY 才允许真正进入 Native 创建;PREPARING 时新的调用复用同一个 Promise;已经是 READY 时直接返回现有 Snapshot。这样同一时刻无论页面触发一次还是三次 prepare,底层都只有一个创建动作。

这个设计后面还会用于防止“用户连续点两次开始”。按钮层做防抖只能解决交互层重复点击,Manager 幂等才能保证业务层不会因为其他入口再次触发。对 Session 这类重资源对象,我更愿意在 Manager 里多做一道保护,而不是相信所有页面永远按预期调用。

4. 第一篇的验收标准,不是“看到 68%”这么简单

我给这一阶段定了五条验收条件:

  • 不支持设备上,不允许创建 Session;
  • 支持设备上,同一任务只能存在一个 Session;
  • 页面退出只停止 UI 订阅,不误杀运行中的任务;
  • 业务 taskId 和 Native Session 解耦;
  • 任务结束或不可恢复失败后,能够确定地执行释放。

其中任何一条做不到,后面的暂停恢复都会变得不可信。

所以 68% 只是这轮截图里的一个观察点,它证明“状态确实在往前走”,却不是最终结论。真正有价值的是从 CHECKING 到 READY、再到 RECONSTRUCTING 的每次状态变化都有日志、有唯一任务 ID、有明确所有者,也知道异常后谁来收尾。

把这些边界固定以后,下一步再处理帧质量,问题就会干净很多:如果模型结果异常,可以更确定地把注意力放到输入,而不是继续怀疑 Session 是否被重复创建、页面是否提前释放了资源。

七、第一条链路跑通后,新的问题反而更明显

现在 ReconRoom 已经能从支持检测进入 Session,再把状态推到重建阶段。运行结果如下:

这张图里几组数据和 DevEco 调试保持一致:

  • 任务:recon_20261001_01
  • 场景:living_room
  • 状态:RECONSTRUCTING
  • 进度:68%
  • 采集帧:128 / 200

这一步完成以后,我没有急着做结果渲染。实际连续扫两轮后,模型质量差异很明显:有一轮帧数更多,结果却更差。回看日志才发现,采集阶段塞进去了很多位姿变化很小的相似帧。

也就是说,链路已经通了,下一阶段真正该解决的不是“再多采几张”,而是 哪些帧应该进入 Session。

下一轮会继续沿用 ReconRoom,把相机内参、位姿变化和帧队列放到采集链路前面,先过滤尺寸不对、变化太小、重复度高的输入,再看重建质量是否稳定。

参考资料

  • HarmonyOS 7 空间计算能力:https://developer.huawei.com/consumer/cn/features/spatialization
  • Spatial Recon Kit 3DGS 端侧重建介绍: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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐