本章将深入Unity的序列化系统,以及Unity如何在编辑器下和运行时,不同对象之间维护健壮的引用,并且还讨论了对象(Objects)和资产(Assets)之间的原理差别,本章的注意还涵盖了如何从有效的加载和卸载Unity中的资产,正确的资产管理对于缩短加载时间和降低内存使用量至关重要。
要理解Unity如何管理数据,就需要了解Unity如何识别和序列化数据。第一个点是Assets和UnityEngine.Objects的区别(就是资产和对象的区别)
资产是存储在Unity项目的Assets文件夹中的文件,例如常见的Texture,3D模型和音频剪辑(audio clips)。某些资产包含了Unity原生格式的数据,如Material。其他的资产需要处理为原生格式,例如FBX文件。
UnityEngine.Object或者Object,都是一组序列化数据,共同描述了一个资源的特定实例。这个可以使Unity使用的任何类型的资源,如mesh,sprite,AudioCLip或者AnimationClip。所有的这些对象,都是UnityEngine.Object的子类。
虽然大部分的Object类型都是内置的,但是有两类特殊类型
资产和对象之间有一对多的关系,也就是说,任何给定的资产文件都包含了一个或者多个对象。
所有的UnityEngine.Objects都可以引用其他UnityEngine.Objects,这些对象可能位于同一个资产中,也可能从其他资产导入,例如,材质对象通常具有一个或多个纹理对象的引用,这些纹理对象通常从一个或者多个纹理资产(例如PNG或者JPG)导入。
在序列化时,这些引用由两个独立的数据组成:文件GUID和本地ID,文件GUID标识存储目标资源的资产文件,本地ID在同一个资产中具有唯一性,标识资产中的每个对象,因为资产可能包含多个对象。
文件GUID存储在.meta文件中,这些meta文件在Unity首次导入资产时生成,并且与资产存储在同一个目录中。
上述标识和引用系统可以在文本编辑器中看到:创建一个新的Unity项目,创建一个材质,并将纹理导入项目,然后把材质应用到一个立方体中。
用文本编辑器打开该材质的.meta文件,标有”guid”的那一行,会出现在文件顶部附近,这一行定义了该材质的GUID,材质对象的定义如下:
--- !u!21 &2100000
Material:
serializedVersion: 3
... more data …
在上面的例子中,以&符号开头的数字是材料的本地ID。 如果该材质对象位于GUID为“abcdefg”的资产内,对象可以唯一标识为文件GUID“abcdefg”和本地ID“2100000”的组合。
为什么Unity需要GUID和本地ID?,答案是为了健壮性,并提供一个灵活,并且与平台无关的工作流程。
文件的GUID提供文件特定位置的抽象表达,只要有与特定文件关联的GUID,那么这个文件在磁盘上的位置就无关紧要了,该文件可以自由移动,而无需更新引用该文件的对象。
由于任何给定的资产文件可能包含(或者通过导入生成的)多个UnityEngine.Object资源,因此,需要本地ID来明确区分每个不同的对象。
如果与资产文件关联的文件GUID丢失,那么对该资产文件中的所有对象的引用也将丢失,这就是为什么.meta文件必须和对应的资产文件保持相同的文件名,并且保存在相同的文件夹中,请注意,Unity会重新生成已删除或者错位的.meta文件。
Unity编辑器具有到已知GUID的文件的映射,无论何时加载或导入资产,都会记录一个映射关系,映射关系将资产的路径链接到资产的GUID,如果Unity编辑器在打开状态下,meta文件丢失,但是资源路径没有更改,编辑器可以确保保留相同的GUID。
如果在Untiy编辑器关闭时丢失了meta文件,或者资产的路径发生变化,而meta文件没有和资产一起移动,那么所有对该资产中对象的引用都会丢失。
正如上文提到的,非原生资产类型必须导入Unity才能使用,这会通过一个资产导入器完成,虽然这些导入器会在资产导入的时候自动调用,不过这些API也通过AssetImporter把一些细节暴露了出来,例如,TextureImporter可以让你去更改相应的纹理资源,当然,这些导入的资源必须是有效的,就像PNG。
导入的结果是产生一个或者多个UnityEngine.Objects,这些在Unity编辑器中为父资产中的多个自资产,例如嵌套在已作为精灵图集导入的纹理资产下的多个精灵,每一个对象都共享一个文件GUID,因为它们的源数据储存在相同的资产文件中,它们将通过本地ID在导入的纹理资源中区分。
导入的过程,会把资源转换为适合在Unity中选择的平台的格式,导入过程可能会包括许多耗时的操作,例如纹理压缩,因为这个过程很长,所以导入的资源会缓存在项目根目录下的Library文件夹下,无需在下次启动时,重新导入。
具体来说,导入的结果会存储在一个以文件GUID前两位命名的文件夹中,该文件夹储存在Library/metadata/folder中,资产中的单个对象会被序列化为一个与资产的文件GUID名称相同的二进制文件。
此流程适用于所有资产,而不仅仅是非原生资产,原生资产不需要冗长的转换过程,或者重新序列化。
虽然文件GUID和本地ID是具有健壮性的,但GUID比较速度较慢,运行时需要更高性能的系统,Unity内部维护一个缓存(内部叫做PersistentManager),将文件GUID和本地ID转换为简单的,唯一的整数,这个就是Instance ID,并且在新对象向缓存注册时,会以简单递增的方式分配。
缓存维护给定的Instance ID,GUID和定义对象源数据位置的本地ID,以及内存中对象的实例(如果有的话)之间的映射,这允许UnityEngine.Objects健壮的维护彼此的引用,解析Instance ID引用可以快速返回由Instance ID表示的加载对象,如果目标对象尚未加载,则可以将文件GUID和本地ID解析为对象的源数据,从而使Unity能够及时加载对象。
在启动时,Instance ID缓存会被初始化,并包含了第一个场景中需要的所有对象(即构建场景中的引用)的数据以及Resources文件夹中包含的所有对象,在运行时导入新资产时(例如用代码创建一张纹理),以及从AssetBundles加载对象时,会在缓存中增加一个条目。当提供对特定文件GUID和本地ID的访问的AssetBundle被卸载时,将删除实例ID与其文件GUID和本地ID之间的映射以节省内存,如果重新加载AssetBundle,则将为重新加载的AssetBundle的每个对象创建一个新的Instance ID。
有关卸载AssetBundle的含义的更深入的讨论,可以看看Managing Loaded Assets
在特定的平台上,某些事件可能会强制对象内存不足,比如,IOS上,当APP暂定时,图形资产可能会从图形内存中卸载,如果这些对象来自被卸载的AssetBundle,那么Unity将无法重新加载对象的原始数据,对这些对象的任何现存的引用也是无效的,在前面的示例中,场景可能会看到网格不可见,或者红色的纹理。
实现注意事项:在运行时,上述控制流程不是完全准确的,在重载加载操作期间,运行时比较文件GUID和本地ID的性能不佳,因此,在构建Unity项目时,文件GUID和本地ID被映射为更简单,并且唯一,确定的格式,然而,这个概念仍然时相同的,并且用文件GUID和本地ID的思想,和运行时的操作是一个有效的类比,这也是资产GUID在运行时无法查询的原因。
理解MonoBehavior引用了一个MonoScript,以及MonoScript仅包含定位指定脚本类所需的信息,这两种Object都不包含脚本类的可执行代码。
MonoScript包含三个字符串:程序集名称,类名称和命名空间。
在构建项目时,Unity会将Assets文件夹中的所有分散的脚本文件,编译为Mono程序集,除了Plugins文件夹外,其他的C#脚本会放置到Assembly-CSharp.dll中,Plugins文件夹下的脚本放置在Assembly-CSharp-firstpass.dll中,以此类推(还有Editor)。此外,Unity2017.3还引入了定义自定义托管程序集的功能。
这些程序集以及预构建的程序集DLL文件都包含在Unity最终打包中,他们也是MonoScript引用的程序集,与其他资源不同,包含在Unity中的所有程序集都在引用程序启动时加载(StreamingAsset下的程序集除外)。
MonoScript的存在,让AssetBundle(或者Scene prefab)中并不需要包含可执行的代码,但允许不同的MonoBehavior引用特定的共享类,即使MonoBehavior位于不同的AssetBundle中。
为了减少加载时间和应用的内存占用空间,了解UnityEngine.Objects的资源生命周期非常重要,对象在特定和自定义的时间从内存中加载或卸载。
以下情况会自动加载对象:
也可以通过创建对象或通过资源加载API(如AssetBundle.LoadAsset)将对象现式加载到脚本中,加载对象时,Unity会尝试通过将每个引用的文件GUID和本地ID转换为Instance ID来解析任何引用。如果两个条件为真,则会在Instance ID第一次被引用时按需加载对象:
这个情况通常发生在引用本身被加载或者解析之后的很短时间内。
如果文件GUID和本地ID没有Instance ID,或者如果一个Instance ID引用了一个无效的文件GUID和本地ID,例如引用一个已卸载的对象,那么引用被保留,但实际的对象不会被加载,这在Unity编辑器中显示为Missing,在运行的时候,或在场景视图中,Missing对象将以不同的方式可见,例如,网格丢失,会不可见,纹理丢失会显示红色等。
对象在三种特定的情况中卸载:
如果调用了AssetBundle.Unload(false),那么来自这个AssetBundle的实例对象不会被销毁,但Unity会使其Instance ID的文件GUID和本地ID引用无效。如果这些对象从内存中卸载,但是有其他对象对它的引用,那么Unity是无法重新加载这些对象的(最常见的情况是,在运行时将对象从内存中移除而未被卸载时,Unity会失去对其图形上下文的控制权。 当移动应用程序被暂停并且该应用程序被强制置于后台时,可能会发生这种情况。 在这种情况下,移动操作系统通常会从GPU内存中清除所有图形资源。 当应用程序返回到前景时,Unity必须在场景渲染恢复之前将所有需要的纹理,着色器和网格重新加载到GPU)。
在序列化Unity GameObjects的层次结构时,例如在Prefab序列化期间,请务必记住整个层次结构将完全序列化。也就是说,层次结构中的每个GameObject和Component将分别在序列化数据中表示。这对加载和实例化GameObject的层次结构所需的时间有着有趣的影响。
在创建任何GameObject层次结构时,CPU时间用于几种不同的方式:
读取源数据(来自存储设备,来自AssetBundle,来自另一个GameObject等)
在新的Transform之间设置父子关系
实例化新的GameObjects和组件
在主线程中唤醒新的GameObjects和组件
后三种时间成本通常是不变的,无论层次结构是从现有分层结构克隆还是从存储结构加载。但是,读取源数据的时间随着组件和GameObjects序列化到层次结构中的数量的增加而线性增加,并且也乘以数据源的加载速度。
在目前所有的平台上,从内存中的其他地方读取数据比从存储设备载入数据要快得多。此外,存储介质的性能表现在不同平台之间差异很大。因此,在缓存的平台上加载Prefab时,从存储中读取Prefab序列化数据的时间可能会大大超过实例化预制件的时间。也就是说,加载操作的成本必然与存储I / O时间有关。
如上所述,当序列化一个整体预制件时,每个GameObject和组件的数据都会被单独序列化,这可能会复制数据。例如,具有30个相同元素的UI屏幕将具有相同元素序列化30次,产生大量的二进制数据。在加载时,这些30个重复元素中的每一个元素上的所有GameObjects和组件数据必须在传输到新实例化的对象之前从磁盘读取。文件读取时间是实例化大型预制件的总体成本的重要因素。大型层次结构应该模块化的创建实例,然后在运行时拼在一起。
Unity 5.4注意:Unity 5.4改变了内存中transforms的排列。每个根transforms的整个子层次结构都存储在紧凑,连续的内存区域中。在实例化新的GameObjects时,这些新的GameObjects会立即重新排列到另一个层次结构中,请考虑使用带有parent参数的GameObject.Instantiate重载变体。使用此重载可以避免为新的GameObject分配根变换层次结构。在测试中,这加快了实例化操作所需的时间约5-10%。