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.2s23%12-18 次200-300ms
点云初始化1.8s13%4-6 次300-400ms
高斯优化9.1s64%30-50 次200-300ms
总计14.1s100%46-74 次—

数据在麒麟 9030 上跑的,重建素材是一只陶瓷摆件,120 帧环绕拍摄。耗时波动 ±2 秒,跟素材复杂度和后台负载有关。

高斯优化占了 64% 的耗时,这是进度条设计的关键。如果三个阶段在进度条上平均分配(各占 33%),用户会在 36% 处卡很久——因为特征提取和初始化很快跑完,然后高斯优化要跑 9 秒,进度条从 36% 爬到 100% 很慢。

按实际耗时占比分配进度条段位,特征提取占 0-23%,初始化占 23-36%,优化占 36-100%。这样进度条的推进速度大致均匀。

三个阶段从发起到收尾,回调怎么穿到 UI 上,用一张时序图说明:

SpatialReconKit引擎重建子线程UI主线程SpatialReconKit引擎重建子线程UI主线程发起重建任务createReconSession回调 feature_extraction 进度写 AppStorage, 更新进度条回调 initialization 进度更新阶段说明文字回调 optimization 进度更新百分比与预估剩余时间重建完成收尾, 关闭心跳动画

时序里两个关键点:进度全部经 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 更新帧率用户感知评分
麒麟 903014.2s58 次1.2s4.2 fps3.8/5
麒麟 9030 Pro10.1s52 次0.8s5.1 fps4.2/5
麒麟 902022.5s72 次2.1s3.5 fps2.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 秒更差,内测时转圈版本的退出率更高。

进度条不完美,但比替代方案好。我们的目标是"让用户愿意等完",不是"精确反映进度"。

总结一下下

  1. 进度条按阶段实际耗时占比分配段位,不要平均分
  2. 高斯优化占 60%+ 耗时,是进度条设计的重点
  3. 回调间隔可能超过 1 秒,加心跳动画防止"以为卡死"
  4. 预估剩余时间用指数移动平均平滑,避免跳变
  5. 进度回调在子线程,用 AppStorage 跨线程更新 UI
  6. 必须处理失败回调,失败时进度条变红显示原因
  7. 同一时刻只允许一个重建 session,重复触发要拦截
  8. 进度体验跟芯片强相关,低端芯片上 UI 优化收益有限
Logo

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

更多推荐