网易首页 > 网易号 > 正文 申请入驻

切场景卡顿200ms?深挖Resources.UnloadUnusedAssets底层优化全方案

0
分享至


【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!

这是侑虎科技第2008篇文章,感谢作者其乐陶陶供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:

https://www.zhihu.com/people/jun-yan-76-80

前言

Resources.UnloadUnusedAssets是Unity项目里一个绕不开的卡顿源:每次切场景,它都会在主线程上同步跑完一整套Native资源回收,200-300ms的耗时几乎是常态,低端机甚至到秒级别。而它名称标注的“异步”并不能把任何工作真正挪出主线程。

下面分四步拆:执行机制、耗时构成、可优化的环节、具体的改造方法。最终给出两套互不冲突的优化方案,实测能把整体耗时减少70%左右

一、Resources.UnloadUnusedAssets的作用

1.1 它要解决的问题

写过Unity的都遇到过这种代码:

GetComponent 

 ().material.color = Color.red; 

这一行看着无害,实际触发了一次Material Clone。Renderer.material的Getter不直接返回Shared Material,而是检查“这个Renderer是否已经持有自己的Material实例”,没有就Clone一份。Clone出来的实例,生命周期挂在Renderer 上。

问题来了:Renderer销毁了,Clone出来的Material谁来回收?C# 这边没人引用它了,但它是个NativeObject,GC.Collect()管不到。

Resources.UnloadUnusedAssets就是来收这个尾的。它扫一遍所有还活着的NativeObject,找出那些“既不被C# 持有、也不在场景里”的资源,挨个释放。


1.2 还在哪里被触发

主动调用是一种,但更隐蔽的是:每次SceneManager.LoadScene(非Additive模式),引擎自动调一次。这是写在PreloadManager里的固定流程,关不掉。也就是说,只要项目用Unity切场景,这个API就会规律性地造成固定卡顿开销。

1.3 规避的做法

其实上面两个例子,UnloadUnusedAssets的调用并非完全不可规避:Clone出来的Material可以自己持有引用手动销毁,切场景的那次触发也可以稍微改造引擎去掉。但实际项目有历史负担,也要控制内存峰值,在我面对的业务场景下回避不了,只能优化。

二、它做了什么:执行流程与异步真相


一次UnloadUnusedAssets耗时拆解

UnloadUnusedAssets的内部流程,可以打这么个比方:它像物业管理员搞一次小区普查,目的是腾出空房子。整个流程分五步,每一步对应一个具体阶段。

2.1 五个阶段

第一步:列住户名册:FindAllLiveObjects打开物业系统,把所有登记在册的住户(Object::IDToPointerMap里的所有对象)抄一遍,生成liveObjects[]。

第二步:确认门牌种子:MarkSceneRootsAndReduceLiveObjects找出哪些住户是“明确不能动的” —— 场景里挂着的GameObject、MonoBehaviour、AssetBundle等,以及MarkManagerRoots找出的全局Manager。这些标成Root,索引加入initialNeedsProcessing队列,作为追踪起点。

第三步:建索引:CreateObjectToIndexMappingFromNonRootObjects为剩下的对象建一张“InstanceID → 索引”的哈希表。后面要频繁通过InstanceID找对象,有这张表能在O(1)查到。

第四步:把C# 那边的根也加进来,然后挨家敲门:MarkManagedStaticVariableRoots + MarkAllDependencies

这一步是两个连续动作,合起来占全流程接近九成:

4a.MarkManagedStaticVariableRoots(=GC.MarkStaticDependencies,~49%):调IL2CPP的Liveness::FromStatics(),遍历所有C# 静态字段、递归walk整个托管堆,把发现的Native对象加入needsProcessing队列。这一段全程在C# 托管堆里跑,没碰Native图遍历。

4b.MarkAllDependencies(=GC.MarkNormalDependencies,~39%):一次性消费两个队列 —— initialNeedsProcessing(场景根/Manager根)和needsProcessing(FromStatics新发现的根),递归MarkDependencies,把所有可达Native对象Marked=1。

第五步:封空房:CleanupUnusedObjects扫一遍,所有没被标记到的对象,挨个释放Native内存。

2.2异步的真相:这个API不是真异步

到这里有人会问:这个API不是异步的吗?返回的是AsyncOperation,这么大的耗时不是应该挪到子线程去跑、不阻塞主线程吗?不是。它的“异步”只是延迟一帧

  • 调用时,把操作加入PreloadManager队列,立刻返回

  • 下一帧的PreloadManager.UpdatePreloading主线程执行,触发IntegrateMainThread()

  • IntegrateMainThread里把整个GarbageCollectSharedAssets()跑完 —— 主线程独占

  • 子线程那边只做了一件小事:关一下文件流,占比可忽略

