# 前言

之前其实有写一篇 『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
更多是减少运行时内存 —— 这一点在 官方文档 也有明确说明,更详细的内存方面的对比,后续我会再进行详细测试。

# 全打接口

关于全打接口,大概可以总结如下基本规则:

  1. 资源与其引用资源都指定了不同的 AssetBunldeName ,会分别分离单打
  2. 没有指定 AssetBunldeName,那么会跟引用它的对象打一个包
  3. 没有指定 AssetBunldeName,存在多个对它的引用就会有多个副本,造成内存冗余

对于全打接口的工作原理,使用 AssetBundles-Browser 也可以比较清晰看出其关系。

设置对应的 AseetbundleName 之后,就会展示在 AssetBundles-Browser 中。

  1. Prefab 和 图片设置不同 AseetbundleName:
  2. 只设置 Prefab AseetbundleName:
  3. 不指定 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 资源包,可以验证与上述关系一致。

# 单打接口

单打接口实际规则,跟全打有一点差异。

总结规则如下:

  1. 若只有主资源存在列表,无论引用资源是否有设置其它 AssetBundleName,其引用资源会自动与主资源打成一个包
  2. 若将主资源及其引用资源都传入,且引用资源有自己的 AssetBundleName ,那么会分别根据各自的 AssetBundleName 单打
  3. 若将主资源及其引用资源都传入,但引用资源没有设置自己的 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

# 总结

调用全打资源接口时:

  1. 资源与其引用资源都指定了不同的 AssetBunldeName ,会分别分离单打
  2. 没有指定 AssetBunldeName,那么会跟引用它的对象打一个包
  3. 没有指定 AssetBunldeName,存在多个对它的引用就会有多个副本,造成内存冗余

调用单打资源接口时:

  1. 若只有主资源存在列表,无论引用资源是否有设置其它 AssetBundleName,其引用资源会自动与主资源打成一个包
  2. 若将主资源及其引用资源都传入,且引用资源有自己的 AssetBundleName ,那么会分别根据各自的 AssetBundleName 单打

取决于项目资源加载管理,可采用的优化选项:DisableWriteTypeTree、DisableLoadAssetByFileName、DisableLoadAssetByFileNameWithExtension

例如,像我们项目通过 AssetBundle.LoadAllAssets (LoadAllAssetsAsync) 加载全部资源,然后管理,AssetBundle 自带的 LoadAsset (Name) 接口是不会使用的,此时就可以禁用名字加载。(正常情况下加载一个资源,一般来说也是使用完整路径的)

另外,通常出包以及出热更包必然也是同一个 Unity 版本 (应该不可能有 『项目还在线上就去动 release 分支 Unity 版本』的操作吧),此时可以禁用写入类型树以优化内存及加载。

其它:DeterministicAssetBundle 经测试与多方对比,可以认为全打资源默认始终包含该选项,仅单打资源时该项会对资源产生额外影响。(这大概也是为什么同为『始终启用』的 CollectDependencies 被标记为弃用,但它没有 —— 大概就是因为 DeterministicAssetBundle 对于单打该选项还有效)

参考文档: