HarmonyOS 7 / API 26 3DGS 重建会话卡住排查:进度、取消和后台恢复实战

HarmonyOS 7 3DGS 重建会话卡住排查

重建会话不是普通按钮点击

HarmonyOS 7 / API 26 的 3DGS 端侧重建属于很典型的长任务。它和普通页面请求不一样:普通请求失败了,大不了提示重试;重建任务一旦卡住,用户可能已经等了几十秒,设备也可能在持续发热,页面退出后还可能留下半截状态。

所以这类功能不能只写一个“开始重建”按钮。更稳的做法是把它看成一个会话:开始、运行、暂停、取消、失败、恢复、落盘,每一步都要有状态。

我遇到这类问题时,优先排查三个点:

排查点 表现 处理方向
进度是否可信 进度停在 40% 或 70% 很久不动 给阶段超时和重试入口
取消是否完整 用户退出后后台还在跑 页面销毁时取消或保存任务快照
恢复是否可控 切后台回来显示旧进度 用 sessionId 对齐当前任务

这篇只看 3DGS 重建会话本身,不讨论模型首屏黑屏,也不讨论 Spatial Recon Kit 和 ArkGraphics 3D 的职责边界。前者适合放到模型预览文章里,后者适合放到架构边界文章里。这里专门处理“重建任务为什么卡住,以及怎么把状态收回来”。

官方能力边界和版本前提

文章面向 HarmonyOS 7 / API 26。Spatial Recon Kit 负责空间重建相关能力,ArkUI 页面负责展示状态和交互,持久化层负责保存任务快照和产物路径。不要把长任务状态直接绑在一个页面组件实例上。

