oynix

于无声处听惊雷,于无色处见繁花

Asset的一生(二)

转载:原文地址

Unity Asset的简单介绍

由于篇幅问题,所以分了两篇,继续我们上一篇的介绍吧~

3.Asset与AssetBundle

1.什么是AssetBundle?

AssetBundle是什么?严格来说,AssetBundle是Asset的一个集合,是个压缩包。是什么?严格来说,AssetBundle是Asset的一个集合,是个压缩包。相比直接使用Asset,它有如下几个好处:

  1. 可以帮我们解决文件之间的依赖关系,一个项目中里面的资源依赖是非常复杂的,AssetBundle可以帮我们解决这样的问题,即Dependencies,但是也可能造成资源冗余的新问题。

  2. 可以做一个跨平台,在打包的时候我们希望在不同的平台上用不同的东西,比如说不同平台不同的文件格式,希望打成不同的bundle。

  3. 可以帮我们做一个快速的索引。

  4. 它是被压缩过的,可以节省内存。

所以说AssetBundle实际上是Unity的一套虚拟文件系统,它延展了Unity的跨平台性,使我们的Build Pipline的代码是可以一致性的,也就是说我们只需要写一份代码就可以打各个平台的AB,只需要简单的调整一些参数即可。

AssetBundle简单来说由两部分组成的,一部分就是压缩的内容,叫内容体,还有一部分就是它的头。也就是摘要信息,官方示意图如下:

实际的AssetBundle要比这个更复杂,它包裹了很多层,最里面那层是大家经常看到的cab为开头的这种key(如下图,我们用AssetStudio查看AssetBundle时就可以看见这个key),再外面一层叫artifactKey,两层去包裹这样的一个AssetBundle。

还有种特殊的AssetBundle,就是Scene,它是一个单独的AssetBundle,因为它和其他的Asset的处理方式是不一样的,所以Asset和Scene是不能打到一起的,要分开打。

当我们加载一个AssetBundle的时候,它的头会立刻加载进内存,这个也是我们在Profiler里面经常看到的SerializedFile。剩下的内容,也就是Bundle里面的Asset,它是按需加载的。也就是说如果我们不去加载这个Asset,它是不会从包体里被加载到内存中的。但是有一个例外,就是默认的LZMA的压缩,这种压缩格式用一个数据流代表整个AssetBundle,因此要读取里面任意一个Asset的时候需要解压整个数据流。

2.AssetBundle的参数

当我们调用Unity的API去打AssetBundle的时候,实际上有很多的参数可以供我们选择。如果没有选择合适的参数,就可能会导致在包体,内存以及加载时间等方面造成很多的浪费。

实际上我们经常用到的有这么几个:

  1. ChunkBasedCompression:这个参数是压缩AssetBundle的用的。前面提到Android的StreamingAssets是不压缩的。为了减小包体大小,可以使用该参数对AssetBundle进行压缩。它实际上是一个由Unity改良过的LZ4,使它的算法更符合Unity的使用方式。

  2. DisableWriteTypetree:这个其实是会被很多开发者忽略的一个参数,它非常有用,可以帮我们减小AssetBundle包体的大小,同时也可以减小内存,以及减少我们加载这个AssetBundle时的CPU时间。

  3. DisableLoadAssetByFileName,DisableLoadAssetByFileNameWithExtension:当我们加载好一个AssetBundle然后使用LoadAsset加载Asset的时候,需要传递Asset的路径名称。这个名称有三种写法,分别是Asset的文件名,Asset的文件名+扩展名,Asset的全路径,如下:

1
2
3
4
AssetBundle ab = AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, "sphere"));
Instantiate(ab.LoadAsset("Sphere"));
Instantiate(ab.LoadAsset("Sphere.prefab"));
Instantiate(ab.LoadAsset("Assets/Sphere.prefab"));

如果我们打AssetBundle时,不设置上面两个参数,那么使用这三种名称都可以正确的加载AB里面的Asset。但是其中只有全路径是被序列化到AssetBundle当中的,我们查看对应的.manifest也可以发现里面存储的是全路径:

1
2
Assets:
- Assets/Sphere.prefab

