一个3DGS预览页最容易被误判的状态,不是“加载失败”,而是“页面上出现了一个早已不属于当前选择的场景”。用户连续点了两次展厅,前一次模型大、后一次模型小,后发起的加载反而先结束。假如代码在每次 await 返回后都无条件更新当前节点,就可能把已经选好的新展厅重新盖成旧展厅。

这篇使用一个可推演的工程 Demo SceneLeaseLab,专门处理加载结果的归属问题,而不是再次讨论模型文件清单是否完整,也不重复视口瓦片缓存预算。下文的时间、请求数量和界面均为统一的模拟测试向量;没有在真机执行3DGS加载,更不能把生成的开发截图当作运行证据。

一、场景切换的难点不在按钮,而在异步返回的所有权

设想一个文化展览应用:列表页先预览 atrium_09,用户很快再次点击同一入口,紧接着切到 gallery_12。为了明确边界,本例约定三条意图分别编号 req_001、req_002、req_003,代次为1、2、3。前两条指向 atrium_09,第三条指向 gallery_12,它们都是应用自己的请求编号,并非 Spatial Recon Kit 的任务标识。

业务层只认最新选择。即使旧 Promise 晚到了,也不能替用户撤销第三次选择。可是“忽略一个回调”和“取消一个底层加载”是两回事。前者是应用层容易实施的安全门禁;后者必须看具体API有没有取消接口、调用后是否还能返回,以及对象是否已经挂接到渲染场景。官方 GSPlugin.loadGSNode(scene, params, parent?) 返回 Promise<GSNode>,我们不能在没有文档证据时声称它自动取消,也不能把 return 理解成已经释放了节点占用的资源。

这个差异会决定技术方案。传统写法在按钮上简单加 loading=true,确实能阻止用户重复点击,却也会阻止合法的新场景选择;另一种写法粗暴地清空页面,能保持画面干净,却可能让正在等待的节点成为不可追踪的后台资源。这里选择允许最新意图替换旧意图,同时为每一个完成的对象明确归属。旧对象不准晋升为可见对象,并进入待清理清单;只有收到具体资源桥接层的释放确认,才能把它从清单删除。

本例的可核对数据约定为:任务 GS-1010-09,请求3次,接纳1次,旧结果忽略2次,最新加载代次3,可见场景 gallery_12,模型 gallery_12.glb,状态 LEASE_ACTIVE。在没有实际调用可靠清理适配器之前,待清理数量仍是2,释放确认是0。这一故意保留的红色状态,比虚构“资源已经安全释放”更符合工程判断。

二、把系统加载能力和业务租约拆开

Spatial Recon Kit 的 spatialRender 提供3DGS渲染相关能力,官方文档说明 GSPlugin 要在加载节点之前装载对应插件。Scene 与 RenderContext 属于 ArkGraphics 3D,GSNode 才是加载出的渲染对象。这里没有自创 startReconstruction、cancelLoad 或 destroyGSNode 之类接口。构建和渲染属于设备及工程环境的能力,业务代次则是我们自己的控制层。

先解决“如何明确建立一个加载请求”的问题。下面的函数仅展示经官方材料可核对的装载路径:检查渲染上下文、装载插件、创建Scene、使用本地实际存在的模型URI加载节点。示例 URI 是工程资源地址,读者需要按照真实工程资源目录替换。它既不负责任务取消,也不自动承担Scene资源回收。

import { Scene, RenderContext } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';

export interface LoadedScene {
  scene: Scene;
  node: spatialRender.GSNode;
}

export async function loadPreviewModel(uri: string): Promise<LoadedScene> {
  const context: RenderContext | null = Scene.getDefaultRenderContext();
  if (context === null) {
    throw new Error('RENDER_CONTEXT_NOT_READY');
  }
  context.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
  const scene: Scene = await Scene.load();
  const node: spatialRender.GSNode = await spatialRender.GSPlugin.loadGSNode(
    scene, { uri: uri, offset: 0 }, scene.root);
  return { scene: scene, node: node };
}

