前两轮把 ReconRoom 的两件基础工作收住了:Session 不再跟页面生命周期绑死,帧输入也不再是“相机来一张就塞一张”。真正开始连续扫房间以后,新的问题出现在用户操作上——按一下暂停、切到后台、再回来,任务到底应该处于什么状态?

我最开始把暂停理解成一个布尔值:isPaused = true。这在按钮点击时没问题,遇到前后台切换以后马上失控。用户手动暂停、应用退后台自动暂停、相机临时不可用,这三件事都叫“暂停”,但恢复条件完全不同。

所以这一轮没有继续加新的重建 API,而是先把 ReconRoom 的任务状态收成一个明确的状态机。测试任务固定为 recon_20261002_03,目标帧数 620。最终验证时页面已经完成一次“前台采集 → 后台暂停 → 回前台恢复”,当前重新进入 CAPTURING,进度 68%,已采集 428 / 620 帧,待处理队列 6 / 12,采样间隔 200 ms,恢复次数 1,最近检查点为 cp_20261002_03_01。

一、暂停不是一个布尔值,而是一段状态转换

第一版只有:

CAPTURING
PAUSED

但只要把真实操作放进来,就会发现还缺“谁触发暂停”和“能不能恢复”。

比如用户主动点暂停以后,即使应用重新回到前台,也不应该自动继续采集;而应用因为切后台触发的暂停,回前台以后,在相机和任务条件都正常时可以自动恢复。

我最后把任务阶段拆成:

IDLE
PREPARING
READY
CAPTURING
PAUSE_REQUESTED
PAUSED_BY_USER
PAUSED_BY_BACKGROUND
RESUMING
RECONSTRUCTING
COMPLETED
FAILED

同时再维护一个独立的应用可见状态:

FOREGROUND
BACKGROUND

这两个维度不能合并。PAUSED_BY_BACKGROUND 是任务状态,BACKGROUND 是应用状态;应用已经回到前台时,任务仍然可能保持用户主动暂停。

这次调整最大的价值,不是枚举变多,而是所有恢复动作都有前置条件。页面不再自己猜“现在是不是可以继续”。

二、先把状态转换规则收进 ReconTaskState

这一段代码解决的是“多个页面和生命周期事件都能改状态,最后谁覆盖谁”的问题。

我新增了 model/ReconTaskState.ets,页面只能发事件,真正的状态变化统一由 reducer 处理:

export type ReconPhase =
  'IDLE' | 'PREPARING' | 'READY' |
  'CAPTURING' | 'PAUSE_REQUESTED' |
  'PAUSED_BY_USER' | 'PAUSED_BY_BACKGROUND' |
  'RESUMING' | 'RECONSTRUCTING' |
  'COMPLETED' | 'FAILED'

export type ReconEvent =
  'START_CAPTURE' |
  'USER_PAUSE' |
  'APP_BACKGROUND' |
  'APP_FOREGROUND' |
  'RESUME_OK' |
  'START_RECON' |
  'COMPLETE' |
  'FAIL'

export interface ReconRuntimeState {
  phase: ReconPhase
  appVisible: boolean
  resumeCount: number
}

export function reduceReconState(
  current: ReconRuntimeState,
  event: ReconEvent
): ReconRuntimeState {
  switch (event) {
    case 'START_CAPTURE':
      return current.phase === 'READY'
        ? { ...current, phase: 'CAPTURING' }
        : current

    case 'USER_PAUSE':
      return current.phase === 'CAPTURING'
        ? { ...current, phase: 'PAUSED_BY_USER' }
        : current

    case 'APP_BACKGROUND':
      if (current.phase === 'CAPTURING') {
        return {
          ...current,
          appVisible: false,
          phase: 'PAUSED_BY_BACKGROUND'
        }
      }
      return { ...current, appVisible: false }

    case 'APP_FOREGROUND':
      return { ...current, appVisible: true }

    case 'RESUME_OK':
      return {
        ...current,
        phase: 'CAPTURING',
        resumeCount: current.resumeCount + 1
      }

    default:
      return current
  }
}

这里没有让 APP_FOREGROUND 直接把状态改回 CAPTURING。回到前台只说明 UI 可见了,并不代表相机已经重新可用、FrameQueue 已经准备好、业务任务也仍然允许继续。

真正恢复要经过 RESUMING,条件满足以后再发 RESUME_OK。

这样做以后,用户手动暂停和后台暂停终于不会互相覆盖。PAUSED_BY_USER 回前台仍然保持暂停;只有 PAUSED_BY_BACKGROUND 才进入自动恢复判断。

三、ReconRoom 的“暂停”是业务层暂停,不伪造 Native Pause API

这里我特意把边界写清楚。