而文件名和文件名+扩展名是在AssetBundle被加载成功后产生的,因此就会产生一定的代价的。当我们没有Disable打AssetBundle的时候,实际上是算了一个Hash进去的,当通过文件名去找Asset的时候,它会去生成这个文件名的原路径,然后去对比。所以呢,在CPU时间和内存上多多少少会有一些消耗。如果我们确定我们的加载Asset的方式是用全路径加载的话,那么就可以把它关闭掉。

实践出真知,我们来简单的测试一下,假如我们有如下代码来生成不同设置下的AssetBundle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class AssetBundleEditor : Editor
{
[MenuItem("Build/BuildBundleWithUncompresse")]
public static void BuildBundleWithUncompresse()
{
BuildPipeline.BuildAssetBundles(Application.streamingAssetsPath, BuildAssetBundleOptions.UncompressedAssetBundle, BuildTarget.StandaloneWindows64);
}

[MenuItem("Build/BuildBundleWithTypeTree")]
public static void BuildBundleWithTypeTree()
{
BuildPipeline.BuildAssetBundles(Application.streamingAssetsPath, BuildAssetBundleOptions.ChunkBasedCompression, BuildTarget.StandaloneWindows64);
}

[MenuItem("Build/BuildBundleWithoutTypeTree")]
public static void BuildBundleWithoutTypeTree()
{
BuildPipeline.BuildAssetBundles(Application.streamingAssetsPath,
BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.DisableWriteTypeTree, BuildTarget.StandaloneWindows64);
}

[MenuItem("Build/BuildBundleWithoutExtraName")]
public static void BuildBundleWithoutExtraName()
{
BuildPipeline.BuildAssetBundles(Application.streamingAssetsPath,
BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.DisableLoadAssetByFileName | BuildAssetBundleOptions.DisableLoadAssetByFileNameWithExtension,
BuildTarget.StandaloneWindows64);
}
}

同样的Prefab在四种设置下得到的AssetBundle大小如下图:

可以发现在都使用压缩的情况下,用和不用TypeTree大小差了将近30%,可见如果不写入TypeTree,那么当AssetBundle很多的时候,可以节省的大小是非常可观的。个人测试了下,大约2.5G的AssetBundle,如果不写入TypeTree可以缩小到2.2G。

但是也会发现,不写入FileName这些,AssetBundle似乎并没有什么缩小,这是因为我们的AssetBundle太小了,名字只有一个sphere,所以基本看不出大小上的变化,若点击查看详情,可以发现大约有1字节的变化。DisableLoadAssetByFileName更主要的是针对CPU Time和运行时内存的优化。

接下来来看看运行时的情况,写一个简单的Demo,来看下不同参数打出来的AssetBundle在内存中的使用情况。点击按钮的时候调用下面代码,加载我们的AssetBundle,首先测试的是使用ChunkBasedCompression打出的AssetBundle:

1
2
3
4
5
void LoadAsset()
{
AssetBundle ab = AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, "sphere"));
Instantiate(ab.LoadAsset("Sphere"));
}

然后我们打个EXE文件出来测试,一定要在运行时环境测试,build时勾选Development Build。然后运行我们的exe文件,Profiler窗口选择我们运行的程序,点击Load按钮,Take Sample查看Memory项,如下图:

会发现我们的AssetBundle被加载进了内存(如下图),前面提到CAB-xxx包含的是AssetBundle头的信息,因此后面跟着的大小也是AssetBundle头的大小。

注意,如果在编辑器里面运行这个Demo测试,你会发现在SerializedFile下加载了茫茫多的资源,这也是因为Unity编辑器为了保证我们编辑时的体验,会预先加载一部分资源,因此我们需要打包出来在运行时测试,得到的结果才是真实环境下的结果。

然后我们把再加上DisableWriteTypeTree选项来测试一下,得到的结果如下,会发现不写入TypeTree的话,内存空间同样减少了一大截。

接着我们使用DisableLoadAssetByFileName(WithExtension)来打测试一下,会发现我们的Asset加载不出来了,因为LoadAsset(“Sphere”)的结果为null了,只有使用全路径才能正确的加载。

3.AssetBundle的识别

当我们前后两次打出AssetBundle的时候,如何判断哪些AssetBundle是有差异的,哪些AssetBundle是没有发生变化的呢?

