HarmonyOS 7 新特性15:3DGS深潜——多模型共存的加载调度、驻留策略与内存回收时机实测

1、引言

篇 3 里我用 TiledGSNode 把一块 71MB 的庭院点云按视口切块加载,解决了「一个大资产怎么切」的问题。但带看场景的真实需求从来不止一块资产:用户从庭院切到室内、再切到立面,三块点云在同一个 Scene 里共存——谁驻留、谁卸载、内存曲线长什么样,这是篇 3 没碰的另一半。

动手前我列了三个预期会撞上的现象(这些是多模型场景的老毛病,动手前我就列出来了):切到第二块时画面黑一下;切到第三块直接不显示;内存一路涨不回头。真机跑完,三个现象都在,但根因和我预想的完全不一样——它们指向两个比「调度策略」更底层的机制:

机制一:loadGSNode 的 Promise resolve ≠ 点云上传完成。 干净基线(约 80MB)下把 17MB 档设为可见,load 完成时 PSS 只涨约 18MB——而文件本身 17MB、点数 10 万,若解析+上传同步完成,增量应远不止这个数;同一时刻另外两档预载(resolve 了、节点挂进场景图、只是 visible=false)各只涨约 1MB。resolve 拿到的只是句柄加 rawfile 的 mmap 页,点云解析与 GPU 上传的大头被推迟,且推迟的部分不走应用可观测的加载路径。「切到第二块黑一下」不是加载慢,是上传被推迟到了渲染真正需要数据的时刻。

机制二:remove ≠ release。 把当前显示的节点从场景图摘掉(children.remove)、再把 JS 引用置空,PSS 在 +1s/+3s/+10s 三个采样点纹丝不动——三资产同驻的约 140MB 地板一分不降;隐藏(visible=false)同样不降。唯独整场景销毁(Scene.destroy)才真回收:PSS 地板从约 140MB 掉到约 87MB(空场景),释放约 53MB。「内存一路涨不回头」的根因就在这:应用层对已加载点云没有节点级释放手段。

这两个机制决定了本篇的工程形态:调度策略(全常驻/懒加载/滑窗)只是「何时加载、何时摘除」的上层决策,真正的内存账本由底层机制记账。我把三种策略做成可切换的对照实验台,用双令牌治住快速切换的竞态,在 Mate 60 Pro 上采了七组数据。先给结论表:

实验实测一句话结论
分档加载可见 13-22ms / 预载 36-56ms耗时不随文件体积线性涨(mmap 延迟解析)
三资产同驻约 140MB(可见档 +18 / 隐藏预载各 +1)resolve≠上传,上传大头被推迟到渲染需要时
四档回收三档停 139-142MB 地板 / 重建掉到 87MBremove≠release,仅 Scene.destroy 真回收约 53MB
策略切换reset+重载 16-38ms切换成本被 mmap 摊薄,策略选择看内存不看速度
滑窗驻留resident 恒等于 2滑窗真淘汰生效(不是退化成全常驻)
双实例同 uri 再加载 +40MB(17MB 档)重复加载确有多份实例,须显式回收
十连发实加载 2 + 让位 6 + 去重 2去重+焦点序吸收竞态,无陈旧覆盖
2、效果展示与项目结构
2.1 真机效果

诊断台是全常驻策略下三资产同驻的样子:三条状态灯两绿(resident)一蓝,顶部 PSS 读数与基线并排,视口里是当前显示的庭院点云,底部一条自采 PSS 曲线每秒一点:

HarmonyOS 7 新特性15:3DGS深潜——多模型共存的加载调度、驻留策略与内存回收时机实测-1.png

切到滑窗策略连切三档后,状态灯恒为「两绿一灰」——窗口外的资产被真淘汰,这是滑窗驻留的肉眼验证:

HarmonyOS 7 新特性15:3DGS深潜——多模型共存的加载调度、驻留策略与内存回收时机实测-2.png

十连发压测后的日志流:连发被去重与焦点序吸收,「焦点让位」记录逐条在列,最终 current 停在最后一次请求:

HarmonyOS 7 新特性15:3DGS深潜——多模型共存的加载调度、驻留策略与内存回收时机实测-3.png

三档逐档加载的日志:每档的 elapsed 与 PSS 增量,以及 lazy 策略下「显示新档即回收旧档」的 evict 记录:

HarmonyOS 7 新特性15:3DGS深潜——多模型共存的加载调度、驻留策略与内存回收时机实测-4.png

