Unity的AssetBundle使用总结


AssetBundle作为Unity引擎的资源加载管理和热更新手段,在各大项目内普遍的使用着。AssetBundle使用方式简单,只要设置AssetBundleName,然后打包出文件,在运行过程中加载就可以使用,但很多细节上的问题,会影响实际使用的效果。
之前我也写过很多篇文章介绍AssetBundle的使用和依赖拆分策略。这几年来陆陆续续也做了不少项目,在里面遇到了一些问题,所以想重新终结一下。
这篇文章会讨论三个问题:
1、AssetBundle的压缩方式对比
2、AssetBundle的依赖拆分策略
3、AssetBundle的卸载策略

一、AssetBundle的压缩方式对比

Unity默认的打包AssetBundle压缩方式是LZMA,我们也可以通过打包参数BuildAssetBundleOptions的UncompressedAssetBundle指定不压缩,或者ChunkBasedCompression来指定使用LZ4方式压缩。
不压缩,自然也不需要解压缩,所以打包和加载的速度会快一些。但不压缩会让AssetBundle容量很大很多,这样会让安装包变大,还有用户下载热更新时也会变大很多。
下面主要对比的是LZMA和LZ4的使用情况。
这里拿了一个项目的一部分模型作为测试,把所有预设都打包成同一个AssetBundle,分别使用了LZMA和LZ4的压缩方式。
1、容量:
LZMA:160Mb
LZ4:219Mb
可以看出,用LZ4打包出来的AssetBundle,容量会比LZMA的大30%左右,这个和网上看到的结论是相符合的。
但实际上这个容量的大小差别还是很有争议性的,因为这里面的资源全部都是prefab,没有scene文件。我之前的项目曾经试过LZ4比LZMA大了一倍多的,我暂时没有很仔细的找出变大的资源是哪些,但那个项目使用的场景比较多,场景都是直接打包成AssetBundle的,估计和这个有点关系。
2、加载速度:
以下通过同样的加载方式来测试:
1.加载这个包含了所有模型的AssetBundle,使用AssetBundle.LoadFromFile方法加载
2.使用LoadAsset方法读取AssetBundle内的一个模型,并让他实例化在场景里面
首先是LZMA压缩方式的AssetBundle:
在这里插入图片描述
在LoadFromFile方法里面,消耗了11909毫秒。
然后是LZ4压缩方式的AssetBundle:
在这里插入图片描述
在LoadFromFile方法里面,消耗了84毫秒。
这两者之间的差距已经非常明显了,完全不是一个数量级里面的。

3、内存:
首先是在完全没加载任何东西前,unity本身的内存截图如下:
在这里插入图片描述
然后是加载LZMA的AssetBundle之后的内存:
在这里插入图片描述
最后是加载LZ4的AssetBundle之后的内存:
在这里插入图片描述
可以看出,LZMA的AssetBundle加载完之后,比LZ4的AssetBundle加载完的总内存增加了250Mb左右。但是从单帧采样来看,却看不出他们之前的区别,只知道是Unity本身占用的内存。
根据官方文档的说法LZMA是一个整体打成压缩包。LZ4是每个文件单独再打成压缩包再放在一起。所以加载LZ4的时候,AssetBundle只是一个引用,可以单独的提取LZ4压缩包里面的个别文件来使用。
而LZMA作为一个整体,必须一起加载到内存里面去。LZMA加载完之后,是会解压缩然后再次打包成LZ4的格式存在于内存里面,然后再加载的。
如果按照Unity官方的这个说法,那么LZMA的加载速度慢,还有内存大,就是必然的事情了。
对比可以看出,LZ4无论从加载速度还是内存方面都要比LZMA好很多。唯一的缺点就是容量会大,具体比LZMA大多少,这需要具体的情况去分析。
虽然LZ4很多优点,但有些项目还是不能愉快的使用,因为对于买量游戏来说,安装包的包体大小决定了买量的成本,为了减少包体容量,研发团队很有可能宁愿牺牲性能来换取容量上的减少。这真是一个可悲的现实。

二、AssetBundle的依赖拆分策略

这是一个一直在讨论的问题,想要做到的目的很明确:
1、AssetBundle之间不要有冗余的资源
2、下载容量小
3、加载速度快
4、内存占用少

