版本检查: 2017.3-难度: 高级
这是关于Unity5中的Assets、Resources和Resources Management的系列文章的第二章。
本章介绍了Unity的serialization系统的内部结构,以及Unity如何在Unity编辑器和运行时维护不同对象之间的健壮引用。它还讨论了Objects和Assets之间的技术区别。这里讨论的主题对于理解如何有效地加载和卸载Unity中的Assets至关重要。正确的Assets管理对于保持加载时间短、内存使用率低至关重要。
1.1. Inside Assets and Objects
要了解如何正确管理Unity中的数据,重要的是要了解Unity如何标识和序列化数据。第一个关键点是Assets和UnityEngine.Objects之间的区别。
Assets是磁盘上的文件,存储在Unity项目的Assets文件夹中。纹理、3D模型或音频剪辑是常见的Assets类型。有些Assets包含本机Unity格式的数据,如材料。其他Assets需要处理为本机格式,如FBX文件。
UnityEngine.Object或Object都有大写O,是一组序列化数据,它们共同描述资源的特定实例。这可以是任何类型的资源供UnityEngine使用,如mesh,sprite,AudioClip或AnimationClip。所有对象都是UnityEngine.Object基类的子类。
虽然大多数Object类型都是内置的,但有两种特殊类型。
1.ScriptableObject为开发人员定义自己的数据类型提供了一个方便的系统。这些类型可以通过UnityEditor的检查窗口进行本机序列化和反序列化,并进行操作。
2.MonoBehaviour提供了链接到MonoScript的包装器。MonoScript是Unity用于在特定程序集和命名空间中保存对特定脚本类的引用的内部数据类型。MonoScript不包含任何实际的可执行代码。
Assets和Objects之间存在一对多的关系;也就是说,任何给定的Asset文件都包含一个或多个对象。
1.2. Inter-Object references
所有UnityEngine.Objects都可以引用其他UnityEngine.Objects。这些其他对象可以驻留在同一个Asset文件中,也可以从其他Asset文件中导入。例如,material Object通常具有对texture对象的一个或多个引用。这些texture对象通常是从一个或多个texture Asset文件(如PNG或JPG)导入的。
在序列化时,这些引用由两个单独的数据部分组成:一个文件GUID和一个LocalID。文件GUID标识存储目标resource的Asset文件。本地唯一LocalID标识资产文件中的每个对象,因为资产文件可能包含多个对象。
文件GUID存储在.meta文件中。这些.meta文件是在联合第一次导入资产时生成的,并存储在与Asset相同的目录中。
上面的标识和引用系统可以在文本编辑器中看到:创建一个新的Unity项目并更改其编辑器设置,以公开可见的元文件并将Asset序列化为文本。创建一个材料并将一个纹理导入到项目中。将材料分配到场景中的立方体中,并保存场景。
使用文本编辑器打开这个材质对应的.meta文件。在文件顶端附近会有一行被标示为“guid”,该行定义了材质资源文件的文件GUID。
fileFormatVersion: 2
guid: 6839b719d14310c4f945de352bac3767
timeCreated: 1472566765
licenseType: Pro
NativeFormatImporter:
userData:
assetBundleName:
assetBundleVariant:
使用文本编辑器打开与材料关联的.meta文件。标记为“GUID”的行将出现在文件顶部附近。这一行定义了MaterialAsset的文件GUID。若要查找LocalID,请在文本编辑器中打开材料文件。物质对象的定义如下所示:
--- !u!21 &2100000
Material:
serializedVersion: 3
... more data …
在上面的例子中,前面有一个符号的数字是Material的LocalID。如果这个Material对象位于由文件GUID“ABCDEFG”标识的Asset中,那么可以将该物质对象唯一地标识为文件GUID“ABCDEFG”和LocalID“2100000”的组合。
1.3. Why File GUIDs and Local IDs?
为什么需要Unity的文件GUID和LocalID系统?答案是健壮性,并提供灵活的、平台无关的工作流。
文件GUID提供了文件的特定位置的抽象。只要特定的文件GUID可以与特定文件关联,则磁盘上的文件的位置变得无关紧要。该文件可以自由移动而不必更新引用该文件的所有对象。
由于任何给定的Asset文件可能包含(或通过导入)多个UnityEngine.Object资源,因此需要一个LocalID来明确区分每个不同的对象。
如果与Asset文件关联的文件GUID丢失,那么对该Asset文件中所有对象的引用也将丢失。这就是为什么.meta文件必须保持与其关联的资产文件相同的文件名和相同的文件夹存储是很重要的。请注意,Unity将重新生成已删除或错误放置的.meta文件。
UnityEditor有一个特定文件路径到已知的文件GUID的映射。每当加载或导入资产时,都会记录映射项。映射条目将Asset的特定路径链接到资产的文件GUID。如果在.meta文件丢失且资产的路径没有改变时,UnityEditor是打开的,编辑器可以确保Asset保留相同的文件GUID。
如果是的话。当Unity编辑器关闭时,元文件将丢失,或Asset的路径更改而不更改。元文件随资产一起移动,然后对该Asset内的对象的所有引用将被破坏。
1.4. Composite Assets and importers
如在“内部Assets 和Objects”部分中提到的,非本地Asset类型必须导入到Unity中。这是通过Asset导入器完成的。虽然这些导入通常被自动调用,但它们也通过AssetImporter API暴露于脚本中。例如,TextureImporPorter API提供对导入单个纹理资产(如PNG文件)时使用的设置的访问权限。
导入过程的结果是一个或多个UnityEngine.Objects。在UnityEditor中可以看到parent Asset中的多个sub-assets,例如嵌套在纹理资产下面的多个Sprite,该texture资产已作为sprite地图集导入。这些对象中的每一个都将共享一个文件GUID,因为它们的源数据存储在同一个资产文件中。它们将在导入的纹理资产中通过LocalID进行区分。
导入过程将源Asset转换为适合Unity编辑器中选择的目标平台的格式。导入过程可以包括许多重量级操作,例如纹理压缩。由于这通常是一个耗时的过程,导入的资产被缓存在Library文件夹中,从而避免了在下一个Editor启动时再次导入资产的需要。
具体来说,导入过程的结果存储在一个文件夹中,该文件夹名为AssetFileGUID的前两位数。此文件夹存储在库/元数据/文件夹中。来自Asset的单个对象被序列化为一个二进制文件,其名称与Asset的文件GUID相同。
此过程适用于所有Assets,而非非本地Assets。本机Assets不需要冗长的转换过程或重新序列化。
1.5. Serialization and instances
虽然文件GUID和LocalID是健壮的,但是GUID比较慢,运行时需要一个性能更好的系统。Unity在内部维护一个缓存,将文件GUID和LocalID转换为简单的、会话唯一的整数。这些被称为InstanceID,当新对象在缓存中注册时,它们以简单的单调递增的顺序分配。
缓存维护定义对象源数据位置的给定InstanceID、文件GUID和LocalID之间的映射,以及内存中对象实例(如果有的话)之间的映射。这允许UnityEngine.Objects可靠地维护彼此之间的引用。解析InstanceIDs引用可以快速返回由InstanceID表示的加载对象。如果目标对象尚未加载,可以将文件GUID和LocalID解析为对象的源数据,从而使Unity能够及时加载对象。
在启动时,InstanceID缓存将使用项目立即需要的所有对象(即,在构建的场景中引用)以及Resources文件夹中包含的所有对象的数据进行初始化。当在运行时导入新资产和从AssetBundles加载对象时,将向缓存中添加其他条目。只有在卸载提供对特定文件GUID和LocalID的访问的AssetBundle时,才会从缓存中删除InstanceID条目。当发生这种情况时,将删除InstanceID、其文件GUID和LocalID之间的映射,以节省内存。如果AssetBundle被重新加载,将为从重新加载的AssetBundle加载的每个对象创建一个新的InstanceID。
有关卸载AssetBundles的含义的更深入讨论,请参阅“Managing Loaded Assets”文章中的“Assetbundle Usage Patterns”一节。
在特定的平台上,某些事件会迫使对象退出内存。例如,当应用程序挂起时,可以从IOS上的图形内存中卸载图形资产。如果这些对象源于已卸载的资产绑定,那么Unity将无法重新加载对象的源数据。对这些对象的任何现存引用也将无效。在前面的示例中,场景可能有不可见的网格或洋红色textures。
实现注意:在运行时,上述控制流实际上并不准确。在运行时比较文件GUID和LocalID将不能在重载操作中充分发挥作用。在构建一个统一项目时,文件GUID和LocalID被确定地映射为一种更简单的格式。但是,这个概念仍然是相同的,在运行时,用文件GUID和LocalID进行思考仍然是一个有用的类比。这也是不能在运行时查询Asset 文件GUID的原因。
1.6. MonoScripts
重要的是要理解MonoBehaviour对MonoScript的引用,MonoScript仅仅包含定位特定脚本类所需的信息。两种类型的对象都不包含脚本类的可执行代码。
MonoScript包含三个字符串:程序集名称、类名和命名空间。
在构建项目时,Unity将Assets文件夹中的所有松散脚本文件编译为Mono组件。Plugins子文件夹之外的C#脚本被放置在Assembly-CSharp.dll.Scripts中,Plugins子文件夹放置到Assembly-CSharp-firstpass.dll中等等。此外,Unity2017.3还介绍了定义自定义托管程序集的功能。
这些组件以及预构建的组件DLL文件都包含在Unity应用程序的最终构建中。它们也是MonoScript引用的程序集。与其他资源不同,Unity应用程序中包含的所有程序集都在应用程序启动时加载。
此MonoScript对象是AssetBundle(或Scene或prefab)实际上不包含AssetBundle、Scene或prefab中任何MonoBehaviour组件中的可执行代码的原因。这允许不同的MonoBehaviors引用特定的共享类,即使MonoBehaviours位于不同的AssetBunds中。
1.7. Resource lifecycle
为了减少加载时间和管理应用程序的内存占用,了解UnityEngine.Objects的资源生命周期非常重要。对象在特定和定义的时间从内存中加载/卸载。
在以下情况下自动加载对象:
1.映射到该对象的InstanceID被取消引用
2.对象当前没有加载到内存中
3.对象的源数据可以被定位
对象也可以通过创建脚本或调用资源加载API(例如,AssetBundle.LoadAssets)而显式加载到脚本中。加载对象时,Unity尝试通过将每个引用的文件GUID和LocalID转换为InstanceID来解决任何引用。如果两个条件为真,则将首次加载对象-按需加载它的InstanceID:
1.InstanceID引用当前未加载的对象
2.InstanceID具有在缓存中注册的有效文件GUID和LocalID
通常在加载和解析引用本身之后不久就会发生这种情况。
如果文件GUID和LocalID没有InstanceID,或者带有卸载对象的实例ID引用无效的文件GUID和LocalID,则该引用将被保留,但实际对象不会被加载。这在Unity编辑器中显示为“(Missing)”引用。在运行中的应用程序中,或者在场景视图中,“(Missing)”对象将以不同的方式显示,这取决于它们的类型。例如,网格看起来是不可见的,而纹理可能看起来是洋红色。
在三种特定场景中卸载对象:
1.对象在未使用的Asset清理发生时自动卸载。当场景被破坏性地更改时(即当SceneManager.LoadScene被非加性调用时),或者当脚本调用Resources.UnLoadUnusedAssets API时,会自动触发此进程。此过程仅卸载未引用的对象;只有在没有Mono变量保存对象的引用且没有其他活动对象保存对该对象的引用时,才会卸载对象。此外,请注意,标记有HideFlags.DontUnLoadUnusedAsset和HideFlags.HideAndDontSave的任何标记都不会被卸载。
2.可以通过调用Resources.UnloadAsset API显式地卸载来自Resources文件夹的对象。这些对象的InstanceID仍然有效,并且仍然包含一个有效的文件GUID和LocalID条目。如果任何Mono变量或其他对象持有对用Resources.UnloadAsset卸载的对象的引用,则一旦取消任何活动引用,该对象将被重新加载。
3.调用AssetBundle.Unload(True)API时,来自AssetBundles的对象将自动和立即卸载。这将使对象的InstanceID的文件GUID和LocalID无效,任何对卸载对象的活动引用都将成为“(Missing)”引用。从C#脚本中,试图访问卸载对象上的方法或属性将产生NullReferenceException。
如果调用AssetBundle.Unload(False),来自卸载的AssetBundle的活动对象将不会被销毁,但是Unity会使其InstanceID的文件GUID和LocalID引用失效。如果这些活动对象稍后从内存中卸载并保留对已卸载对象的活动引用,那么Unity将不可能重新加载这些对象。
1.8. Loading large hierarchies
当序列化Unity GameObjects的hierarchy时,例如在prefabs序列化期间,重要的是要记住整个hierarchy将完全序列化。也就是说,hierarchy中的每个GameObject和组件都将在序列化数据中单独表示。这对加载和实例化GameObjects层次结构所需的时间产生了有趣的影响。
在创建任何GameObject层次结构时,CPU时间以几种不同的方式度过:
1.读取源数据(从存储、AssetBundle、从另一个GameObject等)
2.在new Transforms之间设置父-子关系,
3.实例化新的游戏对象和组件,
4.唤醒主线程上的新游戏对象和组件。
后三个时间成本通常是不变的,无论hierarchy是从现有hierarchy中克隆还是从存储中加载。但是,读取源数据的时间与序列化到层次结构中的组件和GameObjects的数量成线性关系,并且还与数据源的速度相乘。
在所有当前平台上,从内存中的其他地方读取数据要比从存储设备加载数据要快得多。此外,不同平台间可用存储介质的性能特性差异很大。因此,在存储缓慢的平台上加载预制件时,从存储中读取预制件的序列化数据所花费的时间可以迅速超过实例化预制件所花费的时间。也就是说,加载操作的成本必然要存储I/O时间。
如前所述,在序列化单个预制件时,每个GameObject和组件的数据分别序列化,这可能重复数据。例如,具有30个相同元素的UI屏幕将相同的元素序列化30次,从而产生大量二进制数据。在加载时,必须从磁盘读取这30个重复元素中每个元素上的所有GameObjects和组件的数据,然后才能传输到新实例化的对象。这个文件读取时间对实例化大型预制板的总体成本有很大的贡献。大型hierarchies应该以模块实例化,然后在运行时拼接在一起。
注意:Unity5.4改变了内存中变换的表示。每个root transforms的整个子hierarchy都存储在紧凑的、连续的内存区域中。在实例化将立即重新构建到另一个hierarchy的新游戏对象时,请考虑使用接受父参数的新GameObject.Instantiate重载变量。使用此重载可以避免为新的GameObject分配root transform hierarchy。在测试中,这会使实例化操作所需的时间增加大约5-10%。
(官方译文)未完待续......