也就是说,所谓“异步”,只是把卡顿推迟到下一帧;主线程并没有逃脱阻塞。

2.3 案例

在一个MOBA项目中,Loading末尾调了一下Resources.UnloadUnusedAssets,目的是把Loading期间临时加载的资源回收一下,避免战斗场景内存压力大。调用是fire-and-forget的:

// 加载各种资源
DoBattleLoading();
// Loading 末尾
Resources.UnloadUnusedAssets();
// 接下来发送自己加载结束的消息
SendMarkLoadingComplete();

听起来很合理。但上线后发现一件诡异的事:不同玩家入局时间不一样。有的玩家进战斗场景立刻能动,有的玩家会卡一会儿才能动。

排查到最后定位到这个API。原因是:UnloadUnusedAssets的Integrate时机依赖PreloadManager的调度,它什么时候在主线程跑,引擎说了算,Integrate一般会推迟执行。

结果就是Integrate漏掉了战斗场景刚开始的那一帧。这一帧主线程被GC阻塞了200ms,玩家看到的就是“刚进入战斗就卡了一下”。这种漏出的发生概率和设备性能、当前帧的工作负载、场景切换的Loading速度都有关。低端机更容易漏掉战斗场景,高端机偶尔也会中招,一场对战10个玩家,每个人入局时间都不一致。

2.4 把控制权拿回主线程

把引擎里现成的WaitForAllAsyncOperationsToComplete暴露给脚本层就够了。这个方法在PreloadManager一直存在,只是没暴露到C#。也可以用协程轮询AsyncOperation.isDone,等做完再继续。

Loading末尾改成:

// Loading 末尾
Resources.UnloadUnusedAssets();
// 主线程同步等到所有 PreloadManager 队列里的异步操作完成
WaitForAllOperationsToComplete();
// 此时 GC 已经跑完,可以安全跳战斗
SendMarkLoadingComplete();

代价是Loading末尾多卡200ms,但卡在Loading玩家心理上能接受(进度条还没满),卡在战斗里不能接受(对战已经开始了)。这个改动不解决“为什么慢”,但解决了“什么时候慢”,把不可控的延迟变成可控的延迟。

「慢」本身怎么解,下一节开始处理。

三、标记跳过:让扫描器学会绕路

前面我们提到,第四步两个子项里,MarkStaticDependencies(4a)单独占总耗时35-45%,是最大的单点热点

为什么4a这么慢? 它要做的是IL2CPP层面的Liveness扫描:

  • 拿到所有有静态字段的Class

  • 每个Class的每个静态字段,如果是引用类型,就读出来

  • 引用指向的对象,继续遍历它的所有字段

  • 字段里的对象是数组,数组每个元素再遍历

  • 一直递归下去,直到没有新发现

时间复杂度大概是O(类数量×静态字段数×引用深度×数组元素数)。一旦项目代码量上来,这个数字就爆炸了。实测一个中等规模MOBA项目,单这一步就要50-100ms。

我的直觉是,我们游戏C# 层存在大量配置表对象,这块确保无Native对象,就没必要扫,一定是有明显的优化空间的,所以,可以进行一些标记,让扫描器看到不必扫的字段就跳过

听起来简单,做起来要绕过三道坎。

3.1 第一道坎:运行时判断不能慢

最朴素的想法是给字段挂个[SkipLiveness] Attribute,扫描时查一下:

if (Reflection::HasAttribute(field, SkipLivenessAttribute))
continue;

HasAttribute涉及二分查找,O(log n) 一次。每个字段每次GC都查一遍,一千个类、几万个字段,加起来就是几十毫秒 —— 优化没做出来,先把扫描搞慢了。

要做就要做到O(1)。

3.2 第二道坎:在哪里挂这个标记位

IL2CPP里描述每个字段的元数据是Il2CppType结构,其中有个Attrs字段,16位:

unsigned int attrs : 16;  // 字段属性 bitmask

这16位定义在il2cpp-tabledefs.h里,有STATIC、LITERAL、HAS_DEFAULT等等。但其中有几个位是闲置的:


借位3,定义成:

#define FIELD_ATTRIBUTE_SKIP_LIVENESS  0x0008

扫描时一个位与判断就完事:

if (field->type->attrs & FIELD_ATTRIBUTE_SKIP_LIVENESS)
continue;

零内存开销,扫描时多一次判断。

3.3 借位安全吗:TaggedPointer同源思想

借空闲位不是新发明,业界用了几十年了。最经典的例子是IL2CPP Liveness自己用的TaggedPointer。

64位指针对齐到8字节,每个合法对象地址的最低3位永远是0:

0x7fff12340000  ← 末位 0
0x7fff12340008 ← 末位 0
0x7fff12340010 ← 末位 0