此处刻意不写一个看起来很方便的“释放Scene”调用,因为资源销毁需要根据实际 ArkGraphics 3D 的版本、节点树生命周期及渲染宿主确认。接口能成功返回对象,并不说明页面已经将它正确呈现;反过来,页面卸载了,也不意味着后台Promise不会完成。若在代码评审中看见“只要 if (stale) return 就不会泄漏”,应该追问那个返回值所包含的资源最终由谁处理。

loadGSNode 支持将结果挂到传入场景的父节点下。这里采用“每一次候选加载各自使用Scene”的隔离思路,而不是把所有候选先挂到当前正在显示的公共根节点。这样旧结果虽仍需要清理,但至少不能轻易污染当前可见场景。实际工程仍须验证显示组件与Scene的绑定方式,不能把这个示例视为完整渲染页面。

模型格式还有一个经常被混写的边界:loadGSNode 与 loadTiledGSNode 不是同一个接口。后者面向分块模型,文档列有自己的引入参数和起始版本。本文固定走普通3DGS模型的装载入口,不把瓦片清单格式作为 loadGSNode 的通用入参。写文章时把这两个名字都称为“导入3DGS”看似省事,落到工程里就容易把模型 URI、版本条件和资源释放策略混成一团。

三、不能用页面状态保存尚未验收的对象

真正需要设计的不是再造一套系统加载器,而是业务对象的准入条件。候选节点必须带上它开始加载时的 epoch、场景ID,以及负责后续清理的桥接句柄。准入只允许与当前代次完全相符的候选。一个请求若先后经历“开始—等待—返回”,在等待期间用户进行了下一次选择,它就已经没有修改当前界面的权利。

注意这里的“没有显示权”不等于“可以遗忘”。反而应该给它一个单独状态,例如 PENDING_DISPOSAL,留给资源桥接器安全处理。桥接器的销毁实现依赖具体渲染容器、插件实例和节点树,不能在未证实的版本上编造释放API;下面用明确的应用自定义接口表达这条职责,使上层算法可编排且可做模拟测试。

export interface SceneCandidate {
  sceneId: string;
  requestId: string;
  epoch: number;
}

export interface CandidateLoader {
  load(sceneId: string, requestId: string, epoch: number): Promise<SceneCandidate>;
}

export class ModelLeaseGate {
  private epoch: number = 0;
  private active?: SceneCandidate;
  private pending: SceneCandidate[] = [];
  requested: number = 0;
  accepted: number = 0;
  staleIgnored: number = 0;
  state: string = 'IDLE';

  async select(sceneId: string, requestId: string, loader: CandidateLoader): Promise<void> {
    const ticket = ++this.epoch;
    this.requested += 1;
    this.state = 'LOADING';
    try {
      const candidate: SceneCandidate = await loader.load(sceneId, requestId, ticket);
      if (ticket !== this.epoch) {
        this.staleIgnored += 1;
        this.pending.push(candidate);
        return;
      }
      if (this.active !== undefined) {
        this.pending.push(this.active);
      }
      this.active = candidate;
      this.accepted += 1;
      this.state = 'LEASE_ACTIVE';
    } catch (e) {
      if (ticket === this.epoch) {
        this.state = 'LOAD_FAILED';
      }
    }
  }

  get activeSceneId(): string { return this.active?.sceneId ?? ''; }
  get loadEpoch(): number { return this.epoch; }
  get pendingDisposal(): number { return this.pending.length; }
}

这段是可独立理解的业务算法,不是对 Spatial Recon Kit 新增了名为 CandidateLoader 的系统接口。实际对接时,适配器需要存放 LoadedScene 与显示宿主的关系,并在 pending 队列中的对象完成资源销毁后,再回报“释放完成”。当前示例故意没有删除队列项,因此演示结果是 pendingDisposal=2,不是0。若未来接入的桥接器只能“把节点从页面上隐藏”,却无法证明Scene内的资源已经回收,不能把“隐藏成功”记为销毁确认。

