重建 session 生命周期:单例约束与并发冲突

同一时刻只允许一个重建 session。这条约束写在文档里很不起眼,但商品列表快速切换两个 3DGS 场景时,第二个 session 创建直接失败,错误码 SESSION_ALREADY_EXISTS。我们一开始没当回事,结果多场景切换页上线第一天就收到崩溃堆栈。session 生命周期的坑比设备支持更隐蔽——查询都过了,创建却失败。

能力面:session API 与单例约束

Spatial Recon Kit 的重建 session 通过 spatialRecon.createReconSession(config) 创建,返回 ReconSession 对象,挂载采集、重建、导出三组方法。销毁走 session.destroy(),返回 Promise,异步操作。

单例约束的体现:当前 session 未销毁完成时,再次调用 createReconSession 会立即抛错,错误码集中在 SESSION_ALREADY_EXISTS(上一个还在跑)、SESSION_DESTROYING(destroy 的 Promise 还没 resolve)、INVALID_SESSION(destroy 完成后还用旧引用)、RESOURCE_BUSY(其他进程占 NPU/GPU,极少见)。

文档对单例的描述只有一句"同一时刻只允许一个重建 session"。"同一时刻"的边界是什么——是 destroy 的 Promise resolve 之后?还是底层资源真的释放完之后?文档没说。实测下来,destroy 的 Promise resolve 之后,再创建新 session 仍然有约 80-200ms 的窗口期会失败。底层 NPU 上下文还在清理,但 JS 侧已经认为销毁完成了。

状态流转画成状态机就一眼看清:任何时刻只有一个合法状态,跨状态调用直接拒绝。

switchTo 发起创建

createReconSession 成功

createReconSession 失败

新 switchTo 或 destroy

destroy resolve 加保护延迟

引用作废必须回 IDLE

非 IDLE 时新请求覆盖 pending

IDLE

CREATING

ACTIVE

ERROR

DESTROYING

最容易被忽略的是 ERROR 回到 IDLE 那条边,漏了它 manager 会死锁。

约束面:销毁的异步延迟

session 销毁不是一步到位。从调用 destroy() 到底层资源真正可再用,中间有 JS 释放引用、通知 native 停任务、NPU 上下文清理、GPU 资源归还、后台 GC 几段。Promise resolve 在"GPU 资源归还"之后,但"后台 GC"之前。

也就是说 resolve 之后立刻创建新 session,理论上资源已经够用,但实测仍有失败 case。我们怀疑是底层 NPU driver 的内部队列还没清空,但没拿到源码确认。销毁后立即创建新 session 报错,这是我们踩的第一个坑。修复方式是给保护性延迟,太短没用,太长用户感知卡顿。

场景落地:商品列表快速切换

商品展示页:列表里 6 个商品,每个对应一个 3DGS 场景。用户点 A 进入预览,看两眼切到 B,再切到 C。理想情况下切换是即时的,实际每次切换都要销毁旧 session、创建新 session。

我们在 Mate 90(9030)上测了快速切换连续 10 次,10 次里 3 次冲突,冲突率 30%。冲突后走重试逻辑,重试间隔 50ms,最多重试 5 次。重试成功后总延迟拉到 300ms 左右,用户能感知到"切的时候偶尔顿一下"。不冲突的切换总延迟在 170-215ms,冲突的拉到 295-348ms。

踩坑:状态机没设计好导致状态混乱

第一版我们写了简单封装:销毁旧的,立刻创建新的,失败就重试。结果快速切换场景下出现更恶性的问题——用户连点 A→B→C 三次,A 的销毁还没完,B 的创建失败重试中,C 又来了。最后 session 引用乱掉,UI 显示的是 C 但实际跑的是 B 的重建结果。

修复方案是引入一个严格的状态机,所有 session 操作都走状态机,不允许跨状态调用。状态机如下:

IDLE → CREATING → ACTIVE → DESTROYING → IDLE
                ↘ ERROR → IDLE

新请求来了,如果当前状态不是 IDLE,要么排队要么取消上一个操作。我们选了取消——快速切换场景下,用户要看的是最后一个,中间的请求没意义。

// entry/src/main/ets/recon/SessionManager.ets
import { spatialRecon, ReconSession, ReconConfig } from '@kit.SpatialReconKit';

enum SessionState { IDLE, CREATING, ACTIVE, DESTROYING }

export class SessionManager {
  private state: SessionState = SessionState.IDLE;
  private current: ReconSession | null = null;
  private pendingConfig: ReconConfig | null = null;