2.2 工程结构:调度器与诊断台分层

篇 3 的加载能力(GSSceneLoader:插件幂等、空场景、前向相机、整卡/分块双入口)一行没动,本篇在其上加两层:

entry/src/main/ets/features/schedule/
├── model/
│   └── GSMultiScheduler.ets   # 调度器:双令牌/在途去重/三策略/回收四档/诊断登记/PSS 采样
└── SchedulePage.ets           # 诊断台:策略三选/状态灯/回收四档/E6E7 按钮/日志流/PSS 曲线

诊断台是诚实的实验台形态(先例:篇 7 时序台):页面明写「此为对照实验台,非生产设置页」。三块同庭院不同精度的点云(LOD1 86MB/50万点、LOD2 43MB/25万点、LOD3 17MB/10万点)作为多资产素材——控制变量只有体积,策略对照才干净。

3、Kit能力与核心API深度解析
3.1 loadGSNode 的异步语义:resolve 给的是句柄不是数据

spatialRender.GSPlugin.loadGSNode(scene, params, parent?) 返回 Promise<GSNode>。本篇实测把这个 Promise 前后的 PSS 变化拆成两个不同测量时点,两者相差悬殊,正是「resolve≠数据就绪」的定量证据(干净冷启动会话,基线约 80MB):

  1. load-done 打点时刻(Promise resolve 瞬间):当前可见的 17MB 档 PSS +18~26MB(两次冷启动实测 79→97 与 80→106,差额取决于打点瞬间渲染已访问了多少 mmap 页);而两个隐藏预载档(同样 resolve、节点已挂进场景图、visible=false)各只 +0.8~1.0MB——mmap 映射建立了,但页未被访问,PSS 几乎不动。
  2. 稳态驻留时刻(三档同驻后顶栏读数):PSS 爬到约 140MB;sceneRebuild 后的空场景基线约 87MB,两者之差约 53MB 就是三档资产的驻留 native 成本(约 18MB/档,与文件体积同量级,是解析后常驻的缓冲)。
  3. 两个时点的差额:隐藏档在 resolve 瞬间只 +0.8MB,稳态却占约 18MB——中间这约 17MB 是渲染消费时才逐步显形的解析/上传成本,不走 load-done 打点。这就是「切到第二块黑一下」的来源:数据不是没加载,是上传被推迟到了渲染真正要用的时刻。

关键结论:load-done 打点看到的增量(可见 +18~26 / 隐藏 +1)不等于该资产的稳态驻留足迹(约 18/档)——前者是「resolve 瞬间已被访问的页」,后者是「渲染消费后常驻的缓冲」。做内存预算时按稳态驻留足迹(约 18MB/档 × 驻留档数 + 空场景 87MB)估,别拿 load-done 增量或文件大小直接推 PSS。

3.2 场景图操作面:Container 与 Node

节点挂载与摘除走 graphics3d 的 Container API(SceneNodes.d.ts,@since 12):children.append(item)(L110)/ children.remove(item)(L127)/ clear()(L143);Node 接口给 visible(L193)、readonly parent(L229)、readonly children(L247)、position: Position3(L169,Position3=Vec3{x,y,z},SceneTypes.d.ts L663/L51)。

关键空缺:GSNode 没有 destroy/release 接口(spatialRender.d.ts L122 GSNode extends Node,仅继承 Node 公开面)。应用层对单个节点的「卸载」只有两件事可做:从场景图摘除(remove)、撒掉 JS 引用——而实测证明这两件事都不触发 native 释放。节点级回收在 SDK 面不存在,这是本篇所有回收实验的前提。

3.3 内存读数:hidebug.getPss 的单位是 KB

应用内内存曲线用 hidebug.getPss()(@ohos.hidebug.d.ts L87)。单位坑:d.ts L83 明写返回值 in KB,首版我按字节除了 1048576,读出 0.156「MB」的荒谬值(实际 160MB)——改成 /1024 才对齐。d.ts 还提示读 /proc/smaps_rollup 耗时、建议放异步线程;本篇 1s 间隔采样在诊断台可接受,生产场景应遵守该提示。

3.4 规矩引用:Component3D 首绑 Scene

篇 3 实测的规矩本篇直接沿用不重测:Component3D 只在首次实例化时绑定 SceneOptions 里的 Scene,换 Scene 必须换组件 key 强制重建。所以回收四档里的 sceneRebuild 档,页面在 Scene.destroy() 后自增 sceneGen 键重建 Component3D——不重建的话新 Scene 永远上不了屏。

