【共创稿事节】HarmonyOS 7重建进度反馈 UI 的工程实现
3DGS 重建耗时 10-15 秒,这个时间放在端侧交互里很长。没有进度反馈的话,用户以为应用卡死了,要么狂点屏幕要么直接退。我们第一版没做进度条,内测时 3 个用户里有 2 个在重建过程中尝试退出。第二版加了进度条,但进度回调不均匀——前 80% 秒过完,后面卡 5 秒不动,用户以为是假进度。这篇文章记录我们怎么把进度反馈做到让用户信。
能力面:进度回调 API 与阶段划分
Spatial Recon Kit 在 API 26 提供了进度回调接口。重建过程内部分三个阶段:特征提取、点云初始化、高斯优化。每个阶段有独立的进度回调。
// ArkTS:进度回调注册
import { spatialRecon } from '@kit.SpatialReconKit';
const session = await spatialRecon.createReconSession({
inputUri: 'file:///data/capture/subject.mp4',
outputDir: 'file:///data/output/',
progressCallback: (progress: spatialRecon.ReconProgress) => {
// progress.stage: 'feature_extraction' | 'initialization' | 'optimization'
// progress.percent: 0-100(当前阶段内进度)
// progress.elapsedMs: 当前阶段已耗时
console.log(`阶段: ${progress.stage}, 进度: ${progress.percent}%`);
}
});
三个阶段的耗时占比不是均匀的。我们测了一组数据:
| 阶段 | 平均耗时 | 占比 | 回调次数 | 回调间隔 |
|---|---|---|---|---|
| 特征提取 | 3.2s | 23% | 12-18 次 | 200-300ms |
| 点云初始化 | 1.8s | 13% | 4-6 次 | 300-400ms |
| 高斯优化 | 9.1s | 64% | 30-50 次 | 200-300ms |
| 总计 | 14.1s | 100% | 46-74 次 | — |
数据在麒麟 9030 上跑的,重建素材是一只陶瓷摆件,120 帧环绕拍摄。耗时波动 ±2 秒,跟素材复杂度和后台负载有关。
高斯优化占了 64% 的耗时,这是进度条设计的关键。如果三个阶段在进度条上平均分配(各占 33%),用户会在 36% 处卡很久——因为特征提取和初始化很快跑完,然后高斯优化要跑 9 秒,进度条从 36% 爬到 100% 很慢。
按实际耗时占比分配进度条段位,特征提取占 0-23%,初始化占 23-36%,优化占 36-100%。这样进度条的推进速度大致均匀。
三个阶段从发起到收尾,回调怎么穿到 UI 上,用一张时序图说明:
时序里两个关键点:进度全部经 AppStorage 跨线程传递,UI 收尾要等完成回调而不是最后一个进度。
约束面:回调频率与进度偏差
回调频率不固定
上表里回调间隔 200-400ms,看着挺均匀。实际跑起来不是这样——特征提取阶段回调密集(200ms 一次),高斯优化阶段回调稀疏且不均匀。高斯优化内部是迭代式的,每轮迭代完才回调一次,迭代耗时随轮次变化。
我们记录了一次重建的回调时间戳,前 5 秒回调了 22 次,第 5-10 秒回调了 8 次,第 10-14 秒回调了 16 次。中间有一段 1.2 秒没有回调,UI 进度条停在那不动,用户以为卡了。
进度百分比跟实际进度有偏差
progress.percent 是当前阶段内的百分比。但"百分比"的定义是模糊的——特征提取阶段,percent 跟已处理帧数成正比,比较准。高斯优化阶段,percent 跟迭代轮次成正比,但每轮迭代的实际工作量不同,前期迭代快后期慢。
结果是高斯优化阶段,percent 从 0 到 50 跑了 3 秒,从 50 到 100 跑了 6 秒。进度条前半段快后半段慢,用户感觉"假进度"——以为应用在假装有进度,实际在跑一个不可中断的长任务。
回调在子线程,UI 更新要切主线程
进度回调在重建子线程触发,直接更新 ArkUI 组件会报线程错误。需要切到主线程更新。
// ArkTS:子线程进度回调切主线程更新 UI
import { taskpool } from '@kit.ArkTS';
// 进度状态用 AppStorage 跨线程共享
@StorageLink('reconProgress') reconProgress: number = 0;
@StorageLink('reconStage') reconStage: string = '';
// 重建任务在 taskpool 里跑
@Concurrent
function runRecon(inputUri: string): void {
spatialRecon.createReconSession({
inputUri: inputUri,
progressCallback: (progress) => {
// 子线程里更新 AppStorage,主线程的 UI 自动响应
AppStorage.setOrCreate('reconStage', progress.stage);
AppStorage.setOrCreate('reconProgress', progress.percent);
}
});
}
// UI 组件
@Component
struct ReconProgressBar {
@StorageLink('reconProgress') percent: number = 0;
@StorageLink('reconStage') stage: string = '';
build() {
Column() {
Progress({ value: this.percent, total: 100, type: ProgressType.Linear })
.width('80%')
.color(this.stageColor())
Text(this.stageText())
.fontSize(14)
}
}
private stageText(): string {
switch (this.stage) {
case 'feature_extraction': return '提取特征点';
case 'initialization': return '初始化点云';
case 'optimization': return '优化高斯点';
default: return '准备中';
}
}
private stageColor(): ResourceColor {
switch (this.stage) {
case 'feature_extraction': return '#4A90D9';
case 'initialization': return '#F5A623';
case 'optimization': return '#7ED321';
default: return '#9B9B9B';
}
}
}
AppStorage 跨线程安全,子线程写主线程读没问题。用 @StorageLink 绑定后 UI 自动更新,不用手动 setTimeout 轮询。
场景落地:三阶段进度条 UI
我们的最终方案是三段式进度条,加上预估剩余时间和阶段说明文字。
进度条分段映射
// ArkTS:阶段进度映射到总进度条
interface StageConfig {
stage: string;
startPercent: number; // 该阶段在总进度条的起始位置
endPercent: number; // 该阶段在总进度条的结束位置
label: string; // 阶段说明文字
}
const STAGE_CONFIGS: StageConfig[] = [
{ stage: 'feature_extraction', startPercent: 0, endPercent: 23, label: '提取特征点' },
{ stage: 'initialization', startPercent: 23, endPercent: 36, label: '初始化点云' },
{ stage: 'optimization', startPercent: 36, endPercent: 100, label: '优化高斯点' }
];
function mapToTotalProgress(stage: string, stagePercent: number): number {
const config = STAGE_CONFIGS.find(c => c.stage === stage);
if (!config) return 0;
const range = config.endPercent - config.startPercent;
return config.startPercent + (stagePercent / 100) * range;
}
分段比例(23/13/64)是按我们测的平均耗时定的。不同素材比例会变,但变化幅度不大——特征提取跟帧数正相关,优化跟场景复杂度正相关,比例相对稳定。
预估剩余时间
光给进度百分比不够,用户想看的是"还要等多久"。我们加了预估剩余时间,基于已耗时和当前进度反推。
// ArkTS:预估剩余时间
@Observed
class ProgressEstimator {
private stageStartTime: Map<string, number> = new Map();
private stageHistory: Map<string, number[]> = new Map(); // 历史耗时,用于跨次预估
updateProgress(stage: string, percent: number, elapsedMs: number): { estimatedRemainingMs: number } {
if (percent <= 0) {
this.stageStartTime.set(stage, Date.now());
return { estimatedRemainingMs: this.getHistoricalAvg(stage) || 0 };
}
// 基于当前进度线性外推
const totalEstimated = elapsedMs / (percent / 100);
const remaining = totalEstimated - elapsedMs;
// 平滑处理,避免预估跳变
const smoothed = this.smoothEstimate(stage, remaining);
return { estimatedRemainingMs: Math.max(0, smoothed) };
}
private getHistoricalAvg(stage: string): number {
const history = this.stageHistory.get(stage);
if (!history || history.length === 0) return 0;
return history.reduce((a, b) => a + b, 0) / history.length;
}
private smoothEstimate(stage: string, value: number): number {
// 指数移动平均,避免预估忽高忽低
const key = `smooth_${stage}`;
const prev = AppStorage.get<number>(key) || value;
const smoothed = prev * 0.7 + value * 0.3;
AppStorage.setOrCreate(key, smoothed);
return smoothed;
}
}
线性外推在高斯优化阶段不太准——前面说过优化阶段前期快后期慢,线性外推会低估剩余时间。我们用指数移动平均做平滑,至少让预估不跳来跳去。但"预估 5 秒"实际等了 8 秒的情况还是会有,用户对此有抱怨但没有"以为卡死"那么严重。
UI 更新帧率
进度回调频率 200-400ms,UI 更新跟着这个频率走,大概 3-5 帧/秒。这个更新频率对进度条够用,但数字滚动会显得卡。
我们做了个折中:进度条本身按回调频率更新(3-5 帧/秒),但百分比数字和剩余时间用 60 帧补间动画平滑过渡。视觉上数字在持续滚动,不会突然跳一下。
@Component
struct SmoothProgress {
@StorageLink('reconProgress') targetPercent: number = 0;
private displayPercent: number = 0;
build() {
Column() {
Progress({ value: this.displayPercent, total: 100, type: ProgressType.Linear })
.width('80%')
.animation({ duration: 300, curve: Curve.EaseOut })
Text(`${Math.floor(this.displayPercent)}%`)
.fontSize(16)
}
.onAppear(() => {
// 用动画补间,让进度条平滑追上目标值
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.displayPercent = this.targetPercent;
});
})
.onChange(() => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.displayPercent = this.targetPercent;
});
})
}
}
真机数据:各阶段耗时与回调实测
我们在 3 台设备上各跑 5 次重建,记录回调时间戳和 UI 帧率。
| 设备 | 平均重建耗时 | 回调总次数 | 最长回调间隔 | UI 更新帧率 | 用户感知评分 |
|---|---|---|---|---|---|
| 麒麟 9030 | 14.2s | 58 次 | 1.2s | 4.2 fps | 3.8/5 |
| 麒麟 9030 Pro | 10.1s | 52 次 | 0.8s | 5.1 fps | 4.2/5 |
| 麒麟 9020 | 22.5s | 72 次 | 2.1s | 3.5 fps | 2.9/5 |
用户感知评分是内测时 5 个用户打分取平均。9020 那台评分低,主要因为重建耗时 22 秒太长,加上最长回调间隔 2.1 秒——进度条有 2 秒不动,用户以为卡了。
9030 Pro 评分最高,重建快加上回调间隔短,体验明显好。这印证了一个判断:进度体验跟芯片强相关,低端芯片上再怎么优化 UI 也救不回底层耗时。
回调间隔分布(麒麟 9030,单次重建)
| 时间段 | 回调次数 | 平均间隔 |
|---|---|---|
| 0-3s(特征提取) | 14 次 | 214ms |
| 3-5s(初始化) | 5 次 | 400ms |
| 5-10s(优化前半) | 12 次 | 417ms |
| 10-14s(优化后半) | 27 次 | 148ms |
有意思的是优化后半段回调反而密了。原因是优化后期迭代收敛快,每轮迭代耗时短,回调频率上来。这跟我们的直觉相反——以为后期迭代慢回调会稀疏,实际是后期迭代快但轮次多,回调密。
踩坑与取舍
坑 1:前 80% 秒过完后面卡 5 秒
第一版进度条按阶段平均分配(各 33%),特征提取和初始化 5 秒跑完,进度条到 66%,然后高斯优化 9 秒从 66% 爬到 100%。用户看到的是"进度条嗖一下到 66%,然后慢慢爬",以为前 66% 是假进度。
改成按实际耗时占比分配(23/13/64)后好一些,但高斯优化阶段还是慢。后来加了预估剩余时间,用户至少知道"还要等 6 秒",比盯着进度条干等好。
坑 2:进度回调有 1.2 秒间隔,用户以为卡死
高斯优化中期有一次 1.2 秒没有回调,进度条停住。用户点了屏幕没反应(重建在子线程跑,主线程 UI 还在响应但进度条不动),以为应用卡了,有人直接退了。
解决方法是加一个"心跳"动画——进度条不动时,加一个呼吸光效或者旋转图标,告诉用户"还在跑"。技术上简单,但效果明显。加了心跳后没人再以为卡死。
@Component
struct HeartbeatIndicator {
@State opacity: number = 0.3;
build() {
// 进度条不动时显示呼吸光效
Circle()
.width(8)
.height(8)
.fill('#4A90D9')
.opacity(this.opacity)
.animation({ duration: 800, iterations: -1, curve: Curve.EaseInOut })
.onAppear(() => {
animateTo({ duration: 800, iterations: -1, curve: Curve.EaseInOut }, () => {
this.opacity = this.opacity === 0.3 ? 1.0 : 0.3;
});
})
}
}
坑 3:预估剩余时间忽高忽低
线性外推的预估剩余时间会跳。进度从 10% 到 20% 跑了 1 秒,预估剩余 9 秒;进度从 20% 到 30% 跑了 3 秒,预估剩余 21 秒。用户看到"剩余 9 秒"变成"剩余 21 秒",比没有预估还糟。
加了指数移动平均平滑后好一些,但根本问题是高斯优化阶段进度跟耗时不是线性的。我们试过用二次曲线拟合,效果略好但实现复杂,最后还是用线性加平滑,接受预估有偏差。预估偏差在 ±30% 以内用户还能接受,超过这个范围就显假了。
坑 4:重建失败时进度条卡在中间
重建有可能失败——素材质量太差、内存不足、session 冲突。失败时进度回调不再触发,进度条卡在中间,用户不知道是失败了还是还在跑。
必须监听重建失败的回调,失败时把进度条变红,显示失败原因和重试按钮。这个看起来理所当然,但我们第一版漏了失败回调,内测时重建失败的用户盯着卡住的进度条等了 30 秒才意识到不对。
// ArkTS:重建失败处理
try {
await spatialRecon.createReconSession({
inputUri: inputUri,
progressCallback: onUpdateProgress,
errorCallback: (error: spatialRecon.ReconError) => {
// 失败时更新 UI 状态
AppStorage.setOrCreate('reconStatus', 'failed');
AppStorage.setOrCreate('reconError', error.message);
// 常见错误码映射到用户可理解的提示
const hint = mapErrorToHint(error.code);
AppStorage.setOrCreate('reconHint', hint);
}
});
} catch (e) {
// 同步异常也要处理
AppStorage.setOrCreate('reconStatus', 'failed');
}
function mapErrorToHint(code: number): string {
switch (code) {
case 1001: return '素材帧数不足,请重新拍摄';
case 1002: return '内存不足,请关闭其他应用后重试';
case 1003: return '已有重建任务在运行,请等待完成';
case 1004: return '素材质量过低,特征点不足';
default: return '重建失败,请重试';
}
}
错误码 1003 就是 session 单例约束——同一时刻只允许一个重建 session,第二个会失败。这个错误在内测时出现过,用户连点两次重建按钮触发的。
取舍:进度条还是转圈
我们讨论过要不要干脆放弃进度条,改用不确定进度的转圈动画。理由是进度条总是不准,不如不给数字。
最后还是保留了进度条,原因有两个。一是进度条即使不准,也比转圈给的信息多——用户至少知道"在跑三个阶段中的第二个"。二是转圈等 14 秒的体验比进度条等 14 秒更差,内测时转圈版本的退出率更高。
进度条不完美,但比替代方案好。我们的目标是"让用户愿意等完",不是"精确反映进度"。
总结一下下
- 进度条按阶段实际耗时占比分配段位,不要平均分
- 高斯优化占 60%+ 耗时,是进度条设计的重点
- 回调间隔可能超过 1 秒,加心跳动画防止"以为卡死"
- 预估剩余时间用指数移动平均平滑,避免跳变
- 进度回调在子线程,用 AppStorage 跨线程更新 UI
- 必须处理失败回调,失败时进度条变红显示原因
- 同一时刻只允许一个重建 session,重复触发要拦截
- 进度体验跟芯片强相关,低端芯片上 UI 优化收益有限
更多推荐

所有评论(0)