很多人会通过计算两次打出来AssetBundle的md5来判断是否发生变化,实际上这种方式是不推荐的。因为在Unity打包的过程中,有一些因素是不稳定的,可能导致你两次打包之后的AssetBundle,虽然你里面的东西没有变,但是打出来的Binary不是严格一致的,从而md5也是不一样的。所以不建议算打出来之后的AssetBundle。那怎么算呢?我们可以算Library里的文件的md5,或者是原文件以及对应的meta文件的md5,用这些算出来的hash做为AssetBundle的变化依据是可以的。

在跟AssetBundle一起生成出来的 .manifest 文件中,包含有 AssetFileHash 的字段,可以用来作为我们的识别依据。此外在 .manifest 中还有个CRC(cyclic redundancy check)的字段用来判断AssetBundle的完整性,也常常被加入识别依据当中。

4.AssetBundle的策略

那么我们AssetBundle的大小怎么样是最合适的呢?简单来说不要去走极端,打的过大或过小都是不好的。

AssetBundle过大的问题:

  1. 不容易被下载下来,手机的网络速度相对比较慢,如果AssetBundle很大且没有断点续传,万一用户下了一半失败了,那么就得重新下载,极端的情况下可能就是根本下不下来。

  2. 如果一个AssetBundle里面的东西非常非常的多,就会导致AssetBundle头里面记录的信息摘要,也会非常非常的多。

AssetBundle过小的问题:

  1. 例如打了一个1K的AssetBundle,那么AssetBundle头的占比就会非常的大,这样有效的数据量就非常的小,大部分的数据量都变成了头文件。

Scene打包AssetBundle是合适的,只是说一些组件不要都扔到一个AssetBundle里。官方推荐1-2M是一个比较合适的大小,5G普及后,5-10m也ok。这些指的是需要从网上下载的AssetBundle,如果是放在StreamingAsset下本地带的AssetBundle就可以5-10m左右,不要大于10M,如果大于10M就会有很多问题出现。

4.Asset的加载及管理

1.Editor和Runtime加载机制不同

比如说前面查看Profiler时看的SerializedFile项,在Editor下可以到五十几个,为什么呢?因为使用Unity编辑器的时候,Unity首先保证的是开发过程中的流畅度。因为开发环境的设备一般比较好,所以会尽量把一些资源都提前加载进来,甚至有些情况下,会去额外的加载一些数据来方便和加速大家的编辑和制作过程。

而在Runtime的时候,Unity会严格保证按需加载的方式,来尽量节省目标设备上的内存和CPU,所以它们是完全不一样的两套模式,加载机制也都是完全不一样的。因此我们不能用以Editor的Profiler去当做衡量标准,一定要去Profiler真机,这个才是最终的衡量标准。

2.序列化和反序列化

先来做一个简单的测试,新建一个空场景,里面什么都没有,然后我们创建三个Cube在场景内,如下图:

接着我们用文本编辑器打开该Scene文件看一看,会发现里面Cube相关的Object信息会有三份,例如三份Transform,三份MeshRenderer,整个文件大概有400行左右。

接下来我们把这三个Cube删掉,创建一个Cube的Prefab,放三个该Prefab在场景内,如下图:

再来看看Scene文件,神奇了,该文件变得只有300行左右了,原本很多重复的Object也没有了,变成了三份PrefabInstance信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- !u!1001 &2515783927632439704
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
m_TransformParent: {fileID: 0}
m_Modifications:
- target: {fileID: 2515783927589610787, guid: d8e3e5db1969c994dacbd9ce76c282fa, type: 3}
propertyPath: m_Name
value: Cube
objectReference: {fileID: 0}
- target: {fileID: 2515783927589610791, guid: d8e3e5db1969c994dacbd9ce76c282fa, type: 3}
propertyPath: m_RootOrder
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2515783927589610791, guid: d8e3e5db1969c994dacbd9ce76c282fa, type: 3}
propertyPath: m_LocalPosition.x
value: 0
objectReference: {fileID: 0}
......
m_RemovedComponents: []
m_SourcePrefab: {fileID: 100100000, guid: d8e3e5db1969c994dacbd9ce76c282fa, type: 3}

可以看出使用Prefab的方式,会利用一个PrefabInstance通过guid来引用外部的Prefab,而不是把所有的Object信息都添加进来,来减少数据量。Unity在根据Asset做序列化和反序列化的时候,是以Asset文件里的单位来做的,所以可以简单的理解为,里面的内容越多,要解析的也越多,速度就越慢。