在讨论这个问题之前,先要看游戏实际的情况。
有些游戏,根本不需要考虑依赖拆分的问题。比如格斗游戏,用dlc来增加角色,每个角色就是一个资源包,角色与角色之间也没什么太多的共用资源。这种情况下,每个角色打成一个bundle,简单快捷。
但这种情况毕竟不多见。正常的项目,美术资源之间重复利用反而是经常出现的。所以说刚才那几个问题还是需要考虑。
对于第一个问题,如果想完全没有冗余资源,很容易做到,只要把资源的所有依赖都单独打包AssetBundle,比如一个模型,他使用了一个fbx,一个材质球,一张贴图,三个动画文件。那么我们就把他们全部单独打包,包括模型预设本身,我们生成7个AssetBundle,就完全不会有冗余了,就算其他模型用到了相同的网格模型或者贴图,也不会有问题。
但这样简单粗暴的处理方式,将会产生其他的问题。
1、AssetBundle文件本身每个文件都会有一些文件本身的数据在里面的,我们可以成为文件头信息。虽然资源没有冗余了,但是由于文件多了,文件头的信息加起来也会变多,使得总容量变大了。
2、由于文件变多了,导致玩家下载文件的数量也变多。如果有从网上下载过资源都应该知道,下载文件数量多的时候,每个文件需要建立新的连接来下载,甚至很多小文件只有几Kb或者几十Kb,这样是达不到用户最大网速下载的,除非游戏开多线程来下载这些文件,不然总的下载速度是比下载单独一个文件要慢比较多。
3、加载散文件时,我们需要先查依赖列表,然后逐个文件去加载依赖的AssetBundle,也会比单独加载一个AssetBundle慢。但这个过程耗时比较少,所以一般来说感觉不明显。
4、在内存占用方面,实际上差别会比较大。
同样一个模型加载,我用了2种不同的方式:
第一种是所有依赖的资源打在同一个AssetBundle里面,序列化文件占用只有一个,占用内存99kb
在这里插入图片描述
第二种是把依赖全部打散成单独的AssetBundle里面,序列化文件占用有14个,占用内存0.7mb。
在这里插入图片描述
一个模型占用了0.7mb,如果游戏里面使用的资源多起来,AssetBundle多起来了,就会比较恐怖了。这是一个较为大型的游戏的内存,可以看到AssetBundle有1688个,占用了86.3mb。这部分只是单纯的AssetBundle占用,不包括他们本身的资源比如网格模型、材质球、贴图等。
在这里插入图片描述
从上面的结果看,AssetBundle打得多了,实在是没有好处。
于是我们得出结论,AssetBundle的数量尽量减少一些。但减少到多少合适呢?
如果不需要考虑玩家下载更新,只是单纯的把资源变成AssetBundle来加载,用LZ4的压缩方式来说,整个游戏的资源打在一起,的确是很好的选择。这样加载最快,也不会有任何的冗余。但从实际情况出发,如果只是为了加载资源,完全不需要更新的话,也没有必要用到AssetBundle了,直接用Resource.Load就行了。

我自己在实际项目中使用过三种策略:
1、所有资源都打散为单独的AssetBundle
2、一个单独个体的模型包含依赖打成一个AssetBundle
3、根据依赖引用数量来判断某个资源是否需要打成AssetBundle
第1种方式,是最简单快捷的解决了资源冗余的问题。这是绝对的不可能有冗余。而且由于都是拆散之后的资源,所以其中某一个资源被修改了,一般都不会影响到别的文件,在版本更新的时候,玩家实际需要下载的文件容量应该是最小化的。但缺点刚才已经分析过,由于文件多,导致了玩家下载时会慢,必须开多线程同时下载才能达到正常的网速。然后加载时会让内存变大。
第2种方式,文件少,下载和加载的次数变少了很多。但由于资源之间会有比较多的冗余,导致了打出来的AssetBundle总体容量会变大不少。然后加载到内存里面时,冗余的资源会产生重复的资源内存分配,比如用到同一张贴图的十个模型,加载到内存里面,这张相同的贴图由于是从十个不同的AssetBundle加载的,所以内存里面会有十张一模一样的贴图存在,导致内存占用变大。然后如果这张共用的贴图被修改了,那么下次版本更新,用到这张贴图的十个模型的AssetBundle都需要玩家重新下载。所以我感觉这种做法有点得不偿失,是否能使用,要取决于资源的复用情况多不多,如果多,不建议这样做。
第3种方式,把直接用于加载的预设全部列出来,逐个预设打成一个AssetBundle,然后检查这些预设依赖到哪些文件。如果资源文件只被一个预设用到了,就不需要打包AssetBundle,但如果资源文件被2个或2个以上的预设用到了,那么这个资源就需要单独打包。这种方式比起全部打散依赖的方式,最终打出来的AssetBundle数量会少很多,也基本可以保证没有冗余文件。但也不是完全完美的。假如一个资源一开始只会被一个预设用到,后来变成和另外一个预设共用了,那么打包的时候,为了把共用的资源分离出来,原预设和这个资源都需要重新打包。如果经常有这种情况出现,可能热更新时需要更新的文件会随着共用资源变多而越来越多。
从总体的优缺点来看,第3种方式应该是比较合理的处理方式。