ReconRoom 当前说的“暂停采集”,不是调用一个并不存在于本文代码里的 HMS_SpatialRecon_PauseSession()。当前实现是在 帧进入重建会话之前 暂停上游采集和入队:停止相机帧采样、禁止 FrameQueue 接受新包、等现有队列排空,然后写检查点。

如果已经进入真正的 RECONSTRUCTING 阶段,页面离开只停止 UI 刷新和观察者;本文不把后台运行能力包装成“系统会一直帮我重建”。应用进入后台后的执行条件仍然要遵守 HarmonyOS 的后台任务和资源管理规则,不能靠页面计时器保活。

这一段解决的是“暂停时队列里还有帧继续往 Native 送”的问题:

export class ReconManager {
  private acceptingFrames: boolean = true
  private state: ReconRuntimeState = {
    phase: 'READY',
    appVisible: true,
    resumeCount: 0
  }

  async pauseCapture(
    reason: 'USER' | 'BACKGROUND'
  ): Promise<void> {
    if (this.state.phase !== 'CAPTURING') {
      return
    }

    this.state.phase = 'PAUSE_REQUESTED'
    this.acceptingFrames = false

    await this.frameCollector.stop()
    await this.frameQueue.waitUntilIdle()

    const checkpointId =
      await this.checkpointStore.save(this.snapshot())

    this.state.phase = reason === 'USER'
      ? 'PAUSED_BY_USER'
      : 'PAUSED_BY_BACKGROUND'

    this.hilog.info(
      `pause done reason=${reason}, checkpoint=${checkpointId}`
    )
  }

  async resumeFromBackground(): Promise<void> {
    if (this.state.phase !== 'PAUSED_BY_BACKGROUND') {
      return
    }

    this.state.phase = 'RESUMING'

    await this.cameraController.ensureReady()
    await this.frameQueue.ensureWritable()

    this.acceptingFrames = true
    await this.frameCollector.start(200)

    this.state = {
      ...this.state,
      phase: 'CAPTURING',
      resumeCount: this.state.resumeCount + 1
    }
  }
}

有两个细节我后来才补上。

一个是先把 acceptingFrames 关掉,再停 Collector。因为相机回调和暂停按钮不一定发生在同一个时刻,如果先等相机停,窗口里仍可能再进一两张帧。

另一个是必须等 FrameQueue 空闲再写检查点。否则 task metadata 记的是 428 帧,Native 实际只消费到 425 帧,下一次恢复就会出现状态和数据不一致。

四、页面暂停和应用退后台,走的是两条路径

用户点暂停的时候,页面可以直接调用 pauseCapture('USER')。但应用切后台时,不应该靠 ScanPage.aboutToDisappear() 猜。

原因很现实:页面离开可能是去结果页、去设置页,也可能是 UIAbility 真的进入后台。把这两种动作混在一起,切一个应用内页面就会误停任务。第三篇里我还把上一轮偏综合的 ScanPage.ets 拆成了更明确的 CameraCapturePage.ets,页面通过 ReconService.ets 这个轻量门面访问底层 ReconManager,这样相机采集 UI 和任务状态机的职责更容易分开。

所以我把应用前后台事件放到了 EntryAbility.ets,再通过一个 App 级桥接对象通知 ReconManager:

import { UIAbility } from '@kit.AbilityKit'
import { ReconRuntime } from '../common/ReconRuntime'

export default class EntryAbility extends UIAbility {
  onForeground(): void {
    ReconRuntime.shared().onAppForeground()
  }

  onBackground(): void {
    ReconRuntime.shared().onAppBackground()
  }
}

ReconRuntime 里不直接改 UI,它只看当前任务状态:

export class ReconRuntime {
  private static instance: ReconRuntime = new ReconRuntime()

  static shared(): ReconRuntime {
    return ReconRuntime.instance
  }

  async onAppBackground(): Promise<void> {
    const manager = ReconManager.shared()
    if (manager.getPhase() === 'CAPTURING') {
      await manager.pauseCapture('BACKGROUND')
    }
  }

  async onAppForeground(): Promise<void> {
    const manager = ReconManager.shared()
    if (manager.getPhase() === 'PAUSED_BY_BACKGROUND') {
      await manager.resumeFromBackground()
    }
  }
}

这套结构还有一个好处:以后做多页面、多窗口时,业务任务仍然只有一个生命周期入口,不需要每个页面都复制一套前后台判断。

五、进度也要分成“采集进度”和“重建进度”

前两篇里页面上的百分比主要为了观察采集链路,这次我把它正式拆成两个字段:

captureProgress
reconstructProgress

428 / 620 = 69% 左右,但当前 UI 显示 68%,因为采集进度不是简单拿帧数除目标帧数,而是按“有效关键帧 + 覆盖区域”计算的应用侧指标。帧数只是其中一个输入。