IL2CPP直接借最低位做“已访问”标记:

#define MARK_OBJ(obj)   (obj)->klass = ((obj)->klass | 1)
#define IS_MARKED(obj) ((obj)->klass & 1)
#define GET_CLASS(obj) ((obj)->klass & ~1)

零内存开销,完全不需要额外的哈希表记录“哪些对象访问过”。

借Attrs的位3也是同样的思路:这个位本来就闲着,使用前后保持向下兼容,所有读写Attrs的地方都按位掩码取自己关心的位。

未来要注意的是IL2CPP升级时,如果官方启用了位3表示别的语义,就要换一位。维护成本是每次跨版本升引擎时检查一下il2cpp-tabledefs.h的位定义有没有冲突。

3.4 第三道坎:GC描述符的暗坎

按上面的方案改完,直接挂在Static字段上的 [SkipLiveness] 是生效的—— 这正是3.2方案能解决的核心场景。

写法:静态字段直接标记(生效)

public class ConfigManager
{
[SkipLiveness]
public static List _configCache; // 跳过这个静态字段的 Liveness 扫描


[SkipLiveness]
public static Dictionary _strTable; // 同上
}

Liveness跑到ConfigManager时,FromStatics迭代它的所有静态字段,在FieldCanContainReferences里查field.attrs & FIELD_ATTRIBUTE_SKIP_LIVENESS,命中就直接Continue。这两个字段连同它们引用的整张对象图,全部不再扫描—— 这正是省下耗时的来源。

但在实战中,业务代码不会这么干净。更常见的写法是把多个字段封装到一个数据类里,然后Static字段持有这个类的实例。这时候问题就来了:把 [SkipLiveness] 标记到嵌套实例字段上,无效

写法:嵌套实例字段标记(失效)

public classTestData
{
[SkipLiveness]
public List testList; // 实例字段,标记无效
[SkipLiveness]
public Dictionary dict; // 同上,标记无效
}

publicclassManager
{
publicstatic TestData data; // 静态字段(第一层)
}

FromStatics进入Manager.data后,Liveness拿到TestData实例,然后期望按字段上的 [SkipLiveness] 跳过testList和dict。但实测下来testList 还是被完整遍历了一遍 —— 挂在实例字段上的标记完全无效

为什么失效:GC描述符的快速路径

刨根挖到Liveness::TraverseGenericObject:

size_t gc_desc = (size_t)(GET_CLASS(object)->gc_desc);
if (gc_desc & 1)
TraverseGCDescriptor(object, state); // 走快速路径(按位图扫)
else if (有数组)
TraverseArray(...);
else
TraverseObject(...); // 走慢速路径(逐字段查 attrs)

问题出在gc_desc(GC描述符)。它是IL2CPP给每个Class预生成的一张位图,按字段偏移记录“哪些位置是引用类型”。

class TestData 内存布局     gc_desc 位图
──────────────────── ──────────
offset 0: [object header] 0
offset 8: [List] ──► 1 ← 引用,需扫描
offset 16: [Dictionary] ──► 1 ← 引用,需扫描
offset 24: [int field] 0 ← 值类型,不扫

实例对象走TraverseGCDescriptor时,直接按位图偏移取引用、加入待处理队列,完全不经过字段元数据,field.attrs上挂的SkipLiveness位没人查。所以失效。

静态字段没有gc_desc这层快速路径,走的是FromStatics → 字段元数据迭代,所以field.attrs标记对静态字段是生效的。一个走快速路径、一个走慢速路径,造成了行为差异。

那把gc_desc也改一下,把SkipLiveness字段从位图里去掉,行不行?不行。

gc_desc不只Liveness在用,Boehm GC分配内存时也用它

if (klass->gc_desc != GC_NO_DESCRIPTOR)
o = AllocateSpec(klass->instance_size, klass); // 把 gc_desc 传给 Boehm

Boehm GC通过gc_desc知道对象内部哪些位置是引用,GC标记阶段才能精确扫描这些位置。如果在gc_desc里把SkipLiveness字段排除掉,Boehm GC也会跳过这些字段,字段引用的对象就不会被标记为存活,然后被错误回收会导致野指针,崩溃。

gc_desc是禁区,不能动。

解决方向:换一层挂标记

字段级标记走不通,那就整个类一起挂 —— 既然TraverseGCDescriptor是按Class派发的(GET_CLASS(object)->gc_desc),那么在判断走不走快速路径之前,先在类级别拦一道:这个类整体跳过。

写法:类级标记

[SkipLiveness]                            // 标记到类上
publicclassConfigPayload
{
public List testList; // 类内字段都不再被 Liveness 扫描
public Dictionary dict;
public NestedConfig nested; // 嵌套引用也不会再下钻
}

