# 前言
之前其实有写一篇 『AssetBundle 的实际测试与总结』的文章,不过现在看了下,感觉漏了一些,而且不够完善。
但是感觉改又不好改了,于是新建了一篇文章,重新整理一下。
主要使用工具有:
- Unity3D 2018.4.36f1
- Unity3D 2021.3.6f1
- Microsoft Visual Studio 2022
- Unity 工具:AssetBundles-Browser-1.7.0
# 功能简介
已知目前 Unity 主要提供了两种方式打 AssetBundle 包
- 一种是全打,根据整个项目资源的 AssetBundleName 标记自动生成,该过程全自动化,只有设置 AssetBundleName 这个步骤可以人为控制
- 另一种是单打,根据传入的资源路径、指定的 AssetBundleName 单独生成指定资源的 AssetBundle 包
均为一个接口的两个重载:
// 全打接口,根据项目已设置 AssetBundleName 的资源 | |
// 传入参数为 整个输出路径、打包选项及打包平台 | |
public static AssetBundleManifest BuildAssetBundles(string outputPath, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform) | |
// 单打接口,可以传入一个『需要打包』的资源列表、打包选项及打包平台 | |
public static AssetBundleManifest BuildAssetBundles(string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform) |
# 公共参数
其中公共参数为:
- BuildAssetBundleOptions
- BuildTarget
# BuildTarget
BuildTarget 就像名字一样很简单,主要是具体为哪个平台打的资源包,最简单的方式是直接使用 EditorUserBuildSettings.activeBuildTarget
即可。
或者利用预定义:
#if UNITY_STANDALONE_WIN | |
//BuildTarget.StandaloneWindows64 | |
#elif UNITY_IPHONE | |
//BuildTarget.iOS | |
#elif UNITY_ANDROID | |
//BuildTarget.Android | |
#endif |
单独分平台判断也可以 (用预定义的话,有必要的情况下这里还可以做些其它操作)。
# BuildAssetBundleOptions
默认情况下 AssetBundle 压缩格式为 LZMA,这个选项可以额外选择设置为 不压缩 或者 LZ4 压缩格式:
BuildAssetBundleOptions.UncompressedAssetBundle
BuildAssetBundleOptions.ChunkBasedCompression
可能有人会好奇两个同时传入会如何 (比如说我)?
试了下编辑器会直接报错,提示:
Cannot use options UncompressedAssetBundle and ChunkBasedCompression at the same time.
其它比较重要的还有:
# 1)BuildAssetBundleOptions.DeterministicAssetBundle
这个参数会保证同样的资源每次打出来的 AssetBundle 包二进制一致。
~~ 我的测试方式是:不传入该参数,先全打一次资源备份好,然后删除项目的 Library 再全打一次,就发现新的资源大小虽然跟原本的一样,但是内部二进制就有很大的不同了:
~~(以下错误尝试及结论内容已删除)
经过反复尝试,多次试验后得出结果:DeterministicAssetBundle 似乎并非这个作用。
之所以第一次删除 Library 后打包得出结果不一致,主要是因为 Library 生成的缓存不一致。在试验中,除了第一次删除试验,之后每次重新删除 Library ,再重新打开,后续工程打出来的 AssetBundle 包对比文件均一致了。
尝试在一个工程同时打出带 DeterministicAssetBundle 及不带该选项的资源包,结果最终生成的二进制 AssetBundle 资源包同样完全一致:
只有在单打某个资源时,该选项打出的资源包才会产生差异。
说明这个选项,至少在同一个环境下全打资源是不会对 AssetBundle 资源包产生任何影响的。
官方文档说明:
重新构建资源包时,资源包中对象的 ID 将在完成重新构建后 保持不变。
从 DeterministicAssetBundle 中加载内容也比一般资源包要慢。
那么这个选项究竟是起什么作用呢?有坏处,却没说明白好处。
官方的说明文档信息太少了,反复斟酌之后,偶然看到了说明的最后一行文字:
注意:此功能会始终启用。
...... 原来如此?难怪传入不传入,都没有影响。
后来又经过多方查找后,还看到有说找 Unity 官方确认,确定 Unity5.X 以后的确是默认加入的选项了:
https://answer.uwa4d.com/question/5a7a8b23847802258a065038
然后后续继续查找资料,在 Unity 官方论坛 Is the deterministic asset bundle option obsolete?
帖子也提到了同样的困惑,Unity 官方人员回答说该选项已经无效:
It's a bit of a mess, but yes, it's enabled permanently - the option has no effect.
但是为什么单打时,这个选项针对同一个资源,又会产生影响了?
测试单打资源显示:
BuildAssetBundleOptions.None: 54.7 KB (56,036 字节)
BuildAssetBundleOptions.DeterministicAssetBundle:54.7 KB (56,039 字节)
传入 DeterministicAssetBundle 选项时增加了 3 个字节大小,那么是否可以怀疑,单打时依然会有影响,官方说的『始终启用』仅针对全打资源的情况?
毕竟单打时的引用关系处理,跟全打也是有差异的。
所以这里可以总结为:该选项主要用于生成确定性 ID 以处理重新打包资源时依赖、引用关系,确保增量打包时没有真实修改的资源不会被重打。目前 Unity 全打接口已默认包含该项,且无论是否传入均不影响,单打资源时才会有影响且导致资源产生差异。
# 2)BuildAssetBundleOptions.DisableWriteTypeTree
禁用写入类型树。
根据相关介绍,这个为了给 Unity 跨版本之间做兼容性用的,在真机包出包版本与热更资源都是由一个版本出包情况下,这个选项可以考虑传入以优化性能。
禁用后可以降低包体和内存并提高加载效率,但是可能会造成 Unity 版本的兼容问题。
原理是根据序列化的字段进行反序列化,例如我们资源 meta 版本都有 serializedVersion ,以区分各个版本序列化选项情况,不同版本每个选项可能并不一致。
开启写入类型树时,Unity 在打 AssetBundle 时会先把数据内容的树状结构先写入一遍,然后再写入对应值,这样在加载 AssetBundle 时,先解析出字段的树状结构,然后与真机包包体的解析结构进对比,再解析实际数据值,处理缺失或多余字段,避免反序列化出错 (错位)。
如果不写入,那就是直接根据顺序去反序列化了,换了 Unity 版本字段可能就会反序列化错位,导致出现问题。
为了确定上述说法,可以进行一个简单的测试。
我用 Unity2018.4.36f1 打了两个 Cube.prefab AssetBundle 资源包,分为默认启用以及禁用,压缩模式为 不压缩:
然后在 Unity2021.3.6f1 中进行加载测试:
默认打包模式是可以直接读取的 (虽然按照名字加载失败了),确实加载到了对应资源。
场景中也可以利用 GameObject.Instantiate(x[0] as GameObject);
正确实例化出来。
而换成加载 『cube2018_disablewritetypetree』则直接报错,提示:
The AssetBundle 'Assets\MyBundles\cube2018_disablewritetypetree' could not be loaded because it contains run-time classes of incompatible version. Rebuild the AssetBundle to fix this error.
对于上述测试,利用 AssetBundles-Browser 也可以直接看出问题,默认的可以预览 Cube 各项属性,DisableWriteTypeTree 的资源版本则报同样错误。
如图所示:
当然,这个兼容性也有代价,首先每个 AssetBundle 都得包含额外的 TypeTree 信息,加载 AssetBundle 资源时会先构建这个 TypeTree 结构,然后再解析字段,增加部分 CPU 和 内存消耗。
# 3)BuildAssetBundleOptions.ForceRebuildAssetBundle
默认为增量构建,基于上一次的打包结果,对发生变化的资源重新打包,若传入这个参数,会完全重新打一次。
经过测试,增量构建的关键点在于 Unity 为每个 AssetBundle 资源同时生成的 .manifest 文件。
注 1:删除文件的 .manifest 也会产生同样的效果。
注 2:我们项目的 AssetBundle 关系由自己维护,尝试打完了之后删除 .manifest 节省空间,发现会被重新全打一次。
注 3:关系总表的 .manifest 对其它资源不产生影响,只删除某个资源的 .manifest 就会导致该资源直接被重打。
# 4)DisableLoadAssetByFileName、DisableLoadAssetByFileNameWithExtension
禁用使用 名字加载,这两个选项只影响内置的 AssetBundle.LoadAsset 方法。
如通过 AssetBundle.LoadAllAssets 加载后根据名字判断依然可行。
文件名 + 扩展名会在加载成功后生成,取决于项目的 AssetBundle 资源具体加载方式,可以传入减少一点点内存占用。例如通过全路径或者 AssetBundleRequest allAssets 自己判断方式。
例如,我们通常通过 AssetBundle.LoadAllAssets (LoadAllAssetsAsync) 加载全部资源,然后管理,AssetBundle 自带的 LoadAsset 接口是不会使用的,此时就可以禁用名字加载。
测试代码:
string pathDir = System.IO.Path.Combine(Application.dataPath, "MyBundles"); | |
AssetBundle assetBundle = AssetBundle.LoadFromFile(Path.Combine(pathDir, Name)); | |
Object[] x = assetBundle.LoadAllAssets(); | |
Object o = assetBundle.LoadAsset("Cube"); | |
Object o1 = assetBundle.LoadAsset("Cube.prefab"); | |
Object o2 = assetBundle.LoadAsset("Assets/Cube.prefab"); | |
Debug.Log("OK2"); |
表现如下:
—————————————————————————————————————————
最后,简单测试一下各个不同选项打出来资源大小:
# 图片
LZMA:
tex_default:272 KB (279,003 字节)
tex_disablename:272 KB (278,994 字节)
tex_disablewritetypetree:271 KB (278,163 字节)
tex_disablewritetypetree_and_disablename:271 KB (278,147 字节)
Uncompress:
tex_default:466 KB (477,968 字节)
tex_disablename:466 KB (477,984 字节)
tex_disablewritetypetree:466 KB (477,904 字节)
tex_disablewritetypetree_and_disablename:466 KB (477,936 字节)
LZ4:
tex_default:302 KB (309,643 字节)
tex_disablename:302 KB (309,653 字节)
tex_disablewritetypetree:300 KB (308,172 字节)
tex_disablewritetypetree_and_disablename:300 KB (308,183 字节)
# Prefab
LZMA:
tex_default:54.7 KB (56,036 字节)
tex_disablename:54.7 KB (56,040 字节)
tex_disablewritetypetree:47.6 KB (48,775 字节)
tex_disablewritetypetree_and_disablename:47.6 KB (48,789 字节)
Uncompress:
tex_default:194 KB (198,880 字节)
tex_disablename:194 KB (198,896 字节)
tex_disablewritetypetree:116 KB (119,168 字节)
tex_disablewritetypetree_and_disablename:116 KB (119,200 字节)
LZ4:
tex_default:80.2 KB (82,141 字节)
tex_disablename:80.2 KB (82,148 字节)
tex_disablewritetypetree:62.1 KB (63,668 字节)
tex_disablewritetypetree_and_disablename:62.1 KB (63,678 字节)
可以发现实际测试下来,在资源包大小上 DisableWriteTypeTree 影响更大,特别是 Prefab 资源,不压缩减少了 60% 左右、LZ4 模式减少了 25% 左右大小,LZMA 也有 13%。
虽然对纯图片资源这种 (其它音效之类一样) 减少不会很明显 —— 差不多就 1KB 的样子,毕竟图片资源的 meta 项本身也不会太多。
相对来说禁止名字加载对资源包的大小基本就没什么影响了,顶多几个字节的差异 (甚至是增加大小),不过据说 DisableLoadAssetByFileName、DisableLoadAssetByFileNameWithExtension
更多是减少运行时内存 —— 这一点在 官方文档 也有明确说明,更详细的内存方面的对比,后续我会再进行详细测试。
# 全打接口
关于全打接口,大概可以总结如下基本规则:
- 资源与其引用资源都指定了不同的 AssetBunldeName ,会分别分离单打
- 没有指定 AssetBunldeName,那么会跟引用它的对象打一个包
- 没有指定 AssetBunldeName,存在多个对它的引用就会有多个副本,造成内存冗余
对于全打接口的工作原理,使用 AssetBundles-Browser 也可以比较清晰看出其关系。
设置对应的 AseetbundleName 之后,就会展示在 AssetBundles-Browser 中。
- Prefab 和 图片设置不同 AseetbundleName:
- 只设置 Prefab AseetbundleName:
- 不指定 AssetbunldeName,而又有多个对它产生引用的对象,AssetBundles-Browser 都会直接进行提示:
若忽略提示,打包出来结果如下:
两个 Assetbunlde 包就分别包含了两份同样的图片资源。
调用全打接口代码:
[MenuItem("AssetBundleTest/PackALL")] | |
public static void Pack() | |
{ | |
BuildPipeline.BuildAssetBundles(Path.Combine(Application.dataPath, "MyBundles"), BuildAssetBundleOptions.DeterministicAssetBundle, EditorUserBuildSettings.activeBuildTarget); | |
AssetDatabase.Refresh(); | |
} |
- 对于上述设置一,会生成两个分别包含 RawImage (test1) 和 Tex (tex) 的 AssetBundle 资源包
- 对于上述设置二,会生成一个同时包含 RawImage 和 Tex 的 AssetBundle 资源包,名字为 『test1』
- 对于上述设置三,会生成两个同时包含 Prefab 和 图片的 AssetBundle 资源包,名字为 『test1』、『test2』
使用 AssetStudio 进行观察打出的 AssetBundle 资源包,可以验证与上述关系一致。
# 单打接口
单打接口实际规则,跟全打有一点差异。
总结规则如下:
- 若只有主资源存在列表,无论引用资源是否有设置其它 AssetBundleName,其引用资源会自动与主资源打成一个包
- 若将主资源及其引用资源都传入,且引用资源有自己的 AssetBundleName ,那么会分别根据各自的 AssetBundleName 单打
若将主资源及其引用资源都传入,但引用资源没有设置自己的 AssetBundleName ,那么引用资源都打入主资源一个包里。(注:此处应当是因为触发了第一项规则)
与『全打资源』最大的区别,大概要数第一项:无论被引用资源是否设置自己的 AssetBundleName,只要这次单打没有传入引用资源,那么就会被打入主资源包里。
单打测试代码:
[MenuItem("AssetBundleTest/PackSelect(LZMA)")] | |
public static void PackSelectLZMA() | |
{ | |
PackSelect(BuildAssetBundleOptions.DeterministicAssetBundle); | |
} | |
private static void PackSelect(BuildAssetBundleOptions opt, string extName = null) | |
{ | |
Object o = Selection.activeObject; | |
string path = AssetDatabase.GetAssetPath(o); | |
AssetImporter importer = AssetImporter.GetAtPath(path); | |
if (importer == null) return; | |
AssetBundleBuild build = new AssetBundleBuild(); | |
build.assetBundleName = string.IsNullOrEmpty(extName) ? importer.assetBundleName : string.Concat(importer.assetBundleName, "_", extName); | |
build.assetNames = new string[] { path }; | |
BuildPipeline.BuildAssetBundles(Path.Combine(Application.dataPath, "MyBundles"), new AssetBundleBuild[] { build }, opt, EditorUserBuildSettings.activeBuildTarget); | |
AssetDatabase.Refresh(); | |
} |
以上述『PackSelect』代码及上述资源为例,为 『RaweImage』『RaweImage2』及『Tex』分别设置不同的 AssetBunldeName,调用 PackSelect 传入各自的 AssetBunldeName 及资源路径分别进行单打操作,结果:
主资源及其引用图片被打进了一个包里。
对比调用 『PackALL』 全打资源结果:
其中 test1、test2 均引用了 tex 资源,从大小上看即可表明单打接口资源是被分别单打的了。
—————————————————————————————————————————
若单打资源,同时传入了主资源和引用资源呢?
稍微修改一下『PackSelect』代码,使其可以单打所有『当前选中』的资源:
[MenuItem("AssetBundleTest/PackSelectALL")] | |
public static void PackSelectALL() | |
{ | |
Object[] o = Selection.GetFiltered<Object>(SelectionMode.Assets); | |
AssetBundleBuild[] buildArray = new AssetBundleBuild[o.Length]; | |
AssetImporter importer; | |
AssetBundleBuild build; | |
for (int i = 0; i < buildArray.Length; i++) | |
{ | |
importer = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(o[i])); | |
build = new AssetBundleBuild(); | |
build.assetBundleName = importer.assetBundleName; | |
build.assetNames = new string[] { importer.assetPath }; | |
buildArray[i] = build; | |
} | |
BuildPipeline.BuildAssetBundles(Path.Combine(Application.dataPath, "MyBundles"), buildArray, BuildAssetBundleOptions.DeterministicAssetBundle, EditorUserBuildSettings.activeBuildTarget); | |
AssetDatabase.Refresh(); | |
} |
刚开始研究这个单打接口,或许会写出这种单打当前选中所有资源的代码,然后总结出错误的结论 (比如说我):若传入两个没有引用关系的资源,但是都为同一个 assetBundleName,单打会报错,只会生成其中一个资源的 assetBundle 包。
但要是深入一点测试、思考一下,就会发现这种写法是有问题的。
问题在哪儿呢?
首先尝试上述代码执行,选中两个设置了同一个 assetBundleName 的资源后,调用结果:
Trying to add file F:/Study/AssetbundleTest/Assets/MyBundles/tex.manifest to the list of ouptut files in the build report, but a file at that path has already been added.
意思是添加的文件重复了,但明显资源名字是不同的,那问题可能就是在 assetBundleName?
然后往参数那边看,每一个 AssetBundleBuild 中的 assetNames 参数实际上是一个『数组』,也就是一系列的资源路径,所以对于单打接口,每个 AssetBundleBuild 都是独立的,它不会自动去判断传入的总列表中是否有重复的标签,因此需要我们手动去重:把同一个标签,重复的合并在一个结构中。
修改后代码如下:
[MenuItem("AssetBundleTest/PackSelectALL")] | |
public static void PackSelectALL() | |
{ | |
Object[] o = Selection.GetFiltered<Object>(SelectionMode.Assets); | |
List<AssetBundleBuild> assetBundleBuilds = new List<AssetBundleBuild>(o.Length); | |
AssetImporter importer; | |
AssetBundleBuild build; | |
int index; | |
for (int i = 0; i < o.Length; i++) | |
{ | |
importer = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(o[i])); | |
// 判断是否存在重复 | |
index = assetBundleBuilds.FindIndex((x) => x.assetBundleName == importer.assetBundleName); | |
if (index == -1) | |
{ | |
build = new AssetBundleBuild(); | |
build.assetBundleName = importer.assetBundleName; | |
build.assetNames = new string[] { importer.assetPath }; | |
assetBundleBuilds.Add(build); | |
} | |
else | |
{ | |
// 重复,则添加进去 | |
build = assetBundleBuilds[index]; | |
string[] newAssets = new string[build.assetNames.Length + 1]; | |
newAssets[0] = importer.assetPath; | |
System.Array.Copy(build.assetNames, 0, newAssets, 1, build.assetNames.Length); | |
build.assetNames = newAssets; | |
assetBundleBuilds[index] = build; | |
} | |
} | |
BuildPipeline.BuildAssetBundles(Path.Combine(Application.dataPath, "MyBundles"), assetBundleBuilds.ToArray(), BuildAssetBundleOptions.DeterministicAssetBundle, EditorUserBuildSettings.activeBuildTarget); | |
AssetDatabase.Refresh(); | |
} |
再次尝试,同一个 AssetBunldeName 没有引用关系的几个资源,顺利被打入同一个 AssetBundle 资源包。
# 动画资源
昨天主程会提到了动画脱壳,简单试一下资源包大小:
上述分别为:
anim_off:带 FBX 动画,关闭 Anim.Compression
animall:带 FBX 动画,Anim.Compression 为默认的 KeyFramReduction
animreopt:不带 FBX 动画,Anim.Compression 为默认的 KeyFramReduction
animreduce:不带 FBX 动画,Anim.Compression 为 Optimal
# 总结
调用全打资源接口时:
- 资源与其引用资源都指定了不同的 AssetBunldeName ,会分别分离单打
- 没有指定 AssetBunldeName,那么会跟引用它的对象打一个包
- 没有指定 AssetBunldeName,存在多个对它的引用就会有多个副本,造成内存冗余
调用单打资源接口时:
- 若只有主资源存在列表,无论引用资源是否有设置其它 AssetBundleName,其引用资源会自动与主资源打成一个包
- 若将主资源及其引用资源都传入,且引用资源有自己的 AssetBundleName ,那么会分别根据各自的 AssetBundleName 单打
取决于项目资源加载管理,可采用的优化选项:DisableWriteTypeTree、DisableLoadAssetByFileName、DisableLoadAssetByFileNameWithExtension
例如,像我们项目通过 AssetBundle.LoadAllAssets (LoadAllAssetsAsync) 加载全部资源,然后管理,AssetBundle 自带的 LoadAsset (Name) 接口是不会使用的,此时就可以禁用名字加载。(正常情况下加载一个资源,一般来说也是使用完整路径的)
另外,通常出包以及出热更包必然也是同一个 Unity 版本 (应该不可能有 『项目还在线上就去动 release 分支 Unity 版本』的操作吧),此时可以禁用写入类型树以优化内存及加载。
其它:DeterministicAssetBundle 经测试与多方对比,可以认为全打资源默认始终包含该选项,仅单打资源时该项会对资源产生额外影响。(这大概也是为什么同为『始终启用』的 CollectDependencies 被标记为弃用,但它没有 —— 大概就是因为 DeterministicAssetBundle 对于单打该选项还有效)
参考文档:
- BuildPipeline.BuildAssetBundles
- AssetBundles-Building
- Asset 的一生
- Unity AssetBundle 爬坑手记
- AB 增量打包问题
- Is the deterministic asset bundle option obsolete?
- DeterministicAssetBundle
- DisableLoadAssetByFileName
- assetbundles-and-dependencies