接入前要先确认三件事:

  1. 当前设备是否支持相关系统能力;
  2. 输入素材是否满足重建要求;
  3. 应用在后台、横竖屏切换、多窗口状态下是否能恢复会话状态。
  4. 只要这三件事没定住,后面写再多进度条都不可靠。

    案例一:进度停在 70% 不动

    第一个案例是重建进度卡住。页面上看起来还在运行,但进度长时间不变化。开发者如果只显示一个百分比,用户不知道是慢、卡住,还是失败。

    复现步骤

    1. 启动一次重建任务;
    2. 模拟某个阶段耗时过长,例如特征匹配或 3DGS 表示生成;
    3. 页面进度停在同一个值超过阈值;
    4. 如果没有阶段超时,页面会一直显示运行中;
    5. 加入阶段超时后,页面能提示“当前阶段耗时过长”,并提供取消或重试。
    6. type ReconStage = 'prepare' | 'extractFrames' | 'matchFeature' | 'buildGaussian' | 'persist' | 'done';
      
      interface ReconStageState {
        sessionId: string;
        stage: ReconStage;
        percent: number;
        startedAt: number;
        updatedAt: number;
        tips: string;
      }
      
      const STAGE_TIMEOUT: Record<ReconStage, number> = {
        prepare: 5000,
        extractFrames: 15000,
        matchFeature: 30000,
        buildGaussian: 45000,
        persist: 10000,
        done: 0
      };
      
      export class ReconProgressWatchdog {
        isTimeout(state: ReconStageState, now: number): boolean {
          const limit = STAGE_TIMEOUT[state.stage];
          if (limit <= 0) {
            return false;
          }
          return now - state.updatedAt > limit;
        }
      
        buildTimeoutTips(state: ReconStageState): string {
          return '重建任务停在 ' + state.stage + ' 阶段较久,可以先取消并保留输入素材';
        }
      }

      这里不要只看总耗时。总耗时长不一定有问题,因为素材复杂时本来就慢。真正有用的是阶段耗时:哪个阶段长时间没有更新,哪个阶段就要给用户一个明确反馈。

      页面状态要区分运行和卡住

      type ReconViewPhase = 'idle' | 'running' | 'slow' | 'failed' | 'done';
      
      interface ReconViewState {
        phase: ReconViewPhase;
        percent: number;
        primaryText: string;
        secondaryText: string;
        canCancel: boolean;
        canRetry: boolean;
      }
      
      export function mapStageToView(state: ReconStageState, timeout: boolean): ReconViewState {
        if (timeout) {
          return {
            phase: 'slow',
            percent: state.percent,
            primaryText: '重建还在处理,但当前阶段耗时偏长',
            secondaryText: state.tips,
            canCancel: true,
            canRetry: false
          };
        }
      
        return {
          phase: 'running',
          percent: state.percent,
          primaryText: '正在生成 3DGS 空间模型',
          secondaryText: state.tips,
          canCancel: true,
          canRetry: false
        };
      }

      这段映射看起来简单,但作用很大。用户看到“运行中”和“偏慢”是不一样的,开发者看到日志也能马上知道问题卡在哪个阶段。

      案例二:切后台回来后,旧任务状态覆盖新任务

      第二个案例更接近线上问题。用户开始了一次重建,切到后台,又回来重新点了一次开始。如果页面只用一个全局状态,很容易出现旧任务回调覆盖新任务的问题。

      复现步骤

      1. 第一次启动任务,生成 sessionId=A;
      2. 应用切后台,任务暂停或变慢;
      3. 用户回来后重新发起任务,生成 sessionId=B;
      4. 旧任务 A 的回调晚到;
      5. 如果不校验 sessionId,页面会被旧状态覆盖。
      6. interface ReconSessionSnapshot {
          sessionId: string;
          inputUri: string;
          stage: ReconStage;
          percent: number;
          outputUri: string;
          updatedAt: number;
        }
        
        export class ReconSessionStore {
          private currentSessionId: string = '';
          private snapshot: ReconSessionSnapshot | null = null;
        
          start(inputUri: string): ReconSessionSnapshot {
            const sessionId = 'recon-' + Date.now();
            this.currentSessionId = sessionId;
            this.snapshot = {
              sessionId,
              inputUri,
              stage: 'prepare',
              percent: 0,
              outputUri: '',
              updatedAt: Date.now()
            };
            return { ...this.snapshot };
          }
        
          acceptUpdate(next: ReconSessionSnapshot): boolean {
            if (next.sessionId !== this.currentSessionId) {
              return false;
            }
            this.snapshot = { ...next, updatedAt: Date.now() };
            return true;
          }
        
          current(): ReconSessionSnapshot | null {
            return this.snapshot ? { ...this.snapshot } : null;
          }
        
          clear(sessionId: string): void {
            if (sessionId === this.currentSessionId) {
              this.currentSessionId = '';
              this.snapshot = null;
            }
          }
        }

        这里的关键是 acceptUpdate。任何来自重建会话的回调都必须带 sessionId。只有当前会话才允许更新页面。旧任务晚回来,只记录日志,不覆盖 UI。

        取消动作要真正收尾

        重建任务取消不只是把进度条隐藏。至少要做三件事:停止当前会话、保存可恢复信息、释放临时资源。

        interface ReconCancelResult {
          sessionId: string;
          stopped: boolean;
          retainedInput: boolean;
          message: string;
        }
        
        export class ReconCancelController {
          async cancel(session: ReconSessionSnapshot | null): Promise<ReconCancelResult> {
            if (!session) {
              return { sessionId: '', stopped: true, retainedInput: false, message: '没有正在运行的重建任务' };
            }
        
            await this.stopNativeSession(session.sessionId);
            await this.keepInputForRetry(session.inputUri);
            await this.cleanTempOutput(session.outputUri);
        
            return {
              sessionId: session.sessionId,
              stopped: true,
              retainedInput: true,
              message: '已取消重建,输入素材已保留,可以稍后重试'
            };
          }
        
          private async stopNativeSession(sessionId: string): Promise<void> {
            console.info('stop recon session ' + sessionId);
          }
        
          private async keepInputForRetry(inputUri: string): Promise<void> {
            console.info('keep input ' + inputUri);
          }
        
          private async cleanTempOutput(outputUri: string): Promise<void> {
            if (outputUri.length > 0) {
              console.info('clean temp output ' + outputUri);
            }
          }
        }

        这样写的好处是,取消以后用户还能重试,开发者也知道临时产物有没有清掉。不要把取消写成“关闭弹窗”,那只是界面消失了,任务不一定停了。

        推荐的状态流

        阶段 页面表现 技术动作
        prepare 检查设备和输入素材 能力检测、文件校验
        extractFrames 显示素材解析进度 记录阶段开始时间
        matchFeature 提示空间特征匹配 阶段超时监控
        buildGaussian 显示模型生成进度 支持取消和失败恢复
        persist 保存结果 写入产物路径和封面
        done 进入预览 交给 ArkGraphics 3D 展示

        这个状态流不是为了好看,而是为了避免“任务在跑,但没人知道它跑到哪了”。3DGS 重建越耗时,状态越要细。

        最后总结

        3DGS 重建会话卡住时,不要只盯着百分比。百分比只是结果,真正要看的是阶段、更新时间、sessionId、取消动作和恢复策略。

        HarmonyOS 7 / API 26 的 3DGS 能力适合做更有空间感的体验,但接入方式不能停留在 Demo。只要涉及端侧重建,就要把它当成长任务治理:每个阶段有时间边界,每个回调带 sessionId,每次取消有收尾,每个产物能落盘。这样页面才不会在后台恢复、弱设备、复杂素材里失控。

Logo

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

更多推荐