另外,单独检查epoch仍不够。某些场景里,同一场景被重复请求,sceneId 完全相同,但加载意图早已更换。所以校验不能只写 candidate.sceneId === currentSceneId;它必须以意图代次为主键。requestId 负责问题追踪,epoch 负责时序裁决,两个字段的意义不能互换。截图里出现三次请求、两条丢弃,就是为了让这个边界看得见。

四、把“先完成”与“先提交”分成两条事件流

做模拟验证时,不能简单把三个Promise按序 await,那样永远看不到乱序。需要主动制造与发起顺序不同的完成顺序。把旧的 req_001、req_002 先放入加载队列,再让 req_003 优先完成。业务接受第三条后,前两条即使随后成功返回,也只能进入待清理队列。在另一个测试里可以把第三条设置为失败,确认旧请求也不能自动恢复为“当前新场景”;是否允许降级,需要由产品明确提出另一套策略,而不是偷偷绕过代次。

示例状态解释如下:requested=3 只说明已经提交三个意图;accepted=1 是真正获得当前显示权的结果数;staleIgnored=2 表示两个完成但不再有显示权的结果;pendingDisposal=2 表示两条旧候选仍等待资源清理的证据;loadEpoch=3 记录最新选择。这里没有“已销毁2”的数字,因为并未建立真机侧资源销毁完成的证据链。

下面用不依赖硬件的纯函数,对演示向量进行状态汇总。这有助于把UI数据和HiLog预期固定下来,再接入真正的 GSPlugin 实例。它解决的是示例数据的一致性,不替代底层3DGS运行测试。

interface FinishEvent {
  requestId: string;
  epoch: number;
  sceneId: string;
}

interface LeaseSummary {
  requested: number;
  accepted: number;
  staleIgnored: number;
  pendingDisposal: number;
  currentScene: string;
}

function summarize(events: FinishEvent[], activeEpoch: number): LeaseSummary {
  let accepted = 0;
  let stale = 0;
  let current = '';
  for (const event of events) {
    if (event.epoch === activeEpoch) {
      accepted += 1;
      current = event.sceneId;
    } else {
      stale += 1;
    }
  }
  return {
    requested: 3, accepted: accepted, staleIgnored: stale,
    pendingDisposal: stale, currentScene: current
  };
}
const sample: FinishEvent[] = [
  { requestId: 'req_003', epoch: 3, sceneId: 'gallery_12' },
  { requestId: 'req_001', epoch: 1, sceneId: 'atrium_09' },
  { requestId: 'req_002', epoch: 2, sceneId: 'atrium_09' }
];
const summary: LeaseSummary = summarize(sample, 3);
// 演示预期:requested=3、accepted=1、staleIgnored=2、pendingDisposal=2

从代码再回到页面,用户需要看到的是 gallery_12 已成为当前可见场景,而不是“模型切换完成”四个模糊的字。界面同时显示 gallery_12.glb、loadEpoch=3、状态 LEASE_ACTIVE,并保留一行“待清理2”。那个数字不应该用灰色隐藏,因为它正是下一轮工程工作的入口:无论真实渲染桥接怎么写,都要能解释这两个候选的资源归属。

上图为按 Demo 数据制作的 DevEco Studio 风格演示图,右侧模拟器和底部HiLog并非真实运行结果。图片中文字如果看起来像系统日志,也只代表固定演示向量。真实调试必须在设备环境记录插件版本、场景URI、每次Promise完成顺序及销毁确认,不能从示意图反推性能结论。

五、主页面只呈现一个有效租约

交互页面可以提供“切换场景”和“查看迟到诊断”两个入口,但不能同时把旧对象显示成可选结果。本文规定当前主页面是 SceneLeasePage,模型预览区域只是对 gallery_12 的模拟视觉;它表达的是“当前被接纳的场景”,并不是模型已经在目标设备上完成真实3DGS渲染。