4、逻辑流梳理
4.1 资产生命周期状态机

focus/prefetch 发起

世代过期丢弃(摘节点)

落地且焦点获胜(visible 按是否当前)

焦点让位(转 hidden 驻备)

lazy/滑窗淘汰(remove+撒引用)

removeKeepRef(摘图留引用)

再 focus(append 回挂)

撒引用

absent

loading

resident

detached

resolve 只带 mmap 页(隐藏档约 +1MB/档)
上传大头推迟到渲染消费,不走加载路径

remove/撒引用/隐藏均不释放 native(E3 地板 ~140MB 不动)
仅 Scene.destroy 真回收(E3 掉约 54MB 至 ~87MB)

4.2 双令牌:世代管存在,焦点序管显示

单一 gen 计数器想同时表达「这批结果该不该存在」和「该不该设成当前」是不成立的(这一点是代码审查时一眼点破的):setPolicy/rebuild 要作废在途加载,但同世代内的并发 focus 之间还需要「最后请求获胜」。正解是双令牌:

  • gen(世代):换策略/rebuild/dispose/压测每发起一次自增。在途加载落地时 gen 已变 → 结果作废、节点就地摘除、discardSeq 计数;
  • focusSeq/appliedSeq(焦点序):每次用户 focus 自增。落地时 mySeq < appliedSeq → 焦点让位:节点保留为驻备(visible=false,预载不白做)、handoffSeq 计数,不覆盖当前显示。

「让位 6 条」就是焦点序在工作:早发出的请求晚完成时乖乖让位,current 始终是最后一次请求。

5、项目实战

环境声明:HarmonyOS 7.0(API 26)Release,真机 Mate 60 Pro(ALN-AL80,麒麟 9000S,1260×2720,7.0.0.107 SP8C00E105R4P3),DevEco Studio 26.0.0(SDK 26.0.0.105 Release)。标题里的「新特性15」是本系列(《HarmonyOS 7 新特性体验》)的篇号,不代表本篇主题是 API 26 新增能力——3DGS 渲染底座 spatialRender 自 @since 6.0.1(21) 就有,本篇是对既有能力的深潜(多模型驻留调度),不是新特性发布。三档 lod 素材进 rawfile 随包(见 6.5),hap 222MB。

5.1 调度器:在途去重带世代标记

同资产并发加载复用在途 Promise,但表项记 {gen, promise}:世代不符不复用(防策略切换后复用到必然作废的任务);then 里 delete 前校验表项身份(get(id)===rec),防旧任务的完成回调误删新表项。focus 命中同世代预载时仍走 finishFocus 补显示语义——复用只承担「加载完成」,不承担「设当前」,两个语义不互换(代码审查时的一致意见)。

5.2 三策略:滑窗必须真淘汰

三策略只决定「何时 load / 何时 evict」,挂载与摘除动作全部收口在调度器,保证对照实验除策略外零差异:

  • all-resident:focus 落地后顺带预载其余全部 absent 档;
  • lazy:focus 落地后把非当前 resident 全部 evict(removeDropRef);
  • sliding:窗口={当前, 序列下一档},预载 next 并淘汰窗外 resident。

第三点是代码审查抓出的设计缺陷:初版滑窗只预载不淘汰,遍历三档后驻留集=全部,静默退化成全常驻——「resident 恒等于 2」就是修复后的验证。写「N 个策略对照」时,每个策略必须列出它的负面义务(滑窗的负面义务是淘汰窗外),只写正面动作(预载)对照就是假的。

5.3 回收四档:时机分化

回收实验四档,每档 evict 后按 +1s/+3s/+10s 三点采 PSS(页面 setTimeout 自采并镜像进 hilog 归档)。同一会话内三资产同驻(地板约 140MB),对当前显示的 lod3 逐档操作:

档动作+1s+3s+10s解读
hideOnlyvisible=false139.8139.5139.7隐藏不释放任何 native 成本
removeKeepRef摘图+留引用140.3140.3140.5摘图同样不释放
removeDropRef摘图+撒引用141.2141.4141.9撒引用也不触发释放(10s 窗口)
sceneRebuildScene.destroy+重建86.887.087.7真回收:掉到空场景地板(比其余三档 ~140 驻留地板低约 53MB)