  // 外部调用:切换到新场景
  async switchTo(config: ReconConfig): Promise<ReconSession> {
    if (this.state !== SessionState.IDLE) {
      this.pendingConfig = config;        // 只保留最后一个
      await this.drainPending();
      return this.switchTo(config);
    }
    return this.doCreate(config);
  }

  private async doCreate(config: ReconConfig): Promise<ReconSession> {
    this.state = SessionState.CREATING;
    try {
      const session = await spatialRecon.createReconSession(config);
      this.current = session;
      this.state = SessionState.ACTIVE;
      return session;
    } catch (e) {
      this.state = SessionState.IDLE;     // 失败必须回 IDLE,否则死锁
      throw e;
    }
  }

  private async destroyCurrent(): Promise<void> {
    if (this.current === null || this.state === SessionState.DESTROYING) return;
    this.state = SessionState.DESTROYING;
    const session = this.current;
    this.current = null;
    try {
      await session.destroy();
    } finally {
      await this.delay(120);              // 保护延迟,避开 NPU 清理窗口
      this.state = SessionState.IDLE;
    }
  }

  private async drainPending(): Promise<void> {
    const pending = this.pendingConfig;
    if (pending === null) return;
    this.pendingConfig = null;
    await this.destroyCurrent();
    await this.doCreate(pending);
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

这段代码的 120ms 保护延迟是实测出来的。试过 50/80/120/200ms 四档,50ms 仍有约 5% 冲突率,80ms 降到 1.2%,120ms 在 200 次切换里 0 次冲突,200ms 也是 0 但用户感知卡顿。120ms 是我们这台机器上的甜点值,换设备可能要调。

被放弃的方案:我们试过纯 Promise 链不加状态机,结果快速切换下仍出现状态混乱,因为 Promise 链没法取消中间的请求。状态机是必须的。

session 状态机

IDLE ──switchTo──> CREATING ──ok──> ACTIVE ──switchTo──> DESTROYING ──destroy.resolve+120ms──> IDLE
                     │                                                                  ▲
                     └──fail──> ERROR ───────────────────────────────────────────────────┘

状态机里最容易被忽略的是 ERROR 分支。createReconSession 失败时,session 引用无效,但状态必须回 IDLE,否则后续所有 switchTo 都被卡在非 IDLE 状态。我们第一版漏了这个分支,结果一次创建失败后整个 manager 死锁。

真机数据:销毁与创建耗时分布

Mate 90(9030)上跑 100 次"创建-销毁-创建"循环,统计耗时分布:

操作p50p90p99max
createReconSession38ms52ms78ms124ms
session.destroy()142ms188ms245ms312ms
destroy resolve 后到可再创建78ms165ms230ms295ms

第三行是关键——destroy 的 Promise resolve 之后,到下一次 createReconSession 不报错,中间还要等 78-230ms。这个时间没在文档里写明,是实测出来的。

不同设备上保护延迟的甜点值也不同:9030 Pro 上 80ms 够用,9030 要 120ms,9020 要 200ms,中端机 7 系列要 350ms。中端机保护延迟比销毁本身还长,底层资源清理更慢,UI 要给"切换中…"提示,否则用户以为卡死。

几个边界 case

  • 重建进行中调用 destroy:先中断重建再清理,耗时比空闲 destroy 多 200-400ms。中断中又触发 switchTo,pending 被覆盖,旧中断结果丢弃——这是我们想要的行为。
  • 应用退后台再回前台:session 不自动销毁,但 NPU 资源可能被系统回收,回前台后第一次操作可能报 INVALID_SESSION,强制走 destroyCurrent + 重新创建。
  • 重建完成的 session 一直不 destroy:内存不释放,长期占用 1.5-2GB,加了 5 分钟空闲自动销毁兜底。

小总结

  • session 是单例,destroy 是异步,resolve 之后仍有资源清理窗口
  • 销毁后创建需要保护延迟,时长按设备实测,120ms 在 9030 上够用
  • 快速切换场景必须用状态机,纯 Promise 链无法处理取消
  • createReconSession 失败时状态必须回 IDLE,否则 manager 死锁
  • 应用切后台回前台要处理 INVALID_SESSION,强制重建 session
  • 长期不用的 session 要自动销毁,否则内存常驻

session 创建销毁跑通后,下一步压一下快速切换的并发场景:写个脚本每 200ms 触发一次 switchTo,跑 100 次看冲突率和状态是否一致。我们这套状态机在 200 次/200ms 间隔下稳定,但间隔拉到 100ms 仍会偶发状态错乱,那个边界我们还没完全解决。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