HarmonyOS 7 Spatial Recon Kit 开发实录 03:重建进度、暂停恢复与前后台状态机【鸿蒙心迹】
前两轮把 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
更多推荐




所有评论(0)