这个区分能避免后面一个很容易出现的误解:当采集完成切到 RECONSTRUCTING 时,页面进度突然从 100% 变回 0%。

现在页面会明确显示当前阶段:

CAPTURING 68%
RECONSTRUCTING 0% → 100%

两种进度互不覆盖。

为了减少 UI 刷新,我仍然保留上一轮 300 ms 左右的观察节奏;相机帧采样间隔是 200 ms,两者不是一回事。前者是状态显示频率,后者是 ReconRoom 当前的帧采样策略。

六、检查点不保存 Native 指针,只保存可恢复事实

这次新增的 TaskCheckpointStore.ets 只保存业务数据:

taskId=recon_20261002_03
phase=PAUSED_BY_BACKGROUND
acceptedFrames=428
targetFrames=620
queueSize=0
sampleInterval=200
resumeCount=0
checkpoint=cp_20261002_03_01

没有 Session 指针,也没有页面对象。

检查点的作用不是让一个 Native 句柄“跨进程复活”,而是告诉后续恢复逻辑:任务做到哪里、哪些帧已经确认落盘、上一次为什么停、接下来应该从哪个阶段继续。

这也是为什么暂停时要等队列 drain 完。检查点一旦写入,就必须代表一个稳定边界。

七、DevEco 里这次重点盯三条日志

工程结构到第三篇已经从最开始的页面 + Manager,长成了:

model/
  CameraPose.ets
  ReconTaskState.ets

manager/
  ReconManager.ets
  FrameQualityGate.ets
  FrameQueueManager.ets
  TaskCheckpointStore.ets

pages/
  CameraCapturePage.ets
  ReconstructionPage.ets

services/
  ReconService.ets
  CameraService.ets

common/
  ReconRuntime.ets

这次调试时我只抓三类日志:

app lifecycle
task phase
checkpoint

一次完整的后台恢复链路是:

CAPTURING
→ APP_BACKGROUND
→ PAUSE_REQUESTED
→ checkpoint saved
→ PAUSED_BY_BACKGROUND
→ APP_FOREGROUND
→ RESUMING
→ CAPTURING

截图里的任务仍然是 recon_20261002_03。恢复完成以后页面显示 428 / 620 帧、68%,这不是重新创建了一个新业务任务,而是在原任务上继续。

模拟器继续只用于验证 ArkUI 状态和页面布局;Spatial Recon Kit 的真实 Native 重建能力、相机帧和设备行为仍然应该在支持的真机环境确认。

八、我专门测了四种“看起来很像”的暂停

第一种是用户主动暂停。结果应该停在 PAUSED_BY_USER,切后台再回来也不能自己恢复。

第二种是采集中直接回桌面。系统生命周期进入后台后,ReconRoom 停止帧输入并生成检查点;回前台以后进入 RESUMING,相机和队列准备成功才继续。

第三种是暂停过程中再次收到后台事件。因为已经不在 CAPTURING,Manager 会忽略重复暂停,不再生成第二个检查点。

第四种是任务已经进入 RECONSTRUCTING 后切后台。这个阶段不再执行“暂停采集”,页面只断开 UI 轮询;回前台以后重新查询任务状态。是否需要支持更长时间的后台执行,要按实际业务选择官方后台任务方案,不能把“离开页面”和“系统允许长时间后台运行”混为一谈。

最终手机页的状态是:

本轮统一数据:

taskId=recon_20261002_03
app=FOREGROUND
phase=CAPTURING
captureProgress=68%
frames=428/620
queue=6/12
sampleInterval=200ms
resumeCount=1
checkpoint=cp_20261002_03_01

实际用下来,这一轮最重要的不是加了“暂停”按钮,而是任务终于和页面解耦了。

用户做什么、应用处在哪个生命周期、重建任务当前处在哪个业务阶段,这三件事现在分别有自己的状态,不再互相覆盖。下一轮会继续处理长时间采集更容易碰到的问题:Camera Kit 系统压力变化、SEVERE / CRITICAL 级别保护,以及真正发生异常中断以后如何从检查点恢复,而不是简单把任务重新从 0 开始。

九、真正难排查的是暂停过程中的竞态,不是按钮本身

把流程拆开以后,我又专门压了一轮竞态。最常见的情况是用户刚点“暂停”,FrameCollector 已经拿到下一帧回调;这张帧通过了 FrameQualityGate,但还没进入队列。此时如果只看 frameQueue.isEmpty(),会误以为整个采集链已经安静下来。

所以现在暂停入口一进来先关闭 acceptingFrames。任何已经在回调中的帧,在入队前都会再看一次这个开关。也就是说,暂停动作和“是否允许新帧进入系统”的判断在同一个 Manager 里收口。