publicclassManager
{
publicstatic ConfigPayload payload; // 静态字段持有这个纯数据类
}

业务侧的用法很简单:把所有需要跳过Liveness的纯数据字段,封装到一个独立的类里,在类上挂 [SkipLiveness]。

3.5 解法:类级跳过

绕过快速路径的办法,是在更高一层拦截。在Il2CppClass.flags(32位)里再借一位:

#define TYPE_ATTRIBUTE_SKIP_LIVENESS   0x00400000

然后在AddProcessObject入口判断:

bool skip = (GET_CLASS(obj)->flags & TYPE_ATTRIBUTE_SKIP_LIVENESS) != 0;
bool has_references = obj->klass->has_references && !skip;

整个类的实例对象都不递归遍历,但gc_desc一个字节都不动,Boehm GC仍然按原样精确扫描。

这样一共有三个拦截点,组成一个完整的跳过体系:

  • 静态字段扫描入口(类级):整个类的所有静态字段一起跳

  • 字段级判断:单个静态字段的Attrs检查

  • 实例对象扫描入口(类级):这个类的实例不再递归

并且整套机制可以通过运行时开关动态控制,线上出问题可以一键关掉。


IL2CPP代码生成器要配合改。

这两个标记位(field.attrs位3和klass.flags位22)是IL2CPP在编译时写入的。所以只改Liveness.cpp不够,IL2CPP代码生成器(C# 写的Unity.IL2CPP那一套)也要改。

要改的地方有两个钩子。

钩子1:字段级Attrs —— 在Il2CppTypeCollectorComponent.Add之前,扫一下字段的CustomAttributes,如果命中 [SkipLiveness] 就把Attrs加上位3再传进去:

// 伪代码:在生成 Il2CppType 元数据时
int attrs = (int)field.Attributes; // 原本的 STATIC / LITERAL 等
if (HasSkipLivenessAttribute(field))
attrs |= 0x0008; // FIELD_ATTRIBUTE_SKIP_LIVENESS
Il2CppTypeCollector.Add(fieldType, attrs);

钩子2:类级Flags —— 在生成Il2CppClass元数据时,扫类的CustomAttributes:

// 伪代码:在生成 Il2CppClass 元数据时
uint flags = ComputeOriginalClassFlags(typeDef);
if (HasSkipLivenessAttribute(typeDef))
flags |= 0x00400000; // TYPE_ATTRIBUTE_SKIP_LIVENESS
EmitClassFlags(typeDef, flags);

两个钩子都是几行代码,但找位置要花点功夫,Unity.IL2CPP项目里生成元数据的入口分散在几个Component里,需要顺着Il2CppTypeCollectorComponent、MetadataWriter这些类去找具体的字段/类元数据写入点。

最后顺手定义一下C# 侧的Attribute,让业务代码能用:

namespace UnityEngine
{
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Class)]
public class SkipLivenessAttribute : Attribute { }
}

3.6 适合什么、不适合什么

这个优化的安全性,取决于“被标记的对象是否会持有非场景内的Native引用”。

为什么强调“非场景内”?因为场景里的GameObject、Component、以及挂在它们上的Material/Texture,在流程里走的是另一条扫描路径 —— MarkSceneRoots会从场景根开始把它们全部keep住,不依赖C# 静态字段这条路径。也就是说,即便你的类持有一个场景里的GameObject引用,跳过C# 这边的扫描也不会让它被误回收,因为场景那条路径已经替你兜底了。

真正危险的是游离于场景之外的Native引用:Resources.Load出来但没挂到场景里的Texture、AssetBundle加载后缓存在C# 字典里的Material、动态生成的Mesh等。这些只靠C# 静态字段链路引用,标记跳过后就会误删。

适合:

  • 游戏内的配置表(纯数据)

  • C# 帧同步层对象(有放服务器跑的诉求,这一层天然不能持引擎Native引用)

  • 客户端缓存的其它大量纯数据结构,比如一些服务器协议对象

不适合:

  • 持有Resources.Load/AssetBundle加载且未入场景的Native引用的对象

  • 持有动态生成、未挂到场景的Material/Texture/Mesh引用的对象

  • 其它任何你拿不准、不熟悉的对象

加错了的后果是:这些游离Native资源被错误回收,运行时崩溃。这也是为什么这个优化要谨慎,一刀切打到不该打的对象上,死状很惨。

四、多线程优化:扩大并行窗口期

用多线程榨多核算力是常规思路。Unity 2019里多线程优化的雏形已经在了,但官方没写完,用宏隔离关闭着;Unity 2022已经延续开发并默认开启。

所以可以借助AI把2019的多线程优化直接「补全」,但默认方案仍有很大的改造空间。

4.1 默认的多线程优化方案

