注:本文中用到的大部分术语和函数都是Unity中比较基本的概念,所以本文只是直接引用,不再详细解释各种概念的具体内容,若要深入了解,请查阅相关资料。
Unity的资源陷阱
游戏资源的加载和释放导致的内存泄漏问题一直是Unity游戏开发的一个黑洞。因此导致游戏拖慢,卡顿甚至闪退问题成为了Unity游戏的一个常见症状。
究其根源,一方面是因游戏设备尤其是Unity擅长的移动设备运行内存非常有限,另外一方面是因为Unity不太清晰的加载释放策略和谜一样的GC(垃圾收集)机制,共同赋予了Unity “内存杀手”“低效引擎”的恶名,但事实上如果能够深入的了解Unity的资源加载释放机制,亦步亦趋的根据自身情况管理好内存的使用,那么Unity游戏完全可以跳出内存泄漏的陷阱。
那么下面,我们从资源的加载方式,资源的相关概念,加载释放的最佳策略三个方面来逐步探讨这个Unity的“危险领域”。
资源的加载方式
Unity的资源加载方式分两大种类:静态加载和动态加载。
静态加载
顾名思义,直接通过设置属性的办法,把资源直接绑定在场景内的任意对象上,如2D对象的Sprite属性和3D对象的Materials属性;另外通过自定义代码上的Public属性绑定的任何资源也属于静态加载范畴。
静态加载是最为常见的资源加载方式,其资源的生命周期与其所在的场景完全一致,在场景加载时加载,在场景切换时释放,所以这种方式的优缺点也是显而易见的:
优点:可以在场景加载过程中完成自身的加载过程,所以在场景运行期间该资源没有任何性能隐患;另外在场景切换时会被完全释放,无须担心因为释放不及时不完整而导致内测泄漏问题。
缺点:只支持不变的静态资源,无法根据游戏的实际需要灵活更换不同资源;所有资源必须和场景同生共死,无法在场景运行过程中提前释放,如果该资源非常庞大并且只在短时间内需要,则会带来不小的内存浪费。
动态加载
动态加载一般发生在场景的运行期间,游戏为了一定的需求动态的加载和表现不同的资源而产生的需求:如果游戏根据不同的玩家显示不同的头像,根据玩家选择的不同角色而显示不同的3D模型。动态加载的优缺点是非常极端的:
优点:根据游戏设计要求,有些资源在场景开始时无法确定,必须动态加载;动态资源可以在场景运行的任何时间加载,也可以在任何时间释放,开发者具有很强的灵活性和主动性。
缺点:很明显,动态资源的控制需要开发者亲力亲为和更高的技巧;而一旦缺乏对其合理的控制,内存陷阱将会遍地开花,游戏的性能问题和内存泄漏将无法避免。
动态加载的常见方式
Resources 本地资源加载:通过引擎内部的Resources类,对项目中所有Resources目录下的资源进行动态加载。
AssetBundle本地或者远程资源包加载:通过引擎内部的AssetBundle类,对网络,内存和本地文件中的AssetBundle资源包进行加载。然后从资源包中获取资源,在游戏中使用。
Instantiate实例化游戏对象:通过Resources或AssetBundle中的加载的对象,一般不能直接在场景中使用,需要通过Instantiate方法,实例化这些对象,使其成为场景中可用的游戏对象。
AssetDatabase加载资源:通过AssetDatabase的相关函数加载资源,由于仅适用于Editor环境,在这里不加累述。
基本资源加载概念
资源的类型
Unity中常见的资源包括以下几种:
GameObject(游戏对象)
Shader(着色器)
Mesh(网格)
Material(材质)
Texture/Sprite(贴图/精灵)
资源内存镜像的引用和复制
要理解Unity资源的使用,必须先了解以下几个概念:
内存镜像:任何游戏资源或对象一旦加载,都会占用设备的一部分内存区域,这个内存区域就是资源或对象的内存镜像,如果内存镜像过多达到设备的极限,游戏必然会发生性能问题。
引用和复制:Unity的“黑科技”之一, 也是资源加载和释放的主要难点。
引用:指对原资源仅仅是引用关系,不再重新复制一份内存镜像,但引用的关键在于,如果原资源被删除会导致引用关系损坏,使得引用的对象发生资源丢失。
复制:复制原资源的内存镜像,从而产生两个不同的内存区域,如果被复制的资源被释放,不会影响复制的资源。
但不幸的是,Unity中的游戏对象不能简单的用引用和复制来进行区分,大部分的对象不同部分采用了不同模式甚至混合模式,使得游戏对象的内存分配显得错综复杂。
资源加载时对内存的使用
下面通过一个实例来说明资源加载会使用多少内存,比如一个普通的3D对象,包括了Shader/Mesh/Material/Texture等资源,这些资源需要从AssetBundle加载,如果要将其实例化到场景,那么将会占用如下图所示的内存空间:
首先,从文件、网络或者其他内存空间加载AssetBundle以后,会形成AssetBundle内存镜像(上图紫色部分)。
其次,从AssetBundle内存镜像中再加载GameObject以后,该GameObject用到的Shader/Mesh/Material/Texture也同时被加载出来,形成各自不同的内存镜像(注意:请参考上图紫色虚线框中的内容,可知这些资源内存镜像与AssetBundle内存镜像是不同的)
最后Instantiate实例化GameObject以后,GameObject会再一次复制GameObject资源的内存镜像到一个新的内存区域,形成全新的对象数据。(上图上方绿色框中内容)
资源的加载需要理解以下要点
要点1:尽管GameObject是对原有资源内存镜像的完全复制,但由于Unity对各种资源种类的处理方式不同,导致GameObject中的其他相关资源并不是简单的复制关系:
Shader:完全的引用,不占用额外内存,如果原Shader资源被释放会造成资源丢失而损坏对象。
Mesh:复制原资源内存空间的同时,还引用了原资源的数据,也就是说不但占用额外的内存,而且一旦原资源被释放,也会造成数据丢失而损坏对象。
Material:同Mesh,复制并引用原资源。
Texture:通Shader,完全引用原资源。
要点2:从AssetBundle加载到GameObject实例化,大部分资源实际占用3处内存,那么最终我们要释放这3处内存才算将该资源完全释放。
要点3:要特别注意和理解引用关系,这个在后面的资源释放章节中具有重大意义。
资源加载释放最佳策略
Resources资源加载
Resources加载是将游戏内部一部分以文件形式存储的资源加载出来供游戏使用,Resources加载的步骤一般有二步(下面是示例代码):
Object cubePreb = Resources.Load< GameObject >(cubePath);
GameObject cube = Instantiate(cubePreb) as GameObject;
首先通过Resources.Load函数把对象资源(cubePreb)加载到内存镜像。
其次通过Instantiate实例化该资源的内存镜像变成游戏中可用的对象(cube),当然如果是Shader/Mesh/Material/Texture类型资源无须再次实例化,可以直接使用。由此可见Resources加载的资源一般占用2处内存空间:所用资源cubePreb的内存镜像和实例化对象cube的内存镜像。
这里顺便提下Resources资源加载的一个“黑科技”:OnDemand方式。以上述代码为例,cubePreb的所需资源在Resources.Load的时候不会加载,而将在第一次Instantiate的时候一起加载,也常常会导致一些比较大的对象在第一次实例化时造成卡顿现象,不过这个性能问题和内测泄漏无关,不在本文的探讨范畴。
Resources最佳加载策略:
- 相同对象的Resources.Load只需调用一次,该资源对象可以共享,反复调用虽然不会引起内存镜像的重复建立,但依然存在性能损耗。
- 一般只对GameObject进行实例化操作,尽量避免对Shader 、Mesh、Material、Texture资源进行实例化从而造成内存浪费。
- 除了明确需要全局共享的资源,尽量避免使用全局静态变量来引用Resources.Load出的资源对象,因为全局引用的对象存在释放陷阱。
Resource 资源释放
单体释放Reources.UnloadAsset(Object)
主动卸载独立资源,主要作用在于及时释放场景的中的资源,减低运行时的内存损耗,提高游戏性能;但这种方式也带来了不小的风险,由于Unity游戏的资源引用关系错综复杂,如果要单独释放一个资源,要明确该资源已经在场景中不再被引用,否则轻者造成游戏显示错误,重则造成游戏报错。
另外,Reources.UnloadAsset(Object)还有一些暗坑,比如释放Sprite需要先释放Sprite.Texture否则Texture就会存留在内存,所以在使用这个函数的时候,要清楚释放的对象有无内部引用资源。
统一释放Resources.UnloadUnusedAssets
这是一个统一的,一次性的,比较完整的释放闲置资源的函数,而且是Unity官方非常推荐的一种方式,但这个函数实际的使用效果并没有想象的那么美好,该函数本身就是Unity资源释放的一个陷阱。
首先UnloadUnusedAssets对所有需要释放资源有一个非常重要的前置条件:只有不存在任何引用关系的资源才能被该函数释放,看起来这是一个明确的要求,但由于Unity资源的相互引用关系比较隐晦繁复,想要明确的判断某一个资源不存在引用关系是有一定难度的,并且,如果一个我们想释放的资源存在任何隐性的引用关系,UnloadUnusedAssets将会无视这个资源而无任何反馈,这种情况常常会被开发人员忽略而造成内存的泄漏。
一般情况下,要明确一个资源不再被引用,首先要把所有用到该资源使用GameObject.Destroy函数进行销毁,然后要把所有引用到该资源的变量显性的设置为Null,尤其要关注的是类成员和静态变量的引用,最后调用UnloadUnusedAssets才能有效地释放这个资源。
根据实战经验来看,最佳使用UnloadUnusedAssets的时机还是在场景切换的时候,由于Unity的场景关闭会有效地销毁所有的对象和所有代码的引用,那么在场景切换,尤其是在新场景的开头UnloadUnusedAssets上一个场景的资源处理是比较稳妥的做法;而在场景运行过程中希望不断调用UnloadUnusedAssets来快速释放当前空闲资源其实是一招险棋,有欲速则不达的可能:
- 首先,如果大部分资源都存在引用,那么使用该函数徒劳无功。
- 其次,如果该资源在UnloadUnusedAssets以后又被起用,那么资源重新加载的损耗得不偿失。
- 最后,UnloadUnusedAssets是一个异步函数,在其执行过程中,一旦资源又被使用将会导致无法预知的后果。实际开发中发现在场景运行中反复调用UnloadUnusedAssets存在闪退的风险。
Resources最佳释放策略:
- 实例化的对象,在不再使用以后必须立刻Destroy,该清理操作不会引起资源的丢失,风险较小,要充分满足。
- 对于内存消耗非常巨大,并且在场景运行过程中能够明确不再使用的资源内存镜像,可以主动使用Reources.UnloadAsset进行强制释放。对于消耗不大的,等场景结束后进行统一释放是更稳妥的选择。
- 大部分资源建议在场景切换以后,通过Resources.UnloadUnusedAssets方法进行后置释放,必要时再加上GC.Collect。(在下一个场景的开始甚至在一个独立的换场场景中调用都是比较稳妥的选择)
- 全局静态变量和类成员变量引用的资源,务必先把引用设为Null值,然后再调用Reources.UnloadUnusedAssets才能正确释放。
AssetBundle资源加载
AssetBundle是Unity提供的另一种资源加载方式,开发者可以把一批资源打包,然后通过网络下载或者文件加载的方式进行加载。
介于Resources方式的资源必须一起打入游戏包体,AssetBundle方式则提供了一种更为灵活的资源加载方式,AssetBundle无需进入游戏包体,大大减少了游戏文件的体积,另外,AssetBundle允许通过网络下载,也为游戏资源的获取和升级提供了更为灵活的选择。
AssetBundle加载资源一般分3步,(下面是示例代码):
var bundle= AssetBundle.LoadFromFile(path);
var prefab = myLoadedAssetBundle.LoadAsset.<GameObject>("MyObject");
var obj = Instantiate(prefab);
根据前面提到的资源的内存使用和以上示例代码所示,可以得知AssetBundle资源加载到最终加入游戏场景,需要存在3个对象:bundle本身,加载的资源prefab,和实例化出来的obj。这3个对象分别对应不同的内存镜像,在释放的时候需要分别考虑。
AssetBundle最佳加载策略:
- 相同内容的AssetBundle只Load一次,在其Unload之前反复加载会造成不必要的浪费和风险。
- 相同名称的资源用LoadAsset也只需加载一次,这个和Resources.Load基本类似。
AssetBundle资源释放
根据AssetBundle的3级对象,我们分别说下各自的释放办法:
实例化的obj:用GameObject.Destroy释放。
加载的资源prefab:因为是内存镜像,也可以用Object.Destroy释放。另外Resources.UnloadUnusedAssets方法对这种资源释放也是有效的,但条件比较苛刻,prefab的父(bundle)和子(obj)都要已经被释放的情况下,加上本身引用清空,然后使用UnloadUnusedAssets才有效,所以这种办法并不十分推荐。
加载的资源包bundle:AssetBundle.Unload方法是唯一的释放手段。这个方法有2个参数,都有一定的意义:
参数为false的时候,仅仅把资源包内存释放,但保留任何已经加载的资源和实例化对象,这些资源和对象的释放有待后续代码完成。
参数为true的时候,是一次比较彻底的内存释放,资源包和所有被加载出的资源都会被释放,当然实例化的obj不会被释放,但引用关系会被破坏,所以在使用这种方式前必须提前销毁所有实例化对象。
AssetBundle最佳释放策略:
- 实例化的对象使用Destroy这个不加累述了。
- 已经加载的资源prefab,如果消耗巨大而且明确不再使用,可以直接使用Object.Destroy释放。
- 如果AssetBundle能够一次性加载完成所需资源的,可以使用AssetBundle.Unload(false)将AssetBundle的内存立刻释放,然后再场景切换以后通过Resources.UnloadUnusedAssets方法释放所有加载的资源,这种方案的缺陷是不能在AssetBundle.Unload以后再次使用该AssetBundle。
- 如果在场景运行过程中需要不断从AssetBundle加载资源,在这种情况下无须提前做任何释放行为,可以在场景切换以后,最终调用AssetBundle.Unload(true) 将全部资源包和资源释放。这种方式的主要缺陷是,AssetBundle占用的资源会在整个场景过程中一直存在,造成内存浪费,但如果AssetBundle体积不大,这种方式也带来了一定的灵活性。