【共创稿事节】HarmonyOS 7中3DGS 场景热加载与内存回收
3DGS 场景热加载与内存回收
商品列表滑动切换不同 3DGS 场景,每个场景 30-50MB 高斯点数据,连续切 10 个就是 300-500MB。不主动回收内存的话,切到第 6-7 个就 OOM 崩了。我们做商品展示项目时第一版没管内存回收,内测切 8 个场景后应用闪退,日志是 OOM。这篇文章记录我们怎么把内存回收做到能连续切 30+ 场景不崩。
能力面:加载与卸载 API
Spatial Recon Kit 提供 loadGSNode 加载场景,对应的卸载是 release。API 看着对称,实际行为不对称——加载是同步分配内存,卸载是异步释放,且释放不一定干净。
// ArkTS:3DGS 场景加载与卸载
import { spatialRender } from '@kit.SpatialReconKit';
import { Node } from '@kit.ArkGraphics3D';
class SceneManager {
private currentScene: spatialRender.GSNode | null = null;
private sceneRoot: Node;
// 加载新场景
async loadScene(uri: string): Promise<void> {
// 先卸载当前场景
if (this.currentScene !== null) {
await this.unloadCurrentScene();
}
// 加载新场景
this.currentScene = await spatialRender.GSPlugin.loadGSNode(
scene, { uri, offset: 0 }, this.sceneRoot
);
}
// 卸载当前场景
private async unloadCurrentScene(): Promise<void> {
if (this.currentScene === null) return;
// 1. 从场景树移除
this.sceneRoot.removeChild(this.currentScene);
// 2. 调 release 释放高斯点数据
this.currentScene.release();
// 3. 置空引用,帮助 GC
this.currentScene = null;
// 4. 主动触发 GC(可选,见后文讨论)
// globalThis.gc?.();
}
}
四步看着完整,但第 2 步 release 调了之后内存不一定立刻降下来。这是第一个坑,后面详说。
release 的实际行为
release 内部做的是释放 GPU 资源(顶点缓冲、纹理)和标记 CPU 端数据可回收。GPU 资源释放是同步的,调完就还了。CPU 端数据(高斯点数组、SH 系数)是标记可回收,实际回收等 GC。
GPU 内存和 CPU 内存是两块,要分开看。我们的内存监控也分两块统计。
约束面:内存增长曲线与 GC 不可控
我们在麒麟 9030 上连续加载 10 个 3DGS 场景,每个场景约 35MB(CPU 端)+ 28MB(GPU 端),记录内存变化。
不做任何回收(对照组)
| 加载序号 | CPU 内存 | GPU 内存 | 总内存 | 状态 |
|---|---|---|---|---|
| 初始 | 180MB | 120MB | 300MB | 正常 |
| 场景 1 | 215MB | 148MB | 363MB | 正常 |
| 场景 2 | 250MB | 176MB | 426MB | 正常 |
| 场景 3 | 285MB | 204MB | 489MB | 正常 |
| 场景 4 | 320MB | 232MB | 552MB | 正常 |
| 场景 5 | 355MB | 260MB | 615MB | 警告 |
| 场景 6 | 390MB | 288MB | 678MB | 警告 |
| 场景 7 | 425MB | 316MB | 741MB | 危险 |
| 场景 8 | — | — | — | OOM 崩溃 |
每个场景增量约 35MB CPU + 28MB GPU = 63MB。麒麟 9030 应用可用内存上限约 768MB,到第 8 个场景总内存超过 800MB,OOM。
只调 release(第一版回收)
| 加载序号 | CPU 内存 | GPU 内存 | 总内存 | 备注 |
|---|---|---|---|---|
| 初始 | 180MB | 120MB | 300MB | — |
| 场景 1 | 215MB | 148MB | 363MB | — |
| 场景 2 | 220MB | 150MB | 370MB | 场景 1 已 release |
| 场景 3 | 255MB | 178MB | 433MB | 场景 2 已 release,CPU 没降全 |
| 场景 4 | 250MB | 152MB | 402MB | GC 触发,CPU 回收了一部分 |
| 场景 5 | 285MB | 180MB | 465MB | — |
| 场景 6 | 280MB | 154MB | 434MB | GC 触发 |
| 场景 7 | 315MB | 182MB | 497MB | — |
| 场景 8 | 310MB | 156MB | 466MB | GC 触发 |
| 场景 9 | 345MB | 184MB | 529MB | — |
| 场景 10 | 340MB | 158MB | 498MB | GC 触发 |
GPU 内存基本回收干净(每次 release 后降回 150MB 左右)。CPU 内存回收不干净——每次 release 后 CPU 内存只降 5-10MB,不是 35MB。原因是 CPU 端高斯点数组等 GC 回收,GC 不一定在 release 后立刻触发。
CPU 内存呈锯齿状增长,每次 GC 触发降一截但降不到前一个场景的水平。10 个场景后总内存 498MB,没 OOM 但一直在涨。继续切 20 个场景大概率还是会 OOM。
release + 手动释放纹理(最终方案)
| 加载序号 | CPU 内存 | GPU 内存 | 总内存 | 备注 |
|---|---|---|---|---|
| 初始 | 180MB | 120MB | 300MB | — |
| 场景 1 | 215MB | 148MB | 363MB | — |
| 场景 2 | 218MB | 150MB | 368MB | 手动释放后 |
| 场景 3 | 221MB | 152MB | 373MB | — |
| 场景 4 | 219MB | 150MB | 369MB | GC + 手动释放 |
| 场景 5 | 222MB | 152MB | 374MB | — |
| 场景 6 | 220MB | 150MB | 370MB | — |
| 场景 7 | 223MB | 152MB | 375MB | — |
| 场景 8 | 221MB | 150MB | 371MB | — |
| 场景 9 | 224MB | 152MB | 376MB | — |
| 场景 10 | 222MB | 150MB | 372MB | — |
CPU 内存稳定在 220MB 左右,不再持续增长。秘诀是在 release 之外,手动释放了 3DGS 渲染用的纹理资源——这些纹理是 loadGSNode 内部创建的,release 不一定覆盖。
// ArkTS:手动释放 3DGS 相关纹理
import { spatialRender } from '@kit.SpatialReconKit';
class SceneManager {
private currentScene: spatialRender.GSNode | null = null;
private textureCache: Map<string, Resource> = new Map();
async loadScene(uri: string): Promise<void> {
if (this.currentScene !== null) {
await this.unloadCurrentScene();
}
this.currentScene = await spatialRender.GSPlugin.loadGSNode(
scene, { uri, offset: 0 }, this.sceneRoot
);
// 记录该场景创建的纹理,卸载时手动释放
this.trackTextures(uri);
}
private async unloadCurrentScene(): Promise<void> {
if (this.currentScene === null) return;
this.sceneRoot.removeChild(this.currentScene);
this.currentScene.release();
// 关键:手动释放纹理,release 不覆盖这部分
this.releaseTrackedTextures();
this.currentScene = null;
}
private releaseTrackedTextures(): void {
for (const [key, texture] of this.textureCache) {
// 释放 GPU 纹理资源
if (texture.release) {
texture.release();
}
this.textureCache.delete(key);
}
}
}
手动释放纹理这个发现来之不易。我们一开始只调 release,看 GPU 内存确实降了,以为回收干净了。但 CPU 内存持续涨,用内存分析工具查,发现是纹理对象的 CPU 端镜像没释放——GPU 纹理释放了,但 CPU 端的 ImageData 副本还在,等 GC 回收。手动调纹理的 release 才把 CPU 端镜像也释放了。
场景落地:商品列表滑动切换
我们的实际场景是商品列表,每个商品卡片有一个 3DGS 预览。用户上下滑动列表,可见的商品加载 3DGS 场景,滑出去的卸载。
预加载策略
不能等用户滑到才加载——加载耗时 0.5-0.8 秒,用户滑过去看到的是空白。我们做了预加载:当前可见商品 ±1 个预加载,滑出去的 ±2 个卸载。
// ArkTS:列表滑动时的场景加载策略
@Component
struct ProductList {
@State currentIndex: number = 0;
private sceneManager: SceneManager = new SceneManager();
private loadedIndices: Set<number> = new Set();
build() {
List() {
ForEach(this.products, (product, index) => {
ListItem() {
ProductCard({ product: product })
}
})
}
.onScrollIndex((start, end) => {
// 可见范围 + 预加载
this.handleSceneLoadUnload(start, end);
})
}
private handleSceneLoadUnload(visibleStart: number, visibleEnd: number): void {
const preloadRange = 1; // 预加载可见范围 ±1
const keepRange = 2; // 保留可见范围 ±2,超出则卸载
const shouldLoad = new Set<number>();
for (let i = visibleStart - preloadRange; i <= visibleEnd + preloadRange; i++) {
if (i >= 0 && i < this.products.length) {
shouldLoad.add(i);
}
}
// 卸载不在保留范围的场景
for (const loaded of this.loadedIndices) {
if (loaded < visibleStart - keepRange || loaded > visibleEnd + keepRange) {
this.sceneManager.unloadScene(this.products[loaded].uri);
this.loadedIndices.delete(loaded);
}
}
// 加载需要预加载的场景
for (const toLoad of shouldLoad) {
if (!this.loadedIndices.has(toLoad)) {
this.sceneManager.loadScene(this.products[toLoad].uri);
this.loadedIndices.add(toLoad);
}
}
}
}
预加载范围调过几版。±1 预加载时滑动流畅但快速滑动会看到空白(预加载来不及)。±2 预加载时快速滑动不空白,但内存占用高(同时 5 个场景在内存)。最后定 ±1 预加载 ±2 保留,快速滑动时接受短暂空白。
这一整套加载、卸载、控水位的动作,串起来是这样一条流程:
命中缓存的直接复用,没命中的先过水位检查再加载,超阈值就先把旧场景卸载干净。
同时加载多个场景的内存
预加载意味着同时有多个场景在内存。我们测了同时加载 2、3、4 个场景的内存:
| 同时场景数 | CPU 内存 | GPU 内存 | 总内存 | 帧率 |
|---|---|---|---|---|
| 1 | 215MB | 148MB | 363MB | 58fps |
| 2 | 250MB | 176MB | 426MB | 55fps |
| 3 | 285MB | 204MB | 489MB | 49fps |
| 4 | 320MB | 232MB | 552MB | 42fps |
帧率随同时场景数下降,因为同屏高斯点数增加——3DGS 渲染 fillrate 敏感,同屏高斯点数是帧率主要瓶颈。4 个场景同屏时帧率掉到 42fps,可以接受但不流畅。
我们的选择是预加载 ±1(最多同时 3 个场景),帧率 49fps 够用。如果用户快速滑动,预加载来不及的场景显示占位图,滑停后再加载。
踩坑与取舍
坑 1:卸载 API 调了但内存没降
前面说过,release 调了 CPU 内存只降 5-10MB,不是 35MB。原因是 GC 没跟上。我们试过手动触发 GC(globalThis.gc()),但 ArkTS 没有暴露 GC 接口,调不了。
手动释放纹理是绕过 GC 的方法——把大头资源手动释放掉,剩下的零碎等 GC 回收,即使 GC 慢,零碎也不会涨太多。
这个坑让我们重新审视了"卸载"这个概念。release 不是 dispose,它不保证资源立刻回收。要保证回收干净,得自己管理资源生命周期,不能全靠 API。
坑 2:快速滑动时加载取消
用户快速滑动列表,预加载触发了一堆 loadGSNode,用户又滑过去了,加载完的场景要立刻卸载。问题是 loadGSNode 是异步的,加载中途不能取消(API 26 没有取消加载的接口)。
结果是快速滑动时,后台有一堆加载任务排队跑完,每个加载完立刻被卸载。CPU 和 IO 被这些无效加载占满,用户滑停后真正要看的场景加载反而慢。
解决方法是加加载队列和取消标记。loadGSNode 不可取消,但我们可以在加载完之后检查是否还需要——不需要就立刻卸载。
// ArkTS:加载取消标记
class SceneManager {
private loadTokens: Map<string, { cancelled: boolean }> = new Map();
async loadScene(uri: string): Promise<void> {
// 创建加载令牌
const token = { cancelled: false };
this.loadTokens.set(uri, token);
// 加载
const node = await spatialRender.GSPlugin.loadGSNode(
scene, { uri, offset: 0 }, this.sceneRoot
);
// 加载完检查是否已取消
if (token.cancelled) {
// 已取消,立刻卸载
node.release();
this.loadTokens.delete(uri);
return;
}
this.currentScene = node;
}
cancelLoad(uri: string): void {
const token = this.loadTokens.get(uri);
if (token) {
token.cancelled = true;
}
}
}
这个方案不完美——加载还是跑完了,浪费了算力。但至少加载完不会挂到场景树上占内存。真正的取消加载要等 API 后续版本支持。
坑 3:GC 时机不可控导致内存峰值不可预测
GC 什么时候触发、回收多少,开发者控制不了。我们观察到 GC 倾向于在内存压力大时触发,但"压力大"的阈值不固定。有时 450MB 就触发,有时到 550MB 才触发。
这导致内存峰值不可预测。我们的内存预算按 GC 在 500MB 触发算,但偶尔 GC 到 550MB 才触发,峰值就超预算。如果此时用户再切一个场景,可能 OOM。
应对方法是主动监控内存,接近阈值时拒绝加载新场景,等 GC 触发内存降下来再加载。
// ArkTS:内存阈值控制
class MemoryGuard {
private static readonly WARN_THRESHOLD = 480; // MB
private static readonly BLOCK_THRESHOLD = 580; // MB
static canLoadNewScene(): boolean {
const currentMem = this.getProcessMemory();
if (currentMem > this.BLOCK_THRESHOLD) {
return false; // 内存太高,拒绝加载
}
return true;
}
static getProcessMemory(): number {
// 通过 hidumper 或 process.getInfo 获取进程内存
const info = process.getInfo();
return info.memoryUsage / 1024 / 1024; // 转 MB
}
}
// 使用
if (MemoryGuard.canLoadNewScene()) {
await sceneManager.loadScene(uri);
} else {
// 显示"内存不足,请稍候"或降级为图片展示
showFallbackImage(product);
}
坑 4:重建 session 和渲染场景的内存叠加
如果用户在重建一个新场景的同时滑动列表查看已有场景,重建 session 的内存和渲染场景的内存叠加。重建峰值约 1.8GB,加上几个渲染场景的内存,轻松超限。
我们的解决方法是重建时暂停列表预加载,只保留当前可见的一个场景。重建完恢复预加载。这会牺牲一些滑动流畅度,但避免 OOM。
真机数据:30 场景连续切换
最终方案(release + 手动释放纹理 + 预加载 ±1 + 内存阈值控制)在麒麟 9030 上连续切 30 个场景的内存监控:
| 切换序号 | CPU 内存 | GPU 内存 | 总内存 | 是否 OOM | 帧率 |
|---|---|---|---|---|---|
| 1 | 215MB | 148MB | 363MB | 否 | 58fps |
| 5 | 224MB | 152MB | 376MB | 否 | 55fps |
| 10 | 222MB | 150MB | 372MB | 否 | 57fps |
| 15 | 226MB | 153MB | 379MB | 否 | 55fps |
| 20 | 223MB | 151MB | 374MB | 否 | 56fps |
| 25 | 227MB | 153MB | 380MB | 否 | 54fps |
| 30 | 225MB | 152MB | 377MB | 否 | 55fps |
内存稳定在 370-380MB,没有持续增长,没有 OOM。帧率稳定在 54-58fps。30 个场景切完总内存跟切 1 个差不多,回收有效。
对比麒麟 9020:
| 切换序号 | CPU 内存 | GPU 内存 | 总内存 | 是否 OOM |
|---|---|---|---|---|
| 1 | 235MB | 165MB | 400MB | 否 |
| 10 | 268MB | 178MB | 446MB | 否 |
| 15 | 295MB | 185MB | 480MB | 否 |
| 18 | 340MB | 210MB | 550MB | 警告 |
| 20 | — | — | — | OOM |
9020 上到第 20 个 OOM。9020 的 GC 策略不同,内存回收不如 9030 积极,加上 9020 应用可用内存上限更低。内存回收效果跟芯片和系统版本强相关,9020 上我们的方案不够用,需要更激进的回收策略(比如每个场景都等 GC 完成才加载下一个,牺牲流畅度换稳定)。
总结一下下
- 卸载场景不能只调 release,要手动释放纹理等附加资源
- CPU 内存回收依赖 GC,时机不可控,要主动监控内存阈值
- 预加载范围 ±1,保留范围 ±2,平衡流畅度和内存
- 快速滑动时加载不可取消,用取消标记避免加载完挂到场景树
- 同时渲染场景数不超过 3 个,避免 fillrate 过载掉帧
- 重建时暂停列表预加载,避免重建内存和渲染内存叠加 OOM
- 内存回收效果跟芯片相关,9020 上需要更激进策略
- 接近内存阈值时降级为图片展示,不要硬加载
优化计划
我们在试 LRU 缓存策略替代当前的"可见范围 ±N"策略。LRU 按最近访问时间淘汰场景,不受滑动位置限制。好处是用户来回滑动时已加载的场景不重复加载,坏处是 LRU 的淘汰时机跟用户预期可能不一致——用户想看的场景刚好被淘汰了。
另一个方向是场景分级加载:先加载低精度版本(高斯点数 1/10),用户停留 500ms 后再加载高精度版本。低精度版本内存只占 3-5MB,可以同时缓存更多场景。这个方案需要重建时生成多精度版本,目前 Spatial Recon Kit 不直接支持,要自己后处理。
更多推荐


所有评论(0)