如果列表正在加载 gallery_12,业务可以在UI上临时显示旧场景缩略图,但必须用独立标识说明它只是回退占位,不可把旧 GSNode 混作新租约。后台返回时先验代次,再决定是否切换真正的显示绑定。对于被新选择取代的旧Promise,不需要向用户弹出“发生错误”,否则会把正常的交互竞态误报成设备异常;它应该进入诊断记录,等待资源适配层安全处置。

图中任务ID为 GS-1010-09,场景为 gallery_12,请求数3、接纳1、迟到忽略2、待清理2。状态 LEASE_ACTIVE 只意味着应用的归属门禁认为第三条租约有效,不代表GPU纹理、场景图、插件引用或宿主组件的实际生命周期都已经通过验收。这样的命名虽然克制,却能避免状态面板越写越“乐观”。

在工程里还有一个隐蔽的时序:页面执行 aboutToDisappear 后,前一个Promise才完成。此时“当前epoch不变”也不可靠,因为页面本身已经失去显示权。因此页面离开时必须主动使当前租约失效,例如让应用状态推进到新的失效代次,再移交清理队列。倘若进入新页面后复用原来的全局gate,需要同时携带宿主页面会话ID,不能靠单纯的整数3判断它仍属于旧窗口。

六、诊断页要能区分忽略、等待清理和真正释放

普通日志常常只记 load success 和 load fail,这对这种问题帮助不大。因为真正需要抓的恰恰是“成功加载,但被判定为过期”的路径。我们让 LeaseAuditPage 显示请求时间线:10:24:11发起epoch1和2,10:24:12发起epoch3,10:24:13接纳epoch3的gallery_12,10:24:14归档epoch1、2的迟到结果。全部为示例时间,不代表模型只耗费几秒。

两个被忽略的对象在示例里标记 PENDING_DISPOSAL。这个名字故意不叫 RELEASED。若后续的适配器确认可以安全解除场景绑定,并获得资源释放的实际完成事件,就把该候选从队列移除,同时记录 disposedConfirmed。若释放中断,保留失败原因与下一次可重试的安全条件;不能直接吞异常、把计数清零。即使引用被JavaScript垃圾回收,也不能据此推定GPU底层资源同步归零。

这张图与主页面不同,展示的是待清理队列、旧请求编号与各代次状态。它还专门说明“已确认释放0”,避免把设计意图包装成已执行结果。实际采集设备日志时,最好给每条候选记录 sceneId、requestId、epoch、URI摘要和清理尝试次数;URI如果含本地敏感目录或业务令牌,需要脱敏后再存入日志。

诊断页的另一个职责,是阻止大家用错误指标衡量效果。“旧请求忽略2”并不说明处理耗时降低;“主画面没有跳回去”也不能证明资源无泄漏。两者只是第一道正确性验收。后面还要有窗口反复进出、模型大小差异、系统回收、异常关闭、旋转屏以及插件加载失败的完整矩阵。只有在这些测试完成后,才谈得上“切场稳定”或者“资源回收可靠”。

七、加载串行化、每次独立Scene与批量丢弃的取舍

这里不推荐把全部请求简单串行执行。这样确实不会发生“旧的Promise晚于新的Promise完成”,却可能让一次不再需要的大模型占满队列,拖慢用户真正选择的小模型。相反,允许候选并行开始、只让最新请求获得可见租约,会改善交互及时性,但需要承担多份候选对象的暂存成本。当前例子是三次请求与两个待清理候选,因此必须限制并发总量以及待清理队列容量,避免恶意连续点击导致不可控内存增长。

独立Scene是为了缩小旧候选污染当前渲染树的机会,并非免费隔离。每创建一个Scene,都有可能引入额外资源和上下文成本。设备性能不足或模型过大时,应用可以采用“一个当前加载、一个最新等待”的折中方案:新请求覆盖等待槽,但绝不修改已经在运行的系统Promise。具体选择应由内存测试决定,而不是只凭视觉流畅度判断。