三、AssetBundle的卸载策略

对于AssetBundle的卸载,一般都是出于对内存控制的考虑。
想达到这样的目的:
1、AssetBundle尽量占用内存少
2、在不需要使用该AssetBundle里面的资源时,及时的释放资源
为了让AssetBundle尽量少占用内存, LZ4的压缩方式明显比LZMA要适合很多。但加载进来之后,什么时候该卸载资源,这是一个比较复杂的问题。
在写这篇文章之前,我看了网上一些文章的分享。有些文章建议,在加载完AssetBundle之后,加载资源本身,然后立刻把AssetBundle释放,使用Unload(false)的方式。这样就可以没有AssetBundle的内存占用,当资源不再使用的时候,可以用Resources.UnloadUnusedAssets( )方法来释放没用的资源,从而达到内存释放的目的。
这样做其实会存在几个问题:
1、假如资源存在资源依赖的重用,比如一张贴图被多个模型资源使用,那么在分别加载这些模型时,由于前一个资源的AssetBundle已经被释放了,加载下一个资源的时候,重复利用的资源比如贴图,就会需要再次加载AssetBundle然后生成。
对于Unity来说,一个AssetBundle被释放然后再重新加载,虽然还是同一个AssetBundle同一份资源,但他们之间的资源是不会有任何关联的,所以加载到的图片虽然是同一张,但在内存里面会有两份独立的图片资源。这样就会导致内存冗余,如果资源一直重复加载并且得不到合理的释放,就会变成内存泄露导致崩溃。
这个我感觉Unity的底层如果可以再进一步的优化,让加载到的相同资源在内存里面只会有一份,就能解决这个问题。但那毕竟是Unity内部的事情,我们不能把希望寄托在他能解决上。
2、假如资源被代码引用着,比如把LoadAsset出来的Object保存起来,方便下一次实例化之类。那么就算场景里面用到该资源的GameObject都已经被删除了,但还是不能通过UnloadUnusedAssets来释放,如果没有额外的资源管理策略,这些资源将会一直占用着内存而且不会释放。
3、如果AssetBundle加载完之后就立刻释放,一些被依赖得比较多的资源,将会反复的加载和卸载,导致了无谓的性能消耗。

基于这些问题,我并不建议加载完资源之后立刻卸载AssetBundle。
我的思路是这样的:
1、每一个加载的AssetBundle都受到管理,记录每个AssetBundle依赖了哪些AssetBundle,并且记录从单个AssetBundle里面LoadAsset过的所有资源。
2、每一个加载出来的资源都可以判断得到当前是否有被使用
3、找合适的时机检查资源使用情况,如果一个AssetBundle里面加载过的资源都没有被使用,这个AssetBundle本身又没有被其他资源依赖着,那么这个AssetBundle就可以释放。