读法:前三档停在 139-142MB 同一地板——那是「应用+Scene+三档驻留资产」的 native 成本,隐藏、摘节点、撒引用都动不了它(removeDropRef 档甚至微涨,是采样期内渲染器自身波动);sceneRebuild 掉到约 87MB 的空场景地板,与前三档地板的约 53MB 差值正是全部驻留资产的 native 缓冲——只有整场景销毁能把它整体清零。应用层想真正卸载一块点云,唯一手段是连 Scene 一起销毁重建——这是 SDK 无节点级 destroy 的直接后果,也是多模型内存治理的硬边界。

一个诚实的注记:hideOnly 档「隐藏释放可见上传」是我最初被污染数据(前序实验残留未清)里的误读,干净重测后隐藏并不释放——可见档的上传成本混在驻留地板里,应用侧拆不出来。这组数据的教训与回收四档本身同样值钱:内存实验必须全新进程冷启动做,同会话里前序操作的残留会污染所有后续读数(见 6.7)。

5.4 诊断与压测的可观测设计

双实例诊断故意绕开在途去重,对同 uri 再起一次 loadGSNode:17MB 档的第二份实例设为可见后 PSS +40MB(141→181),证实「同 uri 重复加载产生多份实例」——且第二实例同样走「可见触发上传」路径,40MB 增量远超 17MB 文件本身,正是上传大头在渲染消费时的又一正面证据。诊断节点登记进 probeNodes 表(代码审查时的一致意见:游离节点无回收路径会污染后续所有基线),随换策略/rebuild/dispose 统一摘除——「不入状态机」与「无人管」是两回事。

十连发不 await(竞态就是被测对象),读数按本次窗口做差值(审查抓出的要点:累计值不能当这次的证据):10 发 → 实加载 2 + 焦点让位 6 + 在途去重吸收 2,最终 current 停在最后请求、无陈旧覆盖。「10 发只加载 2」不是压测失效,是去重与焦点序两层机制的吸收行为本身——把它当考点而非 bug 记录。

5.5 E 系列汇总
编号数据备注
分档加载可见加载 13-22ms(17/43/86MB 三档);预载 36-56ms耗时不随体积线性涨:mmap 延迟解析,上传推迟到渲染消费
三资产同驻约 140MB;可见档 resolve +18MB、隐藏预载档各 +1MBresolve≠上传的定量证据
四档回收时机分化(5.3 表)remove≠release;仅 Scene.destroy 真回收
策略切换reset+重载 16-38msmmap 摊薄切换成本,策略选择看内存不看速度
滑窗驻留resident 恒=2真淘汰生效
双实例+40MB(17MB 档第二份实例)重复加载多份实例,须显式回收
十连发10 发→实加载2+让位6+去重2双令牌吸收竞态
6、避坑指南
6.1 同 uri 重复加载产生多份实例
  • 现象:对已加载资产再调一次 loadGSNode,PSS 再涨一份(干净重测 +40MB)。
  • 根因:loadGSNode 不做同 uri 去重,每次调用新建节点挂进场景图。
  • 方案:调度器在途去重表 + 驻留表双重拦截;实验性重复加载登记诊断表随策略切换回收。
  • 验证:那组诊断切策略后基线回落,probeNodes 清空日志可见。
6.2 只 remove 不释放:内存账本在底层
  • 现象:摘节点+撒引用后 PSS 10 秒不动(回收地板不变)。
  • 根因:GSNode 无 destroy 接口,native 缓冲生命周期绑在 Scene 上。
  • 方案:内存预算按「可见资产算上传、驻留资产算地板」两本账记;真要卸载走 Scene.destroy+组件重建(注意首绑规矩)。
  • 验证:回收四档的分化表。
6.3 快速切换竞态:单令牌治不住
  • 现象:连点切换时早请求晚完成会覆盖新焦点(初版单 gen 令牌)。
  • 根因:gen 只表达上下文失效,不表达请求顺序。
  • 方案:双令牌(4.2);让位节点保留驻备不白扔。
  • 验证:让位 6 条、current 恒为最后请求。
6.4 getPss 单位是 KB 不是字节
  • 现象:内存读数 0.156「MB」。
  • 根因:d.ts L83 明写 in KB,误按字节除。
  • 方案:/1024;采样间隔与线程位置遵守 d.ts 提示(smaps_rollup 读取耗时)。
  • 验证:修复后读数与 hidumper 量级对齐(干净基线约 80MB)。