整个流程核心是优化MarkManagedStaticVariableRoots + MarkAllDependencies这两段的耗时。先把它们做的事说清楚:

  • MarkManagedStaticVariableRoots(简称FromStatics):从所有C# 静态字段出发,沿引用链递归遍历整个C# 托管堆,把过程中遇到的Native对象InstanceID加入needsProcessing队列。全程在托管堆里跑,不进Native图

  • MarkAllDependencies:消费needsProcessing队列,在Native对象图上做BFS —— 取一个Native对象、读它的引用、新发现的加回队列,直到队列空,所有可达对象Marked=1。

注意MarkAllDependencies这个函数其实是被调用两次的:

  • 第一次,主线程进FromStatics之前先派发一次给Worker —— 让Worker提前消费已经准备好的initialNeedsProcessing(场景根+Manager根+External根这批“提前知道存活的种子”)

  • 第二次,主线程在FromStatics跑完之后自己再调一次 —— 消费mainThreadState.needsProcessing(也就是上面FromStatics边扫边写入的那张清单)

所以严格说,这两段是同一个函数体的两次执行,但消费的是两个不同来源的队列

调度顺序是这样的:


把Unity 2019的多线程改出来后,实测主要问题是和FromStatics并行的那段MarkAllDependencies太短:Worker处理的是初始根(场景根+Manager根),对象数量不够多;主线程的FromStatics更长,Worker跑完之后整段闲置

更令人不是滋味的是,FromStatics过程中发现的对象数量挺多,之后主线程独自MarkAllDependencies(mainThreadState)时间也很长,这段是单线程的,Worker全在旁边看着


改造方向:加一个跨阶段共享队列staticNeedsProcessing,让Worker跑完初始根不退出,继续消费FromStatics实时写入的新Root,把Worker的闲置窗口压到接近零。

4.2 生产者-消费者模型

要让工作线程跑起来,需要打破“主线程等Job → 主线程跑FromStatics → 工作线程空闲”的串行结构。改造目标:

  • 主线程派发多线程任务后不等,立刻开始跑FromStatics

  • FromStatics每发现一个新对象,实时投放到一个共享队列

  • 工作线程消费这个共享队列

  • 主线程跑完FromStatics才同步等待

这是经典的生产者-消费者模型。

4.3 共享队列的设计

场景是1个生产者(主线程FromStatics回调)+N个消费者(Worker),生产消费同时发生,容量上界已知(liveObjects.size()),生命周期只到本次GC结束。

读写索引分离

引擎默认多线程做MarkDependencies的队列方案是单个Count计数器,生产者Inc、消费者Dec。我们这里需要改成读写索引分离的,因为会涉及同时生产和消费。同一个值同时被多方修改、同时承担下一个写位剩余多少两种语义,一旦消费者Dec到负数想回滚、生产者又刚好Inc,Slot就会被复用,读到错乱数据。

正确做法是单调单写:


两个索引各自单调递增,writeIndex - readIndex永不为负,Slot不会被复用。

伪代码

// 生产者(主线程)— 无并发,无需 CAS
buf[writeIndex] = index; // 先写数据
atomic_store(&writeIndex, writeIndex + 1, release); // 再发布索引


// 消费者(Worker)— 多消费者用 CAS 抢 slot
for (;;) {
int r = readIndex;
int w = atomic_load(&writeIndex, acquire);
if (r >= w) {
if (atomic_load(&scanComplete, acquire) && readIndex >= writeIndex)
break; // 真的没了
YieldProcessor(); continue;
}
if (!CAS(&readIndex, r, r + 1)) continue; // 抢失败重来
MarkDependencies(buf[r]); // children 进本线程私有队列
}

关键点:

  • 写顺序:数据先落,索引后发布。Release保证消费者Acquire读到新writeIndex时数据已可见

  • CAS抢readIndex:多消费者只有一个能成功,失败重试,不需要锁

  • 不用Ring Buffer:容量按上界Reserve,索引单调推进到底就停;两个索引每次GC前归零

  • 和JobSystem无锁栈的设计很相似

4.4 改造后的流程

引入共享队列后,Worker处理完初始根不再退出(Thread::YieldProcessor),FromStatics边遍历边把新发现的Root实时写入共享队列,Worker一直在消费,主线程的FromStatics一结束就也过来一起干活。


测下来主线程、Worker几乎全程满载,并行度大幅提高。

4.5 Unity 2019 vs 2022的差异处理

如果用的是Unity 2022,前面这套生产者-消费者模型基本就是2022已有架构上的扩展,改动相对集中。如果用的是Unity 2019,要做的事情更多。但这里有个判断要做:完全照搬2022的实现不一定划算,要平衡改动量和优化效果

下面几个2022的设计选择,在2019里可以用更简化的方案。