要记录AssetBundle及其依赖的加载情况,是非常容易的事情,因为这都是我们主动去做的事情。比如加载一个AssetBundle,我们肯定知道,用到了哪些依赖,我们也是从AssetBundleManifest.GetAllDependencies里面查找然后加载的。这时候,我们要记录加载过的AssetBundle,然后被依赖的AssetBundle做计数器,是很简单的事情。
然后对於单个Asset的加载,也是通过LoadAsset方法主动加载的,所以加载过的资源也是可以逐个AssetBundle做一个记录的。
这个思路里面,最难的一点,是怎样去判断每一个加载出来的资源是否正在被使用。
被使用的情况有3种:
1.资源被实例化为GameObject放在场景里面使用。
2.资源被赋予在某个Component的属性里面,比如图片Sprite被指定在Image组件上。
3.资源被存在代码里面,比如把Object存在某个类里面方便实例化时使用。
这3种情况,如果都不存在的时候,使用Resources.UnloadUnusedAssets( )是可以把某个已加载到内存里面的Asset释放掉的。但释放的过程并没有结果返回,所以我们不知道释放了哪一些资源。
这时候,我们可以使用C#的弱引用(System.WeakReference)机制来判断一个资源是否被释放。弱引用的原理大概是这样的:一般的对象存在某个代码里面时,不管是UnloadUnusedAssets还是System.GC.Collect,都是不能释放的,但弱引用的对象,是可以被上面两种手段清空的。所以我们可以把资源存在弱引用里面,当资源真的被释放掉之后,我们就可以通过判断弱引用里面的对象是否已经为空,来判断这个对象是否有其他地方用到。
具体的步骤如下:
1、每个AssetBundle加载完之后,存在一个计数器里面。然后给每个AssetBundle建立一个管理的对象,里面单独管理自己加载过的Asset。
2、对某个AssetBundle进行LoadAsset时,用AssetName作为key,把加载出来的Object对象创建一个弱引用的对象,存到字典里面。代码大概如下。
在这里插入图片描述
3、找一个合适的时机,比如切换场景之类本身卡顿不会很明显的时候,调用UnloadUnusedAssets和System.GC.Collect。
4、隔一段时间,检查一下刚才保存的弱引用里面,资源是否还存活。代码大概如下:
在这里插入图片描述
5、当确定某个AssetBundle里面加载过的资源都已经没有在使用时,我们还要判断一下,当前AssetBundle有没有被其他AssetBundle依赖。这时候第一步的计数器就起作用了,如果被依赖数为0,那就是可以释放了。释放的时候,看当前的AssetBundle还有没有依赖到别的AssetBundle。如果有,把所有依赖的计数器减一。然后再看看被减一的计数器有没有已经为0的,继续释放。
6、释放的时候,最好不要立刻就Unload(true)。建议是先把可以释放的AssetBundle放到一个队列里面,等1分钟之后再释放。这是怕有些时候逻辑会在释放完某些资源之后,又立刻去申请使用,这时候其实AssetBundle是不需要释放的,释放了还需要重新加载回来,有无谓的消耗。

这个流程下来,一般就能比较好的管理AssetBundle和Asset的使用和卸载了。但需要注意几点潜规则:
1、在调用UnloadUnusedAssets和System.GC.Collect之后,如果立刻检查弱引用,很有可能并没有立刻判断到对象已经被释放。因为UnloadUnusedAssets本身是一个AsyncOperation,是异步的。所以需要隔一段时间判断一次。
2、注意一个GameObject被Destroy之后,并不一定真正的被释放掉了。假如这个GameObject是被某个类的变量引用着,就算GameObject被Destroy了,但实际上只要引用他的对象一直不清空,当前的这段内存是不会释放的。
这个问题其实很常发生,因为Destroy了某个Unity的GameObject之后,用代码判断某个引用这GameObject的变量时,的确可以判断得到对象为空了。所以很多人在Unity里面写代码的时候,都不习惯清空保存的变量。但其实可以做一个实验,我们可以写一个public的变量,然后把一个GameObject赋予给他,最后把GameObject删掉,回头在Inspector面板里面看看,你会发现这个变量会变成Missing,而不是None。Missing的意思是,这里引用的guid不为空,但引用的具体值可能找不到了。
3、如果决定要使用弱引用来判断资源,就不要再用其他代码把资源存起来了,比如很多人会另外建立一个管理类,先存起一个资源,等别人再次请求的时候,直接返回这个资源用于实例化之类。而是应该用弱引用的对象来做这个事情。因为如果用其他代码来存,那么只要代码不释放,弱引用也就永远判断不到他有没有真的没被使用。而且弱引用本身就把这个资源存在target参数里面,只要先判断一下target是否为空,如果不为空就返回使用就可以了。如果弱引用里面的资源被释放了,就再在AssetBundle里面加载出来。
在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章