大家好,我是阿赵。
这篇文章我想写了很久,是关于Unity项目使用AssetBundle加载资源时的内存管理的。这篇文章不会分享代码,只是分享思路,思路不一定正确,欢迎讨论。
对于Unity引擎的资源内存管理,我猜很多朋友都存在一定的疑惑。疑惑的点有非常多,包括资源怎样才能避免冗余,怎样才能不会在内存里面创建重复的资源内存,怎样才能在合适的时机把不需要的资源内存清理干净,什么时候能把AssetBundle本身清理掉。
在这方面,我觉得Unity官方一直没有给用户一个很好的指导。或者说,使用Unity进行开发的游戏研发厂商们对引擎的使用,有点超出了Unity自己的预想,导致对于复杂的游戏类型,Unity本身已经不能用单一的策略去处理,只能提供自己的API,让厂商自己想办法做管理策略了。
关于AssetBundle的使用基础,网上有很多文章介绍,我这里重点不是介绍基础用法,所以我会直接跳过介绍。然后我这里得出了很多测试结论,测试的过程我也不想逐个说明,因为基本上网上都有很多同类的测试。所以接下来,我会直接说出测试结果,然后提出我自己的解决方案。
一、资源对内存的占用分析
通过AssetBundle加载的资源,在游戏运行时会产生多种内存,比较明显的有以下几种:
1、AssetBundle本身的内存占用
如果是通过AssetBundle.LoadFromFile类方法加载,在没有LoadAsset之前,AssetBundle本身占用的内存很少,只是一个头文件的数据引用。
如果是通过AssetBundle.LoadFromMemory类方法加载,那么文件的二进制是完全读取进入内存的,所以AssetBundle文件越大,占用的内存就越大。
由于AssetBundle是互相依赖的,比如一个模型的预设AssetBundle,可能会依赖其他材质球、贴图、动画之类的AssetBundle,所以在加载主AssetBundle时,需要加载一堆依赖的AssetBundle。
值得注意的是,对于同一个AssetBundle,运行时是不可能重复加载的,如果尝试重复加载同一个AssetBundle会有报错。所以同一个AssetBundle是不存在同时占用多份内存的情况。
2、Asset资源的内存占用
这里的Asset资源,指的是通过AssetBundle.LoadAsset类方法,把AssetBundle里面的具体资源读取出来之后资源,比如预设Object、网格、材质球、贴图、动画、shader之类。
Asset的资源占用才是占用内存的主要部分。当资源被LoadAsset出来之后,它会从二进制文件反序列化变成网格数据、贴图数据等。资源本身的复杂程度越高,占用的内存就越大。
值得注意的是Asset的资源内存是可能重复的,而且出现重复的条件很多。比如有以下这些情况:
1.一张贴图同时被2个模型使用了,不同模型单独生成了AssetBundle,但这张作为共用依赖的贴图没有单独作为一个AssetBundle,而是同时包含在A、B两个模型的AssetBundle里面。那么运行时同时加载A、B两个模型,虽然看起来它们使用了同一张贴图,实际上内存里面是有2张重复的贴图的内存的。
2.AssetBundle.Unload(false)卸载之后,该AssetBundle内的资源还留在内存里,再重新加载同一个AssetBundle并LoadAsset资源,内存里面的资源就会有重复多份。
3、实例化对象的内存占用
这里指的实例化,是指通过Object.Instantiate把预设的Object在场景里面实例化成GameObject。GameObject在场景里面肯定是有内存占用的,但它对自身的资源比如网格、贴图等,只是一个引用关系,并不会复制多一份资源本身。所以实例化GameObject占用的内存不会很大。
二、资源管理在理想中想达到的目的
通过上面的分析,Asset内存占用是最大的,我们必须首先要避免重复,然后不用的时候要清除内存。
实例化GameObject的内存不大,但也应该在删除GameObject的时候及时清除。
AssetBundle的占用内存虽然同样不大,不过如果项目很庞大,加载的AssetBundle的数量很多,那么可能也会占用一定量的内存。我们肯定希望在一个AssetBundle不再使用的时候,把它Unload掉。
所以最理想的状态就是,当我们加载一个资源,会顺便加载该资源依赖的所有AssetBundle,然后当我们确定某个资源不再被使用了之后,就把它们在场景里面删除,卸载Asset本身的内存,然后卸载资源的AssetBundle和依赖的其他AssetBundle。这样内存就干净了。
这个看起来很清晰合理的目的,在Unity里面实现起来却并不那么容易,存在一些问题,下面会逐步分析。
三、清理资源内存的API
为了方便理解管理策略,先来明确一下几个不同类型的资源内存的释放API:
1、AssetBundle资源卸载
AssetBundle资源的卸载,是通过AssetBundle.Unload方法实现,可以传入true或者false。
如果传入true会把该AssetBundle里面所有已经加载的资源都卸载掉,是最干净的清理方式,这样不管之前LoadAsset过什么资源,都会直接被清理掉了。如果该AssetBundle的资源还在使用中,那么这些资源都将会丢失。
如果传入false,那么单纯AssetBundle本身的引用内存会被清理掉,但LoadAsset的资源会保留。所以使用这种方式卸载,就算场景里面还有使用资源,也不会丢失。相对的,清理不那么干净,需要自己判断正在使用的资源什么时候释放。
2、Asset资源内存释放
要卸载Asset的资源,有2个方法
1.Resources.UnloadAsset(Object assetToUnload)
这个方法可以有针对性的释放某个指定的Asset对象资源。
2.Resources.UnloadUnusedAssets()
这个方法是Unity自己管理的,Unity会遍历所有的资源,把所有没有被引用的资源卸载掉。这个方法消耗比较大,可能会导致游戏卡顿。
3、实例化资源内存卸载
而GameObject本身的内存,删除之后会根据GC回收。通过Resources.UnloadUnusedAssets()也能把不再使用的内存回收。
四、需要解决的问题
AssetBundle打包资源冗余的问题,之前我也写过好几篇文章去分析怎样做才是粒度最合理又没有冗余的。这里就不再重复,主要是说运行中加载卸载的问题。
从最理想的情况下来看,如果我们能判断得很准确,知道哪些资源已经完全没有用到了,那么直接调用AssetBundle.Unload(true),资源肯定就可以清理得很干净,也不需要管Asset的内存,也不需要Resources.UnloadUnusedAssets。
不过如果对判断资源使用情况的准确度不是那么的自信,一般是不敢直接AssetBundle.Unload(true)的,因为一个不好,判断错了,那么还在使用的资源就会突然间全部丢失了。所以一般的处理情况是AssetBundle.Unload(false)来清理AssetBundle,然后通过Resources.UnloadUnusedAssets来清理已经不再使用的Asset资源内存。
我在网上看了一些文章的经验分享,有不少的建议是,对于直接加载读取的资源,可以读取出来Asset的Object之后,把Object缓存,然后立刻对该AssetBundle资源AssetBundle.Unload(false),而对作为依赖加载的AssetBundle做计数器处理。
我个人认为,这个做法,对于主AssetBundle是prefab类的对象,是可以的。因为这些预制体都是需要实例化到场景里面,只需要在游戏里面做对象池,就可以很容易的知道资源是否还有在使用,也很容易做计数器。
但如果是对于纯资源类的AssetBundle,比如图片,那就不太行了。比如我从AssetBundle里面加载一张图片,然后立刻Unload(false),那么这张图片的内存在什么时候清理呢?
如果通过代码将图片存起来反复使用,那么很难判断图片现在还是否存活,举个例子,我把一张图片加载完之后赋予给了一个模型,然后模型本身被主动删除了或者切换场景被删除了,我们是不知道图片还在模型身上被删除的,除非每次模型被Destroy的时候,都遍历身上所有的Component,然后逐个资源类型去判断。那么被代码引用着的这张图片就永远不会回收。如果不用代码存起来,反复需要读取这张图片时,由于之前的AssetBundle被unload(false)了,如果重新加载AssetBundle,那么这张相同的图片,就会在内存里面生成多份不同的内存。
通过上面的分析可以看出,实际上我们需要解决的问题只有一个,就是怎样很准确的知道我们的资源是否还在存活,是否还被引用,是否可以释放。我们需要一个很准确的依据。
Unity本身肯定是知道的,因为在使用Resources.UnloadUnusedAssets的时候,Unity会把没用引用的资源都回收掉。但可惜的是,Unity没有提供直接的接口给我们查询。
五、弱引用计数
C#有一个弱引用(WeakReference)的机制,可以判断一个对象是否存活。
具体的用法是:
WeakReference weakRefObj = new WeakReference(obj);
然后通过weakRefObj.IsAlive可以判断该对象是否存活,然后通过weakRefObj.Target可以取到之前存进去的那个资源对象。
当一个对象在内存里面已经不存在的时候,这个弱引用对象的IsAlive会变成false,然后Target会变成null。
不过一般情况下,Asset本身是不会自然变成不存在于内存的,因为如果不是执行Resources.UnloadAsset或者Resources.UnloadUnusedAssets,资源并不会随着GameObject的删除而回收的。所以正常的情况下,想要弱引用能正确判断资源已经不存活,必须在删除GameObject时候,调用Resources.UnloadUnusedAssets。
所以如果正常的使用方法,应该是,用从AssetBundle里面LoadAsset得到的asset的对象,创建一个弱引用对象,然后如果需要使用该对象作为资源或者实例化对象的时候,先判断弱引用资源的IsAlive和Target,在保证IsAlive为true,并且Target不为null的情况下,可以取得Target去使用。如果IsAlive为false或者Target为null时,证明这个弱引用关联的资源已经没有任何人在使用了,就可以删除当前的弱引用对象。
不过这个弱引用对象,在Unity里面使用是有问题的,在Unity的自带文档的Overview of .NET in Unity页面里面,有这么一句说明:
Unity does not currently support the use of the C# WeakReference class
with UnityEngine.Objects. For this reason, you should not use a
WeakReference to reference a loaded asset. See Microsoft’s
WeakReference documentation for more information on the WeakReference
class.
看了Unity的这个说明,对刚刚找到希望的我们来说,简直是晴天霹雳。不过究竟Unity对弱引用不支持到什么程度呢?
我自己测试过,实际上在大部分情况下,弱引用对于Unity的Asset对象都是能正常判断的。但在有一种情况下,是会出问题的:
如果我们从弱引用对象的Target实例化一个GameObject,然后删除这个GameObject并执行一次Resources.UnloadUnusedAssets,并且在很短的时间内再次访问弱引用对象的Target(只要访问,包括读取属性、打log、实例化),这时候,弱引用就大概率的会出错,具体出错的信息是这样的:
A scripted object (script unknown or not yet loaded) has a different
serialization layout when loading. (Read 32 bytes but expected 120
bytes) Did you #ifdef UNITY_EDITOR a section of your serialized
properties in any of your scripts?
这个状态下,弱引用里面的Target并不为空,IsAlive也是true的,但Target被破坏了,再次取得这个Target就都是错的了,所有里面的资源都会丢失掉。
上面提到了大概率会出问题,是在于调用Resources.UnloadUnusedAssets之后,多短时间内再次访问这个弱引用对象的Target,时间并不固定。有时候同一帧访问会出错,有时候又不会。
这个现象,我做出了一些猜想,很有可能是因为Resources.UnloadUnusedAssets本身是异步处理的,会返回AsyncOperation,并不是同一帧就立刻清理完所有。在这个过程中,如果访问弱引用对象,它还没有完全判断到资源的清空情况,又一次去尝试访问这个资源本身,导致Resources.UnloadUnusedAssets清理到一半失败了。
六、加载和卸载的策略
到了最后,来总结一下。
关于怎样去加载和卸载AssetBundle的资源和Asset资源本身。策略可以有很多种,但核心的问题基本上就只有一个,就是什么时候才是真正可以释放资源内存。
通过上面的分析知道,Unity并没有很明确的API接口让我们知道一个资源是否还在被引用着。弱引用虽然可以判断,但存在一定的问题,Unity本身是不建议我们这样做的。
那么,接下来就可以分为2种可能性,一种是使用弱引用来判断,另外一种是不使用弱引用判断。
1、使用弱引用的情况
使用弱引用最大的问题,是在Resources.UnloadUnusedAssets的过程中访问Target,会导致资源释放失败,并让Target永久的被破坏。其实这个时候我们只要重新去AssetBundle里面LoadAsset,那么就能解决Target被破坏的问题。但由于这种情况下弱引用对象的IsAlive是true,而Target也不为null,导致我们不知道Target被破坏了,而导致这个资源一直错下去。
为了解决这个问题,我们只能严格的控制Resources.UnloadUnusedAssets的使用时机。切换场景也是会默认有UnloadUnusedAssets的调用的。
于是,整体的加载和卸载过程就会变成这样:
加载:
1、建立一个AssetsMgr管理器,里面通过AssetBundleName和AssetName作为key,可以获取对应的资源。
2、逻辑层通过对象池来申请对象使用,假如对象池里面不存在可用对象,则去AssetsMgr里面获取Asset的Object。
3、AssetMgr里面保存的是WeakReference对象,如果该key没有被保存过,或者WeakReference对象的IsAlive不为true或者WeakReference对象的Target的对象为null,则需要重新去AssetBundleMgr管理器加载资源,并保存成为新的WeakReference。当AssetMgr里面已经有对应的WeakReference,那么将会返回作为Target的Object给对象池那边实例化使用。
4、AssetBundleMgr管理器里面保存着加载过的AssetBundle和它们所有的依赖关系。如果没有加载过对应的AssetBundle,将会在这里加载并返回。
卸载:
1、定时去遍历AssetMgr里面的所有弱引用对象,判断它们是否存活。如果已经不存活了,那么推进一个等待删除的列表,并等待一个短的时间再把它们真正清理。不立刻清理的原因是怕当前帧里面判断到不使用,然后立刻又有地方请求使用。
2、关于AssetBundle的卸载,按道理使用的Object已经存到弱引用管理里面,所以AssetBundle在加载完Asset的Object之后,就可以直接Unload(false)了。不过引用的资源的AssetBundle应该存起来,并通过计数器记录现在的使用情况。如果弱引用里面的主Asset被清理了,那么应该通知依赖AssetBundle计数器减一。当依赖AssetBundle的计数器为0时,则应该去卸载对应的AssetBundle。
特殊处理:
由于弱引用存在一定的问题,所以要避免Resources.UnloadUnusedAssets或者切换场景之后,立刻去判断弱引用的存活。
如果是可以频繁切换场景的游戏,比如回合制游戏,每次战斗都需要切换场景。那么可以用切换场景作为时机,每次切换场景时候等于是Resources.UnloadUnusedAssets了一次,然后等待Resources.UnloadUnusedAssets完全结束之后,才开始时机加载模型,并判断弱引用的使用情况。
如果是不会切换地图场景的游戏,比如大地图SLG那种,建议是所有资源都做成异步加载,定时的执行Resources.UnloadUnusedAssets,然后在等待Resources.UnloadUnusedAssets执行完成之前,先把所有异步加载的请求和弱引用判断停掉,等待Resources.UnloadUnusedAssets结束,再继续执行弱引用加载请求和清理。
2、不使用弱引用的情况
如果不敢使用弱引用计数的话,那么纯资源类型的引用就不好精确判断。
GameObject类资源
我们还是从对象池入手。
1.对于GameObject类的对象,我们做场景对象池,在每个对象需要实例化的时候,去AssetsMgr获取Object。而AssetsMgr里面同样是通过AssetBundleName和AssetName作为key 去保存资源,不过这时候就不是保存一个弱引用对象,而是保存一个计数器类对象,里面会保存Object本身,还有这个Object被实例化的次数。然后Object的GetInstanceID获得唯一Id,通过这个id也同样存一份对应计数器对象的字典。
2.当Object不存在的时候,去加载AssetBundle,并且加载依赖AssetBundle。然后LoadAsset出资源Object,主AssetBundle执行Unload(false),依赖的AssetBundle计数器加1。
3.当对象实例化的时候,在实例化对象身上挂一个脚本,里面记录着实例化这个GameObject的Object的InstanceID,脚本Awake的时候,抛出创建事件,InstanceID作为参数。AssesMgr里面监听这个事件,对InstanceID的引用加一。
4.当对象被删除的时候,身上的脚本的OnDestroy方法会抛出删除事件,InstanceID作为参数。AssetsMgr监听这个事件,并对InstanceID的引用减一。
5.定时去检查AssetsMgr里面的InstanceID计数器,如果有计数器数量为0的情况,就推入待删除队列,下一帧就把InstanceID对应的计数器删除,并且把该AssetBundle的依赖AssetBundle计数器减一,再检查依赖AssetBundle的计数器有等于0 的,执行Unload(false)。
纯资源
对于纯资源类的Asset,比如图片之类的,如果不用弱引用,就只有有所取舍了。可以考虑一下这两种方案:
1、读取了AssetBundle里面的图片之后,AssetBundle本身立刻Unload(false),图片保存在代码里面复用。然后通过最后一次申请调用的时间去判断,大于一定时间之后,就清理掉。
这样做的好处是,AssetBundle本身的内存不会再占用了,然后如果很久没有人申请的资源,也只是在代码层面去掉引用,如果场景里面还有,也不会受到影响。
坏处是,如果一张图片在场景里面长久都没删除,代码引用被清理掉了。下次又申请了同一张图片使用,就要重新在AssetBundle加载一次,内存里面就会出现重复资源。
2、纯资源的asset对象不保存在代码引用,它们的AssetBundle也完全不卸载,这样每次需要使用该图片的时候,都从AssetBundle里面LoadAsset。然后定时执行Resources.UnloadUnusedAssets,让没有在使用的图片Asset内存得到释放。
这样做的好处是,由于每次请求的资源都是从同一次加载的AssetBundle里面读取的,所以同一张图片的内存肯定不会有多份重复的。
这样做的坏处是,引用AssetBundle占用内存由于没有释放的时机,会一直占用内存。不过实际上AssetBundle本身如果是通过LoadFromFile方法加载的话,AssetBundle不会占用太多的内存。
至于LoadFromMemory类的方法加载,原则上是不建议这么做的,不过有些时候却迫不得已,比如要加密AssetBundle文件,或者混淆AssetBundle文件,就需要在文件的二进制里面做修改,然后使用时通过读取完整的二进制再进行解密。这种情况下,就肯定不能不释放AssetBundle文件了,不然内存的占用就非常大。