不必单开线程池

Unity 2022里有个AGCThreadPool,几个专用线程只服务资源回收流程,不参与JobSystem调度。

专用线程的好处是“不被其他工作干扰”。但仔细想想:UnloadUnusedAssets期间,整个硬件资源本来就高度集中在这一件事上 —— 主线程在跑GC,业务逻辑也卡住了,JobSystem也基本没别的活干。这时候专用线程的“隔离”优势就消失了。

代价倒是实打实的:每开一个线程多一份栈内存(默认1MB起)。在内存压力本来就大的移动设备上,这是浪费。2019里直接复用JobSystem的Worker线程即可。JobSystem的work-stealing机制会自己处理负载均衡,业务空闲时这些线程也能去做别的事。

IL2CPP内部缓存可以简化

Unity 2022在IL2CPP扫描时用了两个特殊的数据结构:

  • DynamicHeapAllocator(引擎自己的内存分配器)替代malloc/free。优点是统一追踪、TLSF算法分配快、减少碎片化。

  • CustomGrowableBlockArray(分块数组)替代连续数组。优点是Grow时只新分配一个Block,不需要把整个数组复制到新地址。

这两个优化都很合理,但都是非核心的优化,单次触发的内存分配次数有限,大部分时间不在分配上。

2019里可以简单替换:

  • 内存分配:直接用OS默认的malloc/free,并且在确定了内存分配方案后,不需要涉及GC堆的stop/start world处理,能规避一个死锁的问题

  • 数组容器:维持连续数组(std::vector-like),Grow时复制一次,可接受

这两个简化省下的代码量是几百行,对最终效果的影响在1-2ms量级,不值得为了这点收益去维护两套数据结构的兼容代码。

五、效果验收

这两个优化在不同的设备、不同项目,甚至同一项目的不同时机均有较大的差异。只能提供一些参考数据,在1+8pro(晓龙865、4 Job Worker)上,在一个耗时较重的时机,优化前要200ms,优化后仅需50ms,优化幅度达75%。


未开优化完全交给主线程,200ms左右


开启优化,Job Worker负载上来了,

50ms内搞定

六、一些想法

截至写本文时,前面的两个方案(标记跳过+多线程窗口扩大)已经在项目里落地,下面是之后回头的一些想法。

6.1 把【加不加标记】从人脑里拿出来

整个是否加标记的判断标准:“这个类的字段里会不会摸到非场景内的UnityEngine.Object”,说白了就是人工做可达性分析,这件事是这套方案最脆的环节:

  • 漏审一次,Native资源被误回收,运行时崩溃,且崩溃点离标记点很远,排查成本极高

  • 收益受限于“敢标的范围”,不敢标的部分继续被扫,优化天花板被人压低

但“类型字段闭包能否到达UnityEngine.Object”在数学上是个纯静态可达性问题,IL2CPP在生成元数据时拿得到完整类型图,完全可以自己算出来。

可行的做法是在IL2CPP代码生成器里插一个Pass:

// 简化伪代码
bool CanReachUnityObject(TypeDef t, HashSet visited ) {
if (!visited.Add(t)) returnfalse;
if (t.IsSubclassOf("UnityEngine.Object")) returntrue;
foreach (var f in t.InstanceFields) {
var ft = f.FieldType.Resolve();
if (ft == null) returntrue; // 不可解析:保守
if (ft.IsInterface || !ft.IsSealed) returntrue; // 多态:保守
if (CanReachUnityObject(ft, visited)) returntrue;
}
returnfalse;
}


// 对所有 sealed 类 / struct,闭包不可达 UnityEngine.Object 的,
// 自动给 Il2CppClass.flags 打上 TYPE_ATTRIBUTE_SKIP_LIVENESS

这样:

  • 配置表、纯数据Struct、协议结构体,绝大多数Sealed类型自动识别,不漏不错

  • 多态字段(Object、Interface、未Sealed基类)无法静态决定,保守不打,继续走原扫描,安全的部分自动加速,不安全的部分原样跑

  • 手工Attribute退化成“针对静态分析判true、人工确信安全的多态字段”再加

挂载点和3.5改的位置完全一致,MetadataWriter写出Il2CppClass.flags之前的Type Pass。改造工作量在数百行代码量级,但能把“人审”这个成本移走大部分,而且预期会有更高的收益。

6.2 怎样才能做好资源管理?

回到更上层的问题:Resources.UnloadUnusedAssets这个API之所以存在,是因为引擎需要替我们补一个所有权模糊的窟窿,磁盘加载的Asset没有显式的Owner,Instance Asset也没人负责,只能定期反扫一次。

主流资源框架(Addressables/YooAsset/XAsset/ResKit等)做的事:在脚本层把这个窟窿堵上