6.5 素材投放:hdc 写不进沙箱、hap 读不了 tmp
  • 现象:想把大素材推给应用,两条路都死——hdc shell 是 uid 2000 无 root,写应用沙箱 Permission denied;投到 /data/local/tmp 后应用(debug_hap SELinux 域)accessSync 恒 false。
  • 根因:沙箱属主隔离 + data_local_tmp 域拒绝 hap 读。
  • 方案:素材进 rawfile 随包、loadGSNode 走 OhosRawFile:// 直加载(篇 3 整卡对照同源);大素材用 .gitignore 排除、由脚本从 datasets 准备,仓库不膨胀。
  • 验证:三档校验通过、分档加载成功。代价也得说清:hap 222MB,生产应改下载+沙箱缓存。
6.6 采样器与回调的生命周期
  • 现象:页面销毁后 setTimeout 回调仍写 @State、异常路径 busy 不复位锁死操作。
  • 方案:destroyed 标志收口进 refresh(所有 await 后续过它)+ onLog 回调首行判销毁 + busy 全 try/finally;采样器随 Tab 激活挂/摘(不常驻污染基线)。
  • 验证:制造加载失败后操作台仍可点击。
6.7 内存实验的会话污染:同进程连测会毁掉基线

现象:初版三组内存数据的绝对值全部偏高(同驻「190MB」、回收地板「155MB」、双实例「+75MB」)。写完之后自查,发现文内对不上账,甚至能给出精确推算:190MB 无法由「sceneRebuild 空场景基线 116(同为污染值,干净实测是 87)+ 可见档 +34 + 两个隐藏预载档各 +0.3」凑出(≈151≠190),差约 39MB 无出处。顺着这个对账缺口回真机冷启动复测,才定位到根因:前序的加载、各档 evict(而 remove≠release,摘掉的资产 native 成本还赖在账上)在同一进程会话里层层叠加,轮到三资产同驻时「基线」早已不是干净基线——复查时发现的是「数值无法自洽」这个症状,会话污染这个病根是冷启动重测确认的。根因:remove/隐藏不释放(6.2 的机制),意味着同会话内每次「卸载」都留残值,任何后续 PSS 读数都含前序残留。方案:每组内存实验都 force-stop 冷启动重测,从 policy→reset 后的干净基线(本机约 80MB)起量;差值类读数(如双实例的 +40MB)在同会话内取「操作前后」两点差尚可,但绝对地板值必须冷启动。验证:冷启动重测后同驻 140MB、回收地板 139-142MB、重建后 87MB,三组数据自洽且与前面那笔对账推算吻合(140 ≈ 87 空场景 + 53 三档驻留)。结论对、数值错正是这类污染最隐蔽的地方(差值机制不受影响,绝对量全错)——这条教训本身比任何一组数据都值钱:内存实测的第一规矩是「一次冷启动只量一件事」,而「对不上账」这个直觉常常比自查更早嗅到污染。

7、总结

多模型共存的内存账本不由调度策略记,而由两个底层机制记:上传推迟到渲染消费(resolve≠upload)与释放绑定 Scene 生命周期(remove≠release)。策略层能做的只是决定「谁可见、谁驻留、谁摘除」——全常驻换切换零等待、懒加载换内存最低、滑窗折中但必须真淘汰。应用层对单块点云没有节点级释放手段,这是 SDK 面的硬边界,做内存预算时按两本账(驻留地板量 + 可见增量)记才不会被 PSS 读数骗。

未实测部分(单机 Mate 60 Pro / 7.0.0.107 R4P3 实测):

  • 三档 lod 为同庭院不同精度(控制变量=体积),异构内容资产的驻留交互未测;
  • 回收实验的观察窗 10s,更长时间尺度下 removeDropRef 是否最终释放(GC 触发 native 回收)未测;
  • PSS 为应用进程说法,GPU 显存归账不在其中;「可见档 +18MB / 双实例 +40MB」等均为 PSS 差值近似,上传成本的节点级拆分不可观测;
  • 素材随包(hap 222MB)是实测妥协,生产路径(下载+沙箱缓存)的加载耗时与本篇 rawfile mmap 数据不可直接比;
  • 双令牌治的是应用层竞态;渲染器内部的上传队列行为不可观测,「上传推迟到渲染消费」的结论来自 PSS 差值推断;
  • 内存类绝对值均为冷启动干净会话实测(6.7 规矩),切换与驻留、压测三组为计数与时延类数据;
  • 时延为单次观察值(hilog 毫秒级),量级结论稳、精确值供参考。在这里插入图片描述
Logo

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

更多推荐