Unity AssetBundles and Resources指引 (一)
本文内容主要翻译自下面这篇文章
https://unity3d.com/cn/learn/tutorials/topics/best-practices/guide-assetbundles-and-resources?playlist=30089 A guide to AssetBundles and Resources
为了消除一些歧义,文章里面的专有名词直接用英文单词,比如Assets、Resource、Object
这篇文章是关于在unity引擎中进行Assets和resource管理的深度讨论,以下分四个部分来进行:
- 分析unity序列化Assets和处理Assets之间的引用的底层细节。
- 讨论内建的Resources API。
- 在第一部分的基础上讨论AssetBundle的基础。例如加载AssetsBundles以及从AssetBundles加载Assets。
- 讨论AssetBundle的应用模式。比如给AssetBundle赋值Assets以及从AssetBundle加载Assets并讨论开发者在使用AssetBundle遇到的一些常见陷阱。
注:本文说到的词汇Objects和Assets与Unity公开API的命名规范有些不同。比如本文说道Objects在一些API中被称为Assets,比如AssetBundle.LoadAsset和Resources.UnloadUnUsedAssets.本文称为Assets的文件很少有API对外公开,就算公开也是在Build相关的API。比如AssetDataBase和BuildPipeline。在这些API中他们被较为文件(files)。
第一部分:Assets,Objects 和序列化
本部分讨论在编辑器和运行时unity引擎序列化和维护Objects之间的引用关系的内部细节。也讨论了Objects和Assets之间的区别。理解了这些就可以在unity中有效的加卸载Assets。正确的Assets管理是保证较少的加载时间和更少的内存消耗的关键。
1.1 Inside Assets And Objects
要想在unity中正确的管理数据,就要理解unity识别(identifiles)和序列化(serializes)数据。
首先我们来区分Assets和UnityEngine.Objects:
一个Assets就是一个存储在Unity项目中Assets文件夹里面的一个存储文件。比如文理文件、材质文件和FBX文件都是Assets。有些Assets包含unity本地格式的数据。有些Assets需要处理成本地格式。比如FBX文件。
UnityEngine.Object是一套序列化的数据集合,用来描述一个Resource的特殊实例(instance)。Unity引擎可用的Resource可以是各种类型,比如网格、精灵、声音片段、动画片段等。所有的Objects都是UnityEngine.Object的子类。虽然大部分Objects类型都是内建的。但是有两个特殊的类型:
- ScriptableObject给开发者提供了便于自定义自己数据类型的方法。这个类型能够被unity序列化和反序列化,并且能够在检视窗口编辑。
- MonoBehaviour提供了一个连接到MomoScript的包装。一个MonoScript是一个内部数据类型,unity可以用他来保持到一个程序集和命名空间中某个脚本类的引用。
Assets和Objects是一对多的联系,一个给定的Asset文件包含一个或多个Objects。
1.2 Object之间的引用
所有的UnitEngine.Objects都能引起其他的Objects。别的Objects既可能存在于相同的Asset文件里面也可以从别的Asset文件里面导入。比如一个材质Object经常有一个或者多个贴图Objects的引用。这些贴图Objects一般是从一个或多个贴图Asset文件导入的。
当序列化时,这些引用包含两部分数据:文件的GUID和本地ID(local ID)。文件GUID用来确定Asset文件存储位置。本地ID用来确定Asset文件里面每个Object,因为每个Asset文件可能包含多个Objects。
文件GUID存储在.meta文件里面。当Unity首次导入一个Asset时候unity在相同的目录下为该Asset生成一个.meta文件。
我们可以用文本编辑器看到上面说的情况。新建一个unity项目,在Editor设置里面显示Meta文件并设置序列化Asset为文本格式。在项目中导入一张贴图,并创建一个材质,把贴图赋给材质。在场景中创建一个立方体,并把材质赋予该立方体。然后用文本编辑器打开材质的.meta,就可以看到在顶部有一行标记着guid。这就定义着该Asset文件的GUID。用文本编辑器打开材质文件,就可以找到局部id,比如看起来像下面这样的
---!u!21 & 2100000
Material:
serializedVersion: 3
... more data ...
上面2100000就是该Materail的本地ID。如果该Materail Object的所属Asset文件的GUID是“abcedfg”,那么该Material Object就可以被GUID“abcdefg” 和本地ID “2100000”唯一标记。
1.3 为什么需要File GUID和本地ID?
为什么GUID和本地ID是必须的呢?答案就是为了健壮性和弹性。
GUID抽象了文件位置。只要一个GUID能够和一个文件联系起来,文件位置就变得不相关了,所以在Unity里面文件可以自由的移动而不用更新引用了这个文件的Objects。
因为一个Asset文件可以包含多个Object资源,所以本地ID需要用来区分每个独立的Object。
如果Asset文件的GUID丢失了,那么所有的引用该Asset文件的Objects也就丢失了。所以.meta文件同Asset文件保存在同一个目录非常重要。Unity会重新生成删除掉或者放错位置的meta文件。
Unity编辑器维护一个一个路径同GUID映射集合。当一个Asset被载入项目的时候,就新增加一个映射。如果Editor在运行状态时发现一个meta文件丢失,只要Asset路径没有改变。Unity会确保生成相同的GUID。
如果unity没有运行而meta文件丢失,或者Asset路径改变了而meta文件没有一同移走。所有引用该Asset里面的Objects都会被丢失。
1.4 composite Assets and importers
前面提到非本地Asset类型可以导入到unity,这是通过asset导入器来完成的。虽然通常是自动调用的,但是仍然可以通过AssetImporterApi来调用。比如导入纹理Asset时,TextureImporterApi对外提供设置方法。
导入处理的结果就是一个或者多个Objects。这些在UnityEditor里面已父Asset里面的子资源的形式对外可见。比如一张贴图以精灵图集导入的时候,下面有多个精灵。每个Object共享一个File GUID,因为他们存储在同一个Asset文件里面,精灵之间以本地ID进行区分。
导入处理会把源Asset处理成Editor设置的目标平台适用格式。这种处理会保护非常重的操作,比如文理压缩。如果Editor每次打开都要操作的话效率将非常低下。因此unity会把处理结果缓存在Library目录。特别的是存在Library/metadata/目录中以GUID前面2位组成的目录里面。
1.5 序列化和实例化
虽然GUID和本地ID是健壮的,但是GUID比较比较慢因此运行时需要优化。Unity内部维护一个缓存,把GUID和LocalID转换成一个唯一的整数,被称为实例ID。该缓存维护这实例ID和GUID和本地ID定义的源位置以及内存里面的Object(如果有)。这就使得Object能够维持互相引用。通过实例ID能够快速查找已经加载的Object。如果目标Object还没有加载,通过GUID和本地ID,可以定位Asset位置,unity就可以及时Load该Object。
一开始,实例ID缓存会初始化所有的项目需要编译的Objects(比如场景引用的),以及Resource目录所有的Objects。运行时新导入的资源会附加进来以及从AssetBundles加载的Objects。实例ID的条目只有在没用的时候才从缓存移走。比如AsseBundle被卸载了。
1.6 MonoScripts
认识到MonoBehaviour有到MonoScript的引用非常重要。MonoScript仅仅是包含一些定位一个脚本的信息并不包含类的可执行代码:程序集名称、类名称和命名空间。
当编译一个项目时候,unity收集所有的脚本并编译到一个程序集。Unity会为每一种语言编一个程序集,同时也为Plugins目录编译一个程序集。比如plugins目录外的C#脚本编译成 Assembly-Csharp.dll。plugin目录下的编译成Assembly-Csharp-firstpass.dll。
程序集会被包含进最终的应用程序里面。MonoScript就是指向这里的引用。当程序开始运行时,所有的程序集都被加载。
这就是为什么AssetBundle没有真正包含可执行代码的原因。
1.7 资源生命周期
Objects在内存中加载和卸载。为了减少加载时间和管理应用内存,我们需要理解他的资源的生命周期。
有自动和显式加载Object。当实例ID和Object没有关联到时表面Object没有没加载到内存,当可以定位到Asset,Unity就自动加载Object。脚本可以显式加载Object,比如既可以创建他们也可以通过资源加载API(AssetBundle.LoadAsset).
当被加载后,unity会把GUID和本地ID的引用解析成实例ID。
一个Object的实例ID被第一次引用到时满足下面两个条件就会加载:
- 实例ID引用的Object当前还没有被加载。
- 实例ID关联的GUID和本地ID已在缓存中注册。
如果GUID和本地ID没有实例ID,或者实例ID引用一个错误的GUID和本地ID,引用被保留了,但是Object却不能加载。此时会出现一个Missing。丢失的Object根据类型是可见的,比如纹理丢失的话就会出现洋红色。
Objects的在以下情况会被卸载:
- 当清理未被使用的Asset时Objects自动被卸载。比如场景切换或者代码调用Resource.UnloadUnusedAssets方法时就会触发清理资源。这只会卸载没有被引用的Objects:既没有脚本变量引用到这个Object,也没有其他活跃状态的Objects引用这个Object.
- 从Resource文件夹加载的对象能通过Resource.UnloadAsset方法显式卸载。卸载后该Object实例ID仍在,如果卸载后有脚本变量或者其他对象有引用,这个Object会被立刻重新加载。
- 调用AssetBundle.UnLoad(true)时从AssetBundle加载的Objects会被立即卸载。任何引用该Object都会显示丢失。脚本里面任何引用卸载对象都会引发空引用错误。
如果AssetBundle.UnLoad(false)调用后,从AssetBundle加载的Objects会被被销毁掉。但是Unity会是GUID和本地ID同实例ID的关联失效。如果这些遗留的Object被卸载后,就不能重新再被加载了。
1.8 加载深层级对象
当序列化层级Objects(比如预制对象)时,整个层级都会被完整的序列化。也即每一个对象和组件都被单个的序列化到数据里面去,这也会影响加载和实例化时间 。
当创建GameObject层级时,CPU时间主要花费在下面几个方面:
- 读取资源数据(从存储器或者别的GameObject)
- 设置新的Transform的父子关系
- 实例新的GameObject和组件
- 唤醒GameObject和组件
无论是克隆还是从存储器读取,后面三部花费时间基本不变,但是读取时间却随着层级增加线性增加。
现在平台上,从内存要不存储设备要快。将来随着存储介质的变化,
能也会有很大的不同,桌面pc就要把移动设备要快。如果从慢设备上面加载。读取数据时间就大大超过实例化时间,因此性能瓶颈就在I/O上面。
当序列化一个预制对象时,所有的GameObject和组件数据都要序列化
即使是复制的对象。比如一个屏幕UI有30个一样的元素,这30个一样的元素就要序列化30次。会产生大量的二级制数据。加载的时候,这些数据又要从磁盘读取并被转换成新实例化的Object。造成实例化大型预制对象时文件读取占据主要时间消耗。
一旦被实例化后,克隆一个则比从磁盘读取加载要快得多。
注:unity5.4修改了transform对象在内存中的表示,每个根transform的子transform在内存中都是紧凑排列的。当需要实例一个立刻为其他对象子对象的GameObject时,可以考虑用新的GameObject.Instantiate重载方法,该方法接受一个parent参数,速度回提升5-10个百分点。