这就意味着当Unity去解析打到版本里的场景的时候,解析使用了Prefab的场景会更快,而且更省内存。因为在解析使用了Prefab场景的时候,Unity会优先把PrefabInstance指向的Prefab(例子中的Cube)解析出来,并且让场景中相同Prefab的引用指向同一块内存,所以在内存中,这三个Cube相对比较省的,只解析了一遍。而对于不使用Prefab的第一种情况,Unity会认为那三个Cube是完全不同的三个东西,所以在读取这个场景的时候Cube会被解析三遍,这个过程就浪费了。

若我们做场景的时候拖一堆白色的东西在底下(即没有使用Prefab),就会导致整个场景的加载速度非常的慢。所以建议大家能用Prefab的地方尽量用Prefab,这不仅制作过程会变得方便,在Runtime时性能也会有所提升。

3.TypeTree(兼容性之树)

在前面介绍打AssetBundle选项的时候,提到了不写入TypeTree的种种好处,那么既然写入TypeTree有这么多的缺点,Unity为什么还要默认写入呢?因为它是为了给Unity跨版本之间做兼容性用的。

不管是在Prefab文件里或者是meta文件里,经常会看到有一个字段叫作serializedVersion,值是一个数字,它代表着当前Object或者是Importer的数据格式版本。因为随着Unity引擎不断的发展,需要更多或更少的数据格式来描述一个数据内容,当我们每次去做数据更改的时候都会去修改serializedVersion,来表示当前我用的是哪一个版本。

例如导入同一张图片,在不同Unity版本下的.meta文件如下:

通过serializedVersion可以知道2020.3.13的版本在TextureImporter的结构上比起2018.4.20版本已经修改过两次了,例如新版本里多了名为vTOnly的属性。

如果我们不开启写入TypeTree,Unity在打AssetBundle的时候,只会把Object以及Importer里的值写入到AssetBundle当中,例如图中的0、0、1、0、0等等。然后当我们使用这个AssetBundle的时候,会按照当前Unity的版本对应的格式反向解析这些值,例如第一个值0是mipMapMode,第二个值0是enableMipMap,第三个值1是sRGBTexture。

这样就会存在一个问题,假如我们的AssetBundle是2018版本打的,然后使用是在2020的版本,那么在反向解析的时候,格式按照的是2020的,那么原本是grayScaleToAlpha的值会被赋值到vTOnly上,并且后面的值也全乱了,就会导致AssetBundle在跨版本后就用不了。

而TypeTree就是上诉问题的一个解决方案,当我们开启TypeTree的写入,Unity在打AssetBundle时会先把数据内容的树状结构先写入一遍,即mipMapMode、enableMipMap、sRGBTexture这些字段,然后才去写入它们的值,这也就导致了AssetBundle的大小增加。然后在使用的时候,Unity会先解析TypeTree,然后再去反向解析数据内容。这样当我们用2020解析2018的AssetBundle时,发现有个字段vTOnly是TypeTree里没有的,那么就会使用默认值填充。再比如有个字段是2018有的,2020没有的,那么会丢弃这个字段对应的值,防止反向解析出错。

也就是说Unity通过serializedVersion以及TypeTree的方式,来把跨版本兼容性给做出来了,这就是TypeTree的作用。所以如果你写入了TypeTree,那么你的AssetBundle中就会额外增加所有TypeTree的信息,还会在CPU加载的时候额外遍历一遍TypeTree,同时会在内存中生成一个TypeTree的结构。所以这就是为什么磁盘空间加了,内存加了,CPU时间也加了。

那在什么情况下,我们可以不用TypeTree呢?当你确认项目打apk,ipa这些包的Unity版本和打AssetBundle的版本是一致的,那么这个时候就可以放心的关掉TypeTree,去节省掉这一部分内容。绝大多数的项目都可以关掉,除非项目需要做跨版本兼容。

4.同步和异步

在加载Asset的时候,Unity提供了有同步和异步的API,例如LoadAsset和LoadAssetAsync,那么它们分别什么时候选用比较好呢?