错误恢复也应与时序裁决分开。一条过期请求失败,不要把全局状态从 LEASE_ACTIVE 改成 LOAD_FAILED;反过来,当前最新请求失败,也不该直接承认前一个过期结果。需要在产品层明确定义:失败后继续留在已确认可见的旧场景,还是进入空场景错误页。两种策略都可行,但数据语义不同,日志必须清楚记录“失败请求属于哪个epoch”和“用户最终看到了哪个场景”。

如果一个模型资源真正释放失败,应给操作员一个可观察的状态,例如 DISPOSAL_RETRY_REQUIRED;不要在应用界面偷偷无限重试,更不应该在主线程里阻塞等待。对于真正的空间模型文件,还应限制导入目录、文件大小和模型格式;官方对某些模型能力有设备与版本条件,不满足条件时应提前降级说明。把所有失败一概归为“网络不好”既不准确,也无法指导排错。

八、验收顺序应先保证不串场,再证明不滞留

这篇 Demo 的第一层验收是确定性的:对于不同完成顺序的三个模拟Promise,只有epoch3能激活;epoch1、2必定被标为旧候选。第二层是资源归属:旧候选必须进入队列,并在桥接器完成实际释放后才能消失。第三层才是视觉验收:在支持相关 SpatialRender 能力的设备上,反复切换场景,不出现旧场景覆盖新场景或设备返回前台后节点错配。

上线前还应逐项核对平台文档的版本范围:本文使用 spatialRender.GSPlugin.loadGSNode,不擅自混入 loadTiledGSNode 的分块接口;插件加载、Scene创建和本地模型URI都要在对应版本上重新编译与验证。Scene.load()、RenderContext 和 GSPlugin 的官方签名可以查,但资源桥接器对不同显示容器的销毁责任不能靠一段示意代码替代。

这个案例留给团队的核心结论是:异步加载的成功只说明某个候选对象出现了;是否有资格上屏、是否还欠着资源清理,是两个必须独立记录的事实。 让 GS-1010-09 的状态面板同时保留 LEASE_ACTIVE 和 PENDING_DISPOSAL=2,比一个漂亮却笼统的“加载成功”更有工程价值。本文的状态机、日志与配图可以作为评审输入,但不是系统实际运行证据。

1. 要把关闭页面也当成一个新的意图

切换场景的演示主要覆盖了三次点击,但真实页面还有第四种意图:用户直接离开。它不会产生一个可显示的新模型,却必须让所有在途加载立即失去显示资格。工程里可把页面生命周期也看作租约判定的一部分:页面关闭时递增一个失效代次并关闭可见宿主;迟到候选仍交给待清理队列。不能因为没有新页面要显示,就容许旧Promise重新创建渲染容器。

例如用户从展厅进入订单页,再返回展厅列表。即使旧页面曾经持有epoch3,新页面也不应该复用那个epoch作为有效凭据。实际实现可使用pageSessionId与loadEpoch组成复合身份,前者代表哪一次页面实例,后者代表该实例内的哪一次切场。这样既防止跨页面串场,也使HiLog能够区分“第二次打开同一展厅”和“上次关闭后冒出来的迟到结果”。

资源回收的验收还需要区分三种时间:用户点击离开、宿主解绑完成、底层资源被确认释放。三者有先后关系,但不能假设发生在同一帧。日志把它们合并成一句closed,等同于抹掉了最值得排查的故障窗口。Demo保留pendingDisposal=2,正是在提醒开发者后两步尚未得到实证。

九、参考与能力边界

  • 华为 spatialRender ArkTS API(2026-09-30更新):https://developer.huawei.com/consumer/en/doc/harmonyos-references/spatial-recon-spatialrender
  • 华为 Spatial Recon Kit术语:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/spatial-recon-glossary
  • 华为 Spatial Recon Kit模型写入结构体:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/api/capi-spatialrecon-hms-spatialrecon-modelwriteinfo

以上资料用于核对系统的真实能力与术语。SceneLeaseLab、ModelLeaseGate、CandidateLoader、任务编号、演示统计和待清理队列均是本文自建业务设计,不是华为SDK新增接口或真机测量结果。

Logo

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

更多推荐