资源框架在加载入口Load+1、Release-1、归零卸载,把“磁盘Asset的生命周期”从Unity手里抢回来,用Refcount做显式管理。

这一层一旦到位,反过来给“标记跳过”提供了正当性 —— 留在C# 静态字段图里的,绝大部分就只剩纯数据了(配置表、帧同步状态、缓存字典、协议结构)。这些天然不持Native引用,正好是SKIP_LIVENESS最安全、最适合自动标记的对象。3.6那条判断标准在框架接管之后,从“逐个审”退化成“基本可以全打”。


理想的工程实践

三层各管一段,合起来才是这个时代Unity项目能拿到的“实际最优解”:

  • 资源框架解决“该谁释放谁释放”的所有权问题:这是结构性的,只能从加载入口前移。

  • 本文的两个优化解决“剩余必须留给引擎兜底”的部分,扫描尽可能快:这是局部的,但在不能改API的前提下已经是上限。

  • HideFlags解决“即便前两层判错,关键资源不丢”:这是兜底的,成本极低,价值在于把“判错”的后果从崩溃降级为内存多占。

6.3 横向对照:UE是怎么处理同一类问题的

回头看6.1和6.2提的所有方向(自动标记、显式所有权、资源框架、Native兜底),会发现UE都有现成的对应机制。UE也有同一类问题:GC可达性扫描卡帧在UE社区同样是著名痛点,社区整理的Epic资料[1]记载,Fortnite靠Blueprint Clustering才把PS4上的GC Mark耗时从~66ms压到~22ms。

真正的差别在还债的成本,同一类补丁,UE做起来结构性地便宜。

对应关系大致这么排:


为什么同一类补丁,UE便宜、Unity贵?

根因在对象模型:UE是一张图,Unity是两个世界

UObject一开始就是GC-aware设计(对象系统在设计之初,就把“垃圾回收要怎么扫我”这件事考虑进去了)。UPROPERTY是显式声明,UHT在编译期产出完整反射信息,运行时据此组装Reference Token Stream、构建Cluster、支持Weak Pointer,每一轮GC补丁都是在引擎完全掌控的元数据上做增量改进。

Unity的对象系统则是native-first:对象归C++ 管,C# 侧只是持m_CachedPtr的Wrapper,对象所有权从未交给脚本层。要说清的是,Unity的Native半张图其实有自己的Token Stream等价物,MarkAllDependencies靠序列化元数据遍历Native引用,这部分并不慢;真正缺显式声明的是Managed侧的引用,它们散落在一个引擎读不懂的托管堆里,只能整堆反向扫描。这也正好解释了为什么本文的优化火力全部打在FromStatics 上:债务只欠在半张图上。

换句话说:200ms不是Unity独有的,而是全图可达性扫描这个通病在双对象世界里的重症形态,每一分同类优化都得先跨过IL2CPP这道墙;DOTS选择的则是绕开对象模型、躲掉GC,而不是把对象系统改造成GC-aware。我们这种【在传统GameObject世界里贴补丁】的工程,就是这个时代的常态。

参考

[1] Epic 资料

https://ikrima.dev/ue4guide/performance-optimization/asset-size-loading/

文末,再次感谢其乐陶陶的分享, 作者主页:https://www.zhihu.com/people/jun-yan-76-80, 如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群: 793972859 )。

近期精彩回顾

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
“中国首胖”为爱切胃暴瘦480斤,成名后出轨抛妻,如今下场唏嘘

“中国首胖”为爱切胃暴瘦480斤,成名后出轨抛妻,如今下场唏嘘

健身狂人
2026-08-21 14:36:58
许家印结局已定!5个子女近况曝光,3个女人被他牵连,白珊珊最冤

许家印结局已定!5个子女近况曝光,3个女人被他牵连,白珊珊最冤

180视角
2026-08-21 10:08:59
今秋要见金正恩,特朗普首次公开谈论朝鲜核武器数量,避谈对朝无核化谈判立场

今秋要见金正恩,特朗普首次公开谈论朝鲜核武器数量,避谈对朝无核化谈判立场

大风新闻
2026-08-21 11:08:16
杭州酒局事件持续发酵,女主照片遭曝光:她就是一道诱人的“菜”

杭州酒局事件持续发酵,女主照片遭曝光:她就是一道诱人的“菜”

汉史趣闻
2026-08-21 09:36:10
国防部下通牒,解放军拖船已到位!美国航母跑路,菲律宾这回惨了

国防部下通牒,解放军拖船已到位!美国航母跑路,菲律宾这回惨了

健身狂人
2026-08-21 12:15:17
男子突然离世,同事自发资助其妻儿5年!如今孩子考上大学,公司:承担所有学费

男子突然离世,同事自发资助其妻儿5年!如今孩子考上大学,公司:承担所有学费

