AssetBundle实战:资源冗余,分包策略,加载和卸载
一.前文提要
AssetBundle是用于包装和加载资源的一个重要工具。我们可以在运行时对游戏内容进行更新。这允许开发者提交更小的应用程序,然后通过网络将数据进行传输,可以减轻运行时的压力,可以有选择的加载和卸载内容来进行优化。
不同于Resources加载非常直接的使用方式,AssetBundle的使用方式会更加繁琐和复杂,需要自己设计打包策略,做包引用计数,管理加载和卸载等。
我们如果要在游戏中使用assetbundle,需要进行以下几个步骤:
-
根据分包策略规划每个包内的资源;
-
构建所有包资源;
-
如果是ab资源放在远端,则在游戏运行时下载ab包;
-
加载ab包;
-
在合适的时机卸载不再使用的ab包;
AssetBundle的资源冗余
资源重复入包,也称资源冗余,指的是同一个资源被打包入多个不同的ab包内。
资源冗余导致包体过大,占用内存和带宽。
这个概念在下文中会多次提到,也是工作中常遇到的一个非常重要的问题。
为什么会资源重复入包?
这与unity构建ab包的逻辑有关。构建ab包时,会自动将ab包内的资源和这些资源依赖的所有资源打包。
例如下图所示:
资源重复入包范例-
预制体A依赖于C ,E, 预制体B依赖于D ,E;
-
预制体A放入ab包 A 中,预制体B放入ab包 B 中。
则在打包后,ab包A中会包含A C E3个资源,ab包B中会包含B D E3个资源。
这时预制体 A 和 B 都依赖的图片 E 被打包了2次,这时就产生了资源重复入包。
那我们该如何避免资源重复入包呢?下文的分包策略就是为了处理这个问题。
二. 分包策略
分包策略指的是按照一定的规则,将需要入包的所有资源,按照类型/大小/模块等因素分配到不同的assetbundle包中。
好的分包策略,可以避免资源重复入包,减少加载ab时的时间,减少内存占用和流量消耗。
1. 为什么要分包?
在设计分包策略前,我们需要先了解分包的原因,即ab资源的分包主要为了解决什么问题?
先举例2个极端的分包情况,我们可以看下各自的缺点。
第一种:所有资源都放入一个assetbundle包中。
这种情况下只会有一个ab包,这个ab包内包含了所有的资源。会有以下缺点:
-
加载任意资源都会导致整个ab包加载入内存,导致加载时间过长,内存占用过大;
-
如果有资源热更新,意味着每个资源的修改都将会重新下载整个ab包,浪费带宽;
第二种:每个独立的资源都单独打一个assetbundle包。
这种情况下有多少个资源,则会有多少个ab包。会有以下缺点:
- 因为每个ab包结构上分为包头和资源数据,更多的ab包意味着有更多的包头数据,占用更多的包体和内存(如下图);
-
ab包本地加载时,文件寻址的时间会远大于文件读取的时间。这意味着在数据大小相同时,更多的文件个数读取时间会更长。例如1000个1M的文件的读写时间会远远大于1个1G的文件的读写时间;
-
ab包从服务器下载时,更多的ab包意味着更多的http请求数。会有3方面的影响。
-
因为http有请求头的缘故,同等大小的数据传输时,更多的http请求数意味着更多的带宽占用;
-
http请求中需要有建立链接的时间,更多的http请求意味着更多的时间花在建立链接上;
-
更多的http请求会对服务器并发数和带宽占用产生影响;
-
根据上面2种极端情况,我们对于assetbundle分包的最优解有了结论:在ab包数量更少的情况下,尽量满足每个ab包内的资源不重复,各个ab包的使用场景不相交。
2. 分包的基本原则
Unity官方推荐的ab包分配方式有以下几种:
按逻辑进行分类
即按该资源被哪个功能应用到,同种功能用到的资源打到一个AB包内
-
将一个UI界面的所有图片纹理,布局文件打包到一个AB包
-
将一个角色的所有动画,模型打包到一个AB包
-
将一些公用的资源打包到一个AB包
按类型分类
-
将同一个类型下的资源打包到一个AB包中
-
如将Windows和Mac平台下使用到的不同Shader资源分类打包
-
将不同版本的Unity AB包进行分类打包
同时使用的打包
即考虑到哪些AB包可能会在某个时间段一起使用,则打包到同一个AB包中
-
如某关卡下需要加载的所有资源一起打包
-
某个场景下需要加载的所有资源,你应该尽可能将所有AB包依赖项都打包进来,避免加载其他依赖项
在实际的工作中,按照单一的分配原则有比较大的局限性,往往不能达到最优解。
混合搭配才是王道。
以下是官方给出的一些分类建议:
-
将经常更新和很少更改的资源分开打包;
-
将基本会同时使用的资源打包,如模型和它用到的纹理,动画一起打包;
-
将一些很多资源会引用到的公共资源分开打包,避免重复打包;
-
如果两组几乎不可能同时加载的资源分开打,如Standard和High解析度资源分开打;
-
如果一次加载某个AB包经常用到少于50%其中的资源,则应该分开打包;
-
考虑合并比较小但经常一起使用的AB包;
-
如果某组资源只是同个资源的不同版本分类,使用variant打包(如不同语言版本的同个资源);
3. 手动分包模式
手动分包模式:通过手动配置资源的ab标签管理AB资源分包
手动配置ab包的标签我们可以手动指定每个资源的ab分包标签,当分配好后,调用BuildPipeline.BuildAssetBundles(xxx) 接口即可生成ab包资源。
如果使用这种方式,每次仅仅操作一个资源或文件夹,无法对整个项目中的ab资源分包有全面的了解。
所以一般都会搭配可视化工具来使用,使用可视化的ab资源引用窗口工具,从而详细了解项目中的ab资源分配状况,更好缕清资源引用关系。
官方的可视化工具有:Unity Asset Bundle Browser。其他各种框架大都有内置的类似工具,不一一列举。
此外如果不使用第三方工具,这种方式无法直接查看资源重复入包。即一份资源在ABC3个ab包中使用,则会重复构建3份,导致资源冗余。
当然我们遍历一遍所有资源,是可以找出所有冗余的资源。有很多工具也有对应的功能,例如上文提到的AssetBundle Brower。
4. 自动分包模式
手动分包模式下,需要花费较大的精力来合理规范ab包,从而减少资源冗余。通过自动模式处理ab包分包则节省了不少精力。
自动模式不需要手动指定每个资源/文件夹的ab分包标签,而是通过一套自定义的规则,将项目中使用到的资源划分为不同的ab包。
这套规则必须保证:不产生冗余资源,各ab包大小合理,最好符合同一个ab包内的资源在逻辑/类型/使用场景下有一致性的原则。
一种分包策略如下:
指定一个目录,目录下的所有资源可以动态加载。根据资源的引用关系,将所有资源划分到不同的ab包中。规则如下:
-
如果资源A被引用次数为0,则资源A单独划分为一个ab包;
-
如果资源A被引用次数为1,仅被资源B引用,则资源A被划分到B所属的ab包中;
-
如果资源A被引用次数>=2,被资源B1Bn引用时,如果资源B1Bn在不同ab包中,则A单独划分为一个ab包;如果资源B1~Bn在同一ab包中,则A也被划分到所属的同一ab包中。
这种分包策略下的包划分如下图:
自动分包下的范例5. 特殊资源的分包策略
场景资源
场景assetbundle和其他资源assetbundle需要区分,场景以及其使用的资源要单独划分为一个ab包。
因为两者在格式和内容上略有不同,unity针对场景assetbundle的串流加载做了优化。
shader资源
常见的问题:打包ab后在手机上shader丢失。主要原因与shader的分包策略和shader变体机制有关。
具体原因可以查看我的另一篇文章:AssetBundle中的shader变体丢失问题,这里只说下打包shader的最佳方案:
- 把assetbundle资源中引用到的shader都放到一个ab包中,在进入游戏时首先加载此ab包,这样可以保证后续所有使用的材质没有问题。
- 常用到且变体数量少的shader,可以将shader加入到Always Included Shaders 中,然后在打包ab资源时剔除这些shader。
- 需要assetbundle打包的shader,使用ShaderVirantCollection定义需要打包的变体。可以通过shader control插件来搜集材质球使用到的shader变体,结合人工查找代码中调用使用到的变体。
图集资源
常见问题:图集内贴图资源重复入包。主要原因与贴图和图集的打包策略有关,也与图集是否勾选Include In Build有关。
具体原因不细说,只说打包图集资源的最佳方案:
-
勾选include in build,同一个图集内的所有图元放在同一个AssetBundle中,但是图集不入ab包;
-
不勾选include in build,同一个图集内的所有图元放在同一个AssetBundle中,图集入ab包,需要在代码中添加图集动态绑定回调;
三. 构建AssetBunlde
1. 构建AB包的接口方法
有2种常用的ab资源构建的接口:
// 方法1
BuildPipeline.BuildAssetBundles(string outputPath, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform)
// 方法2 相比1需要传入AssetBundleBuild[]
BuildPipeline.BuildAssetBundles(string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform)
-
不传ab包分包列表 按照资源在Inspector最下面的分包标识构建ab包(如下图)。
-
传入ab包分包列表,按照分包列表构建对应的ab包;
这种方式可以不通过方法1中的编辑分包标识来设置ab包,而是可以自定义分包规则。
2. 构建AB包的构建参数
所有参数定义在BuildAssetBundleOptions枚举下。
None :不使用任何特殊选项构建 assetBundle。
UncompressedAssetBundle :Don't compress the data when creating the AssetBundle.
DisableWriteTypeTree :不包括 AssetBundle 中的类型信息。
DeterministicAssetBundle :使用存储在资源包中对象的 ID 的哈希构建资源包。
ForceRebuildAssetBundle :强制重新构建 assetBundle。
IgnoreTypeTreeChanges :在执行增量构建检查时忽略类型树更改。
AppendHashToAssetBundleName :向 assetBundle 名称附加哈希。
ChunkBasedCompression :创建 AssetBundle 时使用基于语块的 LZ4 压缩。
StrictMode :如果在此期间报告任何错误,则构建无法成功。
DryRunBuild :进行干运行构建。
DisableLoadAssetByFileName :禁用按照文件名称查找资源包 LoadAsset。
DisableLoadAssetByFileNameWithExtension :禁用按照带扩展名的文件名称查找资源包 LoadAsset。
AssetBundleStripUnityVersion :在构建过程中删除存档文件和序列化文件头中的 Unity 版本号。
下面是一些常用的参数的详解:
DisableWriteTypeTree: 打包AB资源时会默认写入TypeTree的信息,这样可以保证AssetBundle文件的向下兼容性,即高版本可以支持以前低版本制作的AssetBundle文件。
如果开启DisableWriteTypeTree选项,则可能造成AssetBundle对Unity版本的兼容问题,但关闭TypeTree会使Bundle更小。
IgnoreTypeTreeChanges:在执行增量构建检查时忽略类型树更改。
此选项允许您在执行增量构建检查时忽略类型树更改。设置此标志后,如果包含的资源没有更改,但类型树发生了更改,则系统不会重新构建目标 assetBundle。
DisableLoadAssetByFileName:禁用按照文件名称查找资源包 LoadAsset。
默认情况下,可通过三种方式查找资源包中的相同资源:完整资源路径、资源文件名称和带扩展名的资源文件名称。完整路径在资源包中进行了序列化,而文件名称以及带扩展名的文件名称是在从文件中加载资源包时生成的。
示例:“Assets/Prefabs/Player.prefab”、“Player”和“Player.prefab”
此选项将在资源包上设置一个标志,以防止创建资源文件名称查找。此选项会节省资源包的运行时内存和加载性能。
3. 打包生成的文件
打包生成的文件在一次打包后会生成很多文件,根据用途分为以上3种:
-
本次构建的所有ab资源的清单列表,通过AssetBundleManifest加载使用。可以获取所有的ab包列表,ab包的依赖列表等。
-
单个ab资源源文件,通过AssetBundle.LoadFromXXX() 加载使用。
-
单个ab资源的manifest文件,包含ab资源的信息,如CRC码,hash值,包内的资源列表,依赖包等。如下图所示:
manifest文件
四. 加载AssetBundle
1. AssetBundleManifest的接口
manifaset文件主要用于记录 AssetBundle 里面的文件信息,AB包之间的依赖关系,文件夹下所有AB的信息,官方有提供对应的类AssetBundleManifest。关键API:
var bundleManifest = assetBundle.LoadAsset<AssetBundleManifest>(string name);
//获取目标ab的所有依赖(递归查找依赖的依赖)
bundleManifest.GetAllDependencies(string abName);
//获取文件夹下所有ab信息
bundleManifest.GetAllAssetBundles();
//获取目标ab的直系依赖(不递归查找依赖的依赖
bundleManifest.GetDirectDependencies(string abName);
2. 加载ab资源的接口
// 从文件中加载
AssetBundle.LoadFromFile();
AssetBundle.LoadFromFileAsync();
// 从内存中加载
AssetBundle.LoadFromMemory();
// 从网络下载
UnityWebRequestAssetBundle.GetAssetBundle();
① AssetBundle.LoadFromFile(Async)
最常用也是最推荐使用的方法。
这个方法可以高性能的加载没有压缩或者通过LZ4方法压缩的AssetBundle。
使用这个方法的时候,大部分平台只加载AssetBundle的包头,剩下的数据仍然在硬盘上。AssetBundles的对象会按需要进行加载,或者被间接引用到的时候加载。在这个情境下不会有额外的内存被消耗。
② AssetBundle.LoadFromMemory(Async)
不建议使用的API。常用于通过加密过的ab资源的加载。
如果你通过下载或者其他方法已经把Assetbundle的字节保存到了内存中的一个数组中,就能通过此函数进行加载。
使用这个方法的时候,底层会把内存中已经加载好的assetbundle数组byte[]拷贝到一个新分配的、相邻的地址上,如果使用了LZMA压缩方法,拷贝的时候还会顺便解压。造成的问题是内存使用的峰值至少是assetbundle的两倍。
③ UnityWebRequestAssetBundle.GetAssetBundle()
从远端服务器加载ab资源时推荐的方法。
UnityWebRequest类允许开发者定义unity应该怎么处理下载的数据,避免不必要的内存消耗。
使用DownloadHandlerAssetBundle 类可以对下载进行配置,使用一个worker线程流式下载数据,并存放在一个可变大小的buffer,或者某个临时的存储空间中。DownloadHandler 不会复制已下载的bytes。
对于LZMA压缩的Assetbundles,下载的时候会顺便解压,存放的时候通过LZ4再次压缩,可以通过Caching.CompressionEnabled 设置。
下载完成后,可以通过DownloadHandler的属性对assetbundle进行访问,这个行为类似于AssetBundle.LoadFromFile。
UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(assetBundlePath, 0);
var opt = request.SendWebRequest();
opt.completed += (_) =>
{
AssetBundle = DownloadHandlerAssetBundle.GetContent(request);
};
④ WWW.LoadFromCacheOrDownload
旧版API,因为性能问题已被废弃,建议使用UnityWebRequest。
五. 卸载AssetBundle
为什么要卸载AssetBundle?
每次加载AB包都会有内存的占用,如果一直不卸载,随着游戏的运行不停加载使用到的新AB包,内存占用会越来越大。
哪些AssetBundle可以卸载?
当前运行时没有使用到的Assetbundle可以卸载。没有使用到的Assetbundle指的是内部的资源当前未被引用。
想要卸载AssetBundle,首先需要了解它在内存中的占用组成。
AssetBundle的内存占用
下面内容引用自 AssetBundle内存 - csdn
AssetBundle的内存占用接下来详细看看每部分的含义。
黑色部分:www类本身占用内存,通过www接口加载AssetBundle才会有这部分内存,www对象保留了一份对WebStream数据(粉色部分)的引用。使用www =null 或者 www.dispose()释放。
其中www.dispose()会立即释放,而www = null会等待垃圾回收。释放www后WebStream的引用计数会相应减一。
橙色部分:官方称为WebStream数据,是数据真正的存储区域。当AssetBundle被加载进来后,这部分内存就被分配了。
它包含3个内容:压缩后的AssetBundle本身、解压后的资源以及一个解压缓冲区(图绿)。无论www(黑色部分)还是后面会提到的AssetBundle对象(粉色部分),都只是有一个结构指向了WebStream数据,从而能对外部提供操作真正资源数据的方法。而当www对象和AssetBundle对象释放时,WebStream数据的引用计数也会相应减1。当WebStream数据引用计数为0时,系统会自动释放。
但为了不频繁地开辟和销毁解压Buffer,其中绿色Decompression解压缓冲区Unity会至少保留一份。例如同时加载3个AssetBundle时,系统会生成3个Decompression Buffer,当解压完成后,系统会销毁两个。
粉色部分:AssetBundle对象,引用了橙色WebStream数据部分,并提供了从WebStream数据中加载资源的接口。通过AssetBundle.Unload(bool unloadAllLoadedObjects)释放。
如果调用AssetBundle.Unload(false),将释放AssetBundle对象本身,其对WebStream引用也将减少,从而可能引起WebStream释放,我们也就无法再通过接口或依赖关系从该AssetBundle加载资源。但已加载的资源还可以正常使用。
如果调用的是AssetBundle.Unload(true),不仅会释放WebStream部分,所有被加载出来的资源将被释放。
无论true或false,AssetBundle.Unload()都将销毁AssetBundle,销毁后调用该AssetBundle对象的任何方法都不会生效或产生报错,也就是说这个接口只能被调用一次,不能先调用unload(false)再调用unload(true)。
卸载的接口
AssetBundle.Unload(false)
会把 Bundle 卸载,但是已经从 Bundle 里加载出来的 资源 是不会被卸载的。
AssetBundle.Unload(true)
不但会卸载 Bundle,也会卸载已经从 Bundle 里加载出来的 所有资源,哪怕这些 资源 还被引用着。
常用的卸载策略
需要通过引用计数管理ab包。
推荐的卸载方式:
-
加载ab包中资源时,引用计数+1,释放已加载的资源时,引用计数-1;
-
当引用计数为0时,调用AssetBundle.Unload(true) 彻底卸载ab包资源和从ab包中加载出来的资源;
六. 其他细节
压缩方式
Unity3D引擎为我们提供了三种压缩策略来处理AssetBundle的压缩,即:
- LZMA格式:默认下采用,压缩为序列化流文件,高压缩比,解压缩时间长;
- LZ4格式:基于chunk压缩,中压缩比,解压快;
- 不压缩
可以自行压缩ab包,然后第一次进入游戏时进行解压,从而减少包体和体验的流畅。
CRC校验
AssetBundle.LoadFromFile(string path, uint crc, ulong offset);
AB包从服务端下载到本地,中途因为某些原因导致传输的文件被损坏了,但损坏的文件,不能使用。因此,需要对文件进行完整性的校验。
目前比较流行的算法有CRC、MD5、SHA1。它们都是通过对数据进行计算,来生成一个校验值,我们可以根据该值来检查数据的完整性。它们的不同点有以下几点:
(1)算法不同:CRC采用多项式除法、MD5和SHA1使用的是替换、轮转等方法。
(2)安全性不同:这里的安全性是指检错的能力,即数据的错误能通过校验位检测出来。CRC的安全性跟多项式有很大关系,相对于MD5和SHA1要弱很多;MD5的安全性很高;SHA1的安全性最高。
(3)效率不同:CRC的计算效率很高;MD5和SHA1比较慢。
(4)用途不同:CRC一般用作通信数据的校验;MD5和SHA1用于安全(Security)领域,比如文件校验、数字签名。
加密方式
-
位运算:打出ab包后,二进制读取然后对每位与密钥进行异或操作。A异或Key=B, B异或Key=A.因此读取的时候需要通过AssetBundle.LoadFromMemory进行加载,所以需要双份内存占用。
-
offset加密:打包时加上一定长度的字段作为offset,读取时通过官方的api进行读取:
AssetBundle abBundle = AssetBundle.LoadFromFile(assetBundleMapPath,0,8);
- unity中国区特供版有加密方法,打包前先设置秘钥:
(AssetBundle.SetAssetBundleDecryptKey("888888fff");
开源库推荐
xasset:https://github.com/xasset/xasset
ABSystem:https://github.com/tangzx/ABSystem
FSFramework:https://github.com/mr-kelly/KSFramework
BundleMaster:https://github.com/mister91jiao/BundleMaster