实际上这只是一个策略的问题,并没有哪个更好。同步最大的优点是快,因为在这一帧里面主线程所有的CPU全都会归你用,所有的时间片全都归你用,它可以一门心思的把这件事情做完,再做其他的事情。但是同步的问题就是会造成主线程卡顿。异步可以简单的理解为多线程(其实还是有点区别的),最大的优点是不怎么会造成主线程的卡顿(也不是完全不卡顿),主线程可以尽量不卡顿的去跑。但是异步永远比同步至少慢一帧,也就是说我这一帧发起的异步,最快也要下一帧才会开始执行。而且异步涉及到一个时间片的问题,所以有的时候异步的总体时间会比同步来的长。比如你用同步去加载一个东西,可能3ms就加载完了,但是你用异步去加载可能就要5ms或6ms才加载完,甚至更长。但是异步花费的这些时间是分布在多个帧里面的,在后台线程里面去跑的,所以它会尽量减少卡顿。

也就是说你在一个对卡顿非常敏感的场景里面,比如战斗时的场景,那么你可以使用异步的方式,然后做一些兼容方式,保证它没有加载完之前有一些处理。但是你是在Loading的时候完全可以考虑分帧去使用同步,但是不能一帧里面加载太多东西,这样的话整体的速度会变快。

还需要注意的是,异步和同步如果混合使用,是会有问题的,下面一点就会介绍这个问题。

5.Preload与Presistent

在Unity引擎内部,是由PreloadManager与PresistentManager来主要负责加载的。PreloadManager负责调度任务,当上层有一个任务下来,比如说我要去加载Asset,那么它会形成一个新的Operation,这个Operation会给到PreloadManager。在PreloadManager里面有个队列,然后每一帧会从里面去取出一个任务(也就是一个Operation)去执行它。而当Operation执行的时候,会去调用PresistentManager(持久化管理器)。PresistentManager的主要的任务是把文件从硬盘上读取到内存当中,同时去给它分配一个id。

如果我们现在有个Operation是异步的,它正在执行。而在下一帧,PreloadManager又加载了一个同步的Operation,这就会导致同步的和异步的Operation会去抢着用PresistentManager。而PresistentManager分配ID和做IO这些都是要阻断线程的,所以它会 zao造成block。也就意味着你的异步工作可能会被你的同步工作阻断,反过来也有可能。所以同时使用的时候,会经常看见一个非常长的Loading.LockPersistenManager,锁的现象出现。

注:在2020的版本里,应该会解决这个锁的问题,因此如果一起使用,主线程阻塞的情况会变少。

参考:

  • Jeffrey Zhuang:U3D GetPreloadData 崩溃分析
  • AssetBundle lockpersistentmanager开销 – UWA问答

5.Asset的卸载

1.UnloadUnusedAssets

它可以卸载掉那些没用的Asset,把它从内存中清除掉。它也是个Operation,它和加载一样,也是归PreloadManager处理的,它必须独成的,不能并行。因为Unity在一次Load Operation开始的阶段就已经确定了哪些Asset要被Load,所以在Load的过程中又发生了Unload这样的操作,那就会导致有些确定了使用且已经被Load的Asset被卸载掉了,就会导致最后的出错。

所以Unity现在的设计是一个同步的过程,所以这个过程会造成卡顿。Unity在切换Scene的时候会自动调用一次UnloadUnusedAssets,如果是通过Scene来管理的话就没太大的必要关心造成的卡顿了。如果不是,那就需要自己找些合适的时机去调用一下。

2.AssetBundle.Unload

它又分true和false,但是无论哪一个都和上面的不一样,它不是一个Operation,也就是不归PreloadManager管。它会遍历当前加载过的东西,然后去把它删掉。

如果是true那就是把AssetBundle和它加载出来的Asset全都一起干掉。这个在不合适的时机就有可能发生Runtime的错误。如果是false,那么只是把AssetBundle给丢掉,Asset是不会被扔掉的。那么当你第二次去加载同一个AssetBundle的时候,在内存中就会有两份Asset,因为当AssetBundle被卸载的时候,它和对应的Asset的关系就被切割掉了。所以AssetBundle不知道之前的Asset是不是还在内存中,是不是从自己这加载出来的。所以使用AssetBundle.Unload就很考验游戏的规划。

Unity为什么不做成Reference?因为Unity内部对于这些Asset实际上是没有Reference的,很多时候是通过遍历去查找,实际上不存在大家想象的ReferenceCount,它和C#其实是不太一样的。目前Unity也是正在解决,或者用Addressables可以解决一部分的。

------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2025/10/076f4db2b790/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道