第二个竞态来自重复恢复。onForeground()、页面重新订阅、相机 ready 回调可能在很短时间里连续到达。如果三条路径都调用 resumeFromBackground(),相机采样器会被启动多次。我的处理不是给按钮加防抖,而是在 Manager 里维护 resumePromise:只要当前已经进入 RESUMING,后续恢复请求直接复用正在执行的 Promise。

第三个竞态来自“恢复中再次退后台”。这是实际测试里最容易忽略的边缘。当状态刚从 PAUSED_BY_BACKGROUND 进入 RESUMING,相机还没 ready,用户又切出应用,这时不能等恢复成功再暂停一次。我给恢复流程增加了 expectedVisibility 检查:每完成一个异步步骤,都确认应用仍然在前台;如果已经回到后台,就终止后续启动并保持暂停态。

这些判断写起来没有很炫的 API,却决定了状态机是不是真的可用。单次 happy path 只会看到“暂停、恢复成功”,连续快速切换前后台才会把边界暴露出来。

十、Snapshot 要不可变,不让 UI 反过来影响任务

前两篇里 getSnapshot() 已经返回对象副本,这一轮我把这个约束继续强化。ReconManager 内部保存可变状态,UI 拿到的永远是只读快照。

export interface ReconSnapshot {
  readonly taskId: string
  readonly phase: ReconPhase
  readonly appVisible: boolean
  readonly captureProgress: number
  readonly capturedFrames: number
  readonly targetFrames: number
  readonly queueSize: number
  readonly resumeCount: number
  readonly checkpointId: string
}

getSnapshot(): ReconSnapshot {
  return {
    taskId: this.taskId,
    phase: this.state.phase,
    appVisible: this.state.appVisible,
    captureProgress: this.captureProgress,
    capturedFrames: this.capturedFrames,
    targetFrames: 620,
    queueSize: this.frameQueue.size(),
    resumeCount: this.state.resumeCount,
    checkpointId: this.lastCheckpointId
  }
}

页面可以展示 68%,却不能通过改一个绑定变量把 Manager 的进度改掉。这个区别在 ArkUI 状态很多时非常重要,否则“界面状态”和“任务事实”会慢慢混成一套对象。

我还把快照版本号加进日志。每次状态真正变化才递增 snapshotVersion,纯 UI 重绘不会影响它。这样看 HiLog 时,如果连续出现同一个版本,就知道只是页面重复刷新;如果版本变化但 phase 没变,则说明帧数、队列或进度发生了更新。

十一、后台策略必须承认系统约束,不能把 Worker 当保活开关

HarmonyOS 官方对长耗时和常驻任务有单独的并发与后台规范。Worker 适合把持续计算从 UI 主线程移出去,避免页面渲染被阻塞,但它不是“应用退后台后无限执行”的通行证。

ReconRoom 这一轮刻意做得保守:采集阶段进入后台就暂停相机输入;已经进入 Native 重建阶段时,不在页面层强行创建新的计时器保活,只记录最后可见状态,回前台后重新查询。这样虽然没有追求“后台也一直跑”,但状态语义是可控的。

如果未来产品明确要求锁屏或长时间退后台仍继续某类业务,就应该根据对应场景使用系统允许的后台任务机制,并重新评估相机、功耗、用户可感知提示以及失败恢复,而不是把这一篇的前后台状态机简单改成 BACKGROUND -> KEEP_RUNNING。

我更愿意先把“不能保证的事情”写清楚。对于重建任务来说,能恢复通常比假设永不中断更重要。

十二、这一轮的回归测试我固定成六条

为了后面继续改代码不把暂停逻辑弄坏,我把测试固定下来。

第一条,READY 状态点暂停无效,不能生成检查点。第二条,CAPTURING 手动暂停后必须进入 PAUSED_BY_USER,回前台不能自动恢复。第三条,采集中切后台必须生成一个且只有一个 cp_20261002_03_01。第四条,后台暂停后回前台,恢复次数从 0 变 1,业务 taskId 保持 recon_20261002_03。第五条,快速“前台—后台—前台—后台”循环时,不允许出现两个 FrameCollector 同时工作。第六条,任务进入 RECONSTRUCTING 后页面退出,不能错误回退成 PAUSED_BY_BACKGROUND。

这六条都通过以后,我才把 03 的状态图定下来。

现在再看手机页里的 FOREGROUND / CAPTURING / resumeCount=1,它们不再只是为了截图好看,而是可以从日志、检查点和 Manager 状态一路对得上的结果。

参考资料

  • Spatial Recon Kit 3DGS 端侧重建 Pipeline:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline
  • HarmonyOS Stage 模型与 Ability 生命周期:https://developer.huawei.com/consumer/cn/arkui/arkui-stage
  • ArkTS 常驻任务并发场景:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/resident-task-overview
Logo

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

更多推荐