上观新闻
2026-08-21 20:20:27
局势彻底失控!俄方正式下达终极通牒,首个北约参战国被实锤锁定

局势彻底失控!俄方正式下达终极通牒,首个北约参战国被实锤锁定

鹤羽说个事
2026-08-21 10:20:15
奇怪的热搜:1.2亿农村老人谁来为他们发声

奇怪的热搜:1.2亿农村老人谁来为他们发声

走读新生
2026-08-20 22:38:31
1947年,张灵甫阵亡后,粟裕清点战场惊出一身冷汗,当即下达两道命令

1947年,张灵甫阵亡后,粟裕清点战场惊出一身冷汗,当即下达两道命令

磊子讲史
2026-08-20 14:39:49
“神药”7年坚持虚假宣传,这次被媒体扒了

“神药”7年坚持虚假宣传,这次被媒体扒了

智识漂流
2026-08-21 18:31:32
4400亿后,王兴兴的办公室只剩他一个人

4400亿后,王兴兴的办公室只剩他一个人

木禾商业财经
2026-08-21 10:13:40
内地游客在香港遭遇 “网约车刺客” 6.5 公里收 584 元!网上曝光浏览破10万后平台退回差价,最后惊动港府:已将司机资料交警方跟进

内地游客在香港遭遇 “网约车刺客” 6.5 公里收 584 元!网上曝光浏览破10万后平台退回差价,最后惊动港府:已将司机资料交警方跟进

澳门月刊
2026-08-21 10:14:22
《黑神话:钟馗》15分钟实机首曝 新主角弹反干净利落

《黑神话:钟馗》15分钟实机首曝 新主角弹反干净利落

热搜摘要官
2026-08-20 18:06:13
48小时神话破灭!两只千元新股集体崩跌 宇树科技回撤近40% AI硬科技估值泡沫加速出清

48小时神话破灭!两只千元新股集体崩跌 宇树科技回撤近40% AI硬科技估值泡沫加速出清

金融界
2026-08-21 14:54:18
3天回撤超40%!宇树科技千元追高散户全套牢

3天回撤超40%!宇树科技千元追高散户全套牢

慧眼看世界哈哈
2026-08-21 12:22:08
北大教授张丹丹“灵活就业是福利,我们朝九晚五牺牲自由”引热议,胡锡进劝道歉,南大博士回怼

北大教授张丹丹“灵活就业是福利,我们朝九晚五牺牲自由”引热议,胡锡进劝道歉,南大博士回怼

东东趣谈
2026-08-21 11:56:23
久违亮相!梅拉尼娅现身白宫玫瑰园,风采依旧状态十分亮眼

久违亮相!梅拉尼娅现身白宫玫瑰园,风采依旧状态十分亮眼

墨薷桃桃
2026-08-21 15:59:12
替补绝平!西甲33岁名将杀疯了:69分钟2球2助 脱衣秀肌肉

替补绝平!西甲33岁名将杀疯了:69分钟2球2助 脱衣秀肌肉

叶青足球世界
2026-08-21 10:41:59
“她是桃花眼,前途不敢估量”,女孩考上中山大学,面相火了

“她是桃花眼,前途不敢估量”,女孩考上中山大学,面相火了

世界圈
2026-08-21 09:05:40
墨西哥9人海边度假被砍掉手掌,尸体堆成山,车里成“屠宰场”

墨西哥9人海边度假被砍掉手掌,尸体堆成山,车里成“屠宰场”

阿尺量世界
2026-08-20 17:45:21
2026-08-21 22:39:00
侑虎科技UWA incentive-icons
侑虎科技UWA
游戏/VR性能优化平台
1615文章数 987关注度
往期回顾 全部

科技要闻

阿里AI的两面:云赚56亿,千问烧掉138亿

头条要闻

日本议员想访华自称"应中方邀请" 各方回应来了

头条要闻

日本议员想访华自称"应中方邀请" 各方回应来了

体育要闻

续哈登+招沃特森=骑士赌了

娱乐要闻

冯绍峰七夕带儿子游玩!次日聚会

财经要闻

蔡昉解读经济:扩大消费需求的政策思考

汽车要闻

15万级顶格大五座/盲订7天破万 荣威家越07亮相成都车展

态度原创

亲子
时尚
本地
数码
家居

亲子要闻

这5条一定要讲给孩子们听!

今日热点:真人版《火影忍者》选角;彭小苒承认恋情……

本地新闻

《牛来》的一声“妈妈”,到底多少人被精神污染了

数码要闻

JBL PULSE 6便携蓝牙音箱到手2499元:支持IP68、三维炫彩灯效

家居要闻

2026建博会(广州) 公装联探展交流活动

无障碍浏览 进入关怀版