资产,对象和序列化
版本检查:2017.3 - 难度:高级
这是关于Unity 5中的资产,资源和资源管理的系列文章的第二章。
本章将介绍Unity序列化系统的深层内容,以及Unity如何在Unity编辑器和运行时维护不同对象之间的强引用。它还讨论了对象和资产之间的技术区别。这里涵盖的主题是理解如何有效地在Unity中装载和卸载资产的基础。适当的资产管理对于保持加载时间短和使用内存使用率底至关重要。
1.1. 内部资产和对象
要了解如何在Unity中正确管理数据,了解Unity如何识别和序列化数据是非常重要。第一个关键点是Assets和UnityEngine.Objects之间的区别。
资产是磁盘上的文件,存储在资产为Unity项目的文件夹中。纹理,3D模型或音频剪辑是资产的常见类型。某些资产包含Unity原生格式的数据,如materials。其他资产需要处理为原生格式,如FBX文件。
UnityEngine.Object,或对象以大写“O”,是一组序列化的数据统称为描述资源的特定实例的。这可以是Unity Engine使用的任何类型的资源,例如网格,精灵,AudioClip或AnimationClip。所有对象都是UnityEngine.Object基类的子类。
虽然大多数对象类型都是内置的,但有两种特殊类型。
1.ScriptableObject提供了方便于系统开发人员可以定义自己的数据类型。这些类型可以通过Unity进行本地序列化和反序列化,并在Unity编辑器的检查器窗口中进行操作。
2.MonoBehaviour提供链接到脚本的包装器。MonoScript是Unity用来在特定汇编和名称空间内保存对特定脚本类的引用的内部数据类型。该MonoScript并没有包含任何实际的可执行代码。
资产和对象之间有一对多的关系; 也就是说,任何给定的资产文件都包含一个或多个对象。
1.2.对象间引用
所有UnityEngine.Objects都可以引用其他UnityEngine.Objects。这些其他对象可能位于同一个资产文件中,或者可能从其他资产文件导入。例如,材质对象通常具有一个或多个对纹理对象的引用。这些纹理对象通常从一个或多个纹理资产文件(如PNG或JPG)导入。
在序列化时,这些引用由两个独立的数据组成:文件GUID和本地ID。文件GUID标识存储目标资源的资产文件。一个本地唯一[^1]本地ID标识资产文件中的每个对象,因为资产文件可能包含多个对象。
文件GUID存储在.meta文件中。这些.meta文件在Unity首次导入资产时生成,并与资产存储在同一个目录中。
上述标识和参考系统可以在文本编辑器中看到:创建一个新的Unity项目并更改其编辑器设置以显示可见元文件并将资源序列化为文本。创建一个材质并将纹理导入到该项目中。将材质分配给场景中的立方体并保存场景。
使用文本编辑器打开与材质关联的.meta文件。标有“guid”的行将出现在文件顶部附近。这一行定义了材料资产的文件GUID。要查找本地ID,请在文本编辑器中打开该材料文件。材质对象的定义如下所示:
在上面的例子中,以&符号开头的数字是材料的本地ID。如果此物料对象位于由文件GUID“abcdefg”标识的资产内,则物料对象可以唯一标识为文件GUID“abcdefg”和本地ID“2100000”的组合。
1.3.为什么要文件GUID和本地ID?
为什么Unity的文件GUID和Local ID系统是必需的?答案是健壮性,并提供一个灵活的,独立于平台的工作流程。
文件GUID提供一个文件特定位置的抽象。只要特定的文件GUID可以与特定文件相关联,那么该文件在磁盘上的位置就变得无关紧要。该文件可以自由移动,而无需更新所有指向该文件的对象。
由于任何给定的资产文件可能包含(或通过导入生成)多个UnityEngine.Object资源,因此需要本地ID来明确区分每个不同的对象。
如果与资产文件关联的文件GUID丢失,那么对该资产文件中所有对象的引用也将丢失。这就是为什么重要的是.meta文件必须与相关的资产文件存储在相同的文件名和相同的文件夹中。请注意,Unity会重新生成已删除或错位的.meta文件。
Unity编辑器具有到已知文件GUID的特定文件路径的映射。无论何时加载或导入资产都会记录地图条目。映射条目将资产的特定路径链接到资产的文件GUID。如果Unity文件编辑器在.meta文件丢失并且资源路径没有更改时打开,编辑器可以确保资源保留相同的文件GUID。
如果在Unity编辑器关闭时丢失了.meta文件,或者资产的路径发生变化而没有随.meta文件一起移动资产,那么所有对该资产内对象的引用都将被打破。
1.4. 复合资产和导入
如“ 内部资产和对象”部分所述,必须将非本地资产类型导入Unity。这是通过资产导入完成的。虽然这些导入程序通常会自动调用,但它们也通过AssetImporter API 暴露给脚本。例如,TextureImporter API可以访问导入个别纹理资产(例如PNG文件)时使用的设置。
导入过程的结果是一个或多个UnityEngine.Objects。这些在Unity编辑器中可见为父资产中的多个子资产,例如嵌套在已作为精灵图集导入的纹理资产下的多个精灵。这些对象中的每一个将共享一个文件GUID,因为它们的源数据存储在相同的资产文件中。它们将通过本地ID在导入的纹理资源中进行区分。
导入过程将源资产转换为适合在Unity编辑器中选择的目标平台的格式。导入过程可以包括许多重量级操作,例如纹理压缩。由于这通常是一个耗时的过程,因此导入的资源会缓存在库文件夹中,无需在下次编辑器启动时再次重新导入资源。
具体来说,导入过程的结果存储在一个名为文件GUID的前两位的文件夹中。该文件夹存储在Library/metadata/ 文件夹内。资产中的单个对象被序列化为一个与资产的文件GUID名称相同的二进制文件。
此流程适用于所有资产,而不仅仅是非本地资产。本地资产不需要冗长的转换过程或重新序列化。
1.5. 序列化和实例
虽然文件GUID和本地ID是健壮的,但GUID比较速度较慢,运行时需要更高性能的系统。 Unity内部维护一个缓存[^2],将文件GUID和本地ID转换为简单的会话唯一的整数。这些被称为实例ID,并且在新对象向缓存注册时以简单,单调递增的顺序进行分配。
缓存维护给定实例ID,文件GUID和定义对象源数据位置的本地ID以及内存中对象的实例(如果有)之间的映射。这允许UnityEngine.Objects之间保持一致的引用。解析实例ID引用可以快速返回由实例ID表示的加载对象。如果目标对象尚未加载,则可以将文件GUID和本地ID解析为对象的源数据,从而使Unity能够及时加载对象。
在启动时,实例ID缓存初始化为项目立即需要的所有对象(即在构建的场景中引用)的数据以及Resources文件夹中包含的所有对象。 在运行时导入新资产时[^3]以及从AssetBundles加载对象时,其他条目将添加到缓存中。当提供对特定文件GUID和本地ID的访问的AssetBundle被卸载时,实例ID条目仅从缓存中移除。发生这种情况时,将删除实例ID,其文件GUID和本地ID之间的映射以节省内存。如果重新加载AssetBundle,则将为从重新加载的AssetBundle加载的每个对象创建一个新的实例ID。
有关卸载AssetBundles的含义的更深入讨论,请参阅AssetBundle使用模式文章中的管理已加载资产部分。
在特定的平台上,某些事件可能会强制对象内存不足。例如,当应用程序被暂停时,图形资产可以从iOS的图形内存中卸载。如果这些对象来自已被卸载的AssetBundle,则Unity将无法重新加载对象的源数据。对这些对象的任何现存引用也是无效的。在前面的示例中,场景可能看起来有不可见的网格物体或洋红色的纹理。
实现注意事项: 在运行时,上述控制流程不是完全的准确。在加载操作期间,在运行时比较文件GUID和本地ID的性能不足。在构建Unity项目时,文件GUID和本地ID被确定性地映射为更简单的格式。然而,这个概念仍然是相同的,并且在运行时,用文件GUID和本地ID方面的思考仍然是一个有用的类比。这也是为什么在运行时无法查询资产文件二的GUIDs的原因。
1.6. MonoScripts
理解MonoBehaviour引用MonoScript是很重要的,MonoScripts仅包含定位特定脚本类所需的信息。这两种Object都不包含脚本类的可执行代码。
MonoScript包含三个字符串:程序集名称,类名称和名称空间。
在构建项目时,Unity会将Assets文件夹中的所有松散脚本文件编译为Mono程序集。插件子文件夹外的C#脚本被放置到Assembly-CSharp.dll中。Plugins子文件夹中的脚本放置在Assembly-CSharp-firstpass.dll中,依此类推。此外,Unity 2017.3还引入了定义自定义托管程序集的功能。
这些程序集以及预构建的程序集DLL文件都包含在Unity应用程序的最终版本中。它们也是MonoScript引用的程序集。与其他资源不同,包含在Unity应用程序中的所有程序集都在应用程序启动时加载。
这个MonoScript对象是AssetBundle(或场景或预制件)实际上不包含AssetBundle,Scene或Prefab中的任何MonoBehaviour组件中的可执行代码的原因。这允许不同的MonoBehaviours引用特定的共享类,即使MonoBehaviours位于不同的AssetBundle中。
1.7. 资源生命周期
为了减少加载时间和管理应用程序的内存占用空间,了解UnityEngine.Objects的资源生命周期非常重要。对象在特定和定义的时间从内存中加载/卸载。
在以下情况下会自动加载对象:
1.映射到该对象的实例ID已取消引用
2.该对象目前没有加载到内存中
3.对象的源数据可以被定位。
对象也可以在脚本中显式地加载,要么通过创建对象,要么通过调用资源加载API(例如AssetBundle.LoadAsset)。当加载对象时,Unity会尝试通过将每个引用的文件GUID和本地ID转换为实例ID来解析任何引用。如果两个条件为真,则会在实例ID第一次被取消引用时按需加载对象:
1.实例ID引用当前未加载的对象
2.实例ID具有在缓存中注册的有效文件GUID和本地ID
这通常在引用本身加载并解决后很快发生。
如果文件GUID和本地ID没有实例ID,或者带有未加载对象的实例ID引用了无效的文件GUID和本地ID,则将保留该引用,但实际的对象不会被加载。这在Unity编辑器中显示为“(Missing)”引用。在正在运行的应用程序中或在场景视图中,“(缺失)”对象将以不同的方式显示,具体取决于它们的类型。例如,网格看起来是不可见的,而纹理可能看起来是品红色的。
对象在三种特定情况下卸载:
- 对象在未使用资产清理时自动卸载。当场景以破坏性方式更改(即SceneManager.LoadScene非相加调用时)或脚本调用Resources.UnloadUnusedAssets API 时,会自动触发此过程。该过程仅卸载未引用的对象; 如果没有Mono变量持有对该对象的引用,并且没有其他活动对象持有对该对象的引用,则只会卸载对象。此外,请注意,标有HideFlags.DontUnloadUnusedAsset和HideFlags.HideAndDontSave的内容不会被卸载。
- 可以通过调用Resources.UnloadAsset API 显式地卸载源文件夹中的对象。这些对象的实例ID保持有效,并且仍将包含有效的文件GUID和LocalID条目。如果任何Mono变量或其他Object持有对使用Resources.UnloadAsset卸载的Object的引用,那么只要解除引用任何活动引用,就会重新加载该Object。
- 当调用AssetBundle.Unload(true)API 时,从AssetBundle获取的对象会自动并立即卸载。这将使对象的实例ID的文件GUID和本地ID无效,并且对卸载的对象的任何实时引用都将变成“(丢失)”引用。从C#脚本尝试访问未加载对象上的方法或属性将产生NullReferenceException。
如果调用了AssetBundle.Unload(false),则来自未加载的AssetBundle的实时对象不会被销毁,但Unity会使其实例ID的文件GUID和本地ID引用无效。如果这些对象稍后从内存中卸载,并对未加载的对象进行实时引用,那么Unity就不可能重新加载这些对象。。[^4]
1.8.加载较大的层次结构
在序列化Unity GameObjects的层次结构时,例如在预制序列化期间,请务必记住整个层次结构将完全序列化。也就是说,层次结构中的每个GameObject和Component将分别在序列化数据中表示。这对加载和实例化GameObject的层次结构所需的时间有着有趣的影响。
创建任何GameObject层次结构时,CPU时间花费在几种不同的方式上:
- 读取源数据(来自存储,来自AssetBundle,来自另一个GameObject等)
- 设置新变换之间的父子关系
- 实例化新的GameObjects和组件
- 在主线程中唤醒新的GameObjects和组件
后三种时间成本通常是不变的,无论层次结构是从现有分层结构克隆还是从存储结构加载。但是,读取源数据的时间随着组件和GameObjects序列化到层次结构中的数量的增加而线性增加,并且还乘以数据源的速度。
在目前所有的平台上,从内存中的其他地方读取数据比从存储设备载入数据要快得多。此外,在不同平台之间可用存储介质的性能特征也有差异很大。因此,在存储缓慢的平台上加载预制件时,从存储中读取预制件序列化数据的时间可能会迅速超过实例化预制件的时间。也就是说,加载操作的成本绑定到存储I/O时间。
如前所述,当序列化一个整体预制件时,每个GameObject和组件的数据都会被单独序列化,这可能会复制数据。例如,具有30个相同元素的UI屏幕将具有相同元素序列化30次,产生大量的二进制数据。在加载时,这些30个重复元素中的每一个元素上的所有GameObjects和组件数据必须在传输到新实例化的对象之前从磁盘读取。此文件读取时间是实例化大型预制件的总体成本的重要因素。大型层次结构应该在模块化块中实例化,然后在运行时拼接在一起。
Unity 5.4注意: Unity 5.4改变了内存中变换的表示。每个根变换的整个子层次结构都存储在紧凑的、连续的内存区域中。当实例化新的GameObjects时,这些新的GameObjects会立即重新排列到另一个层次结构中,请考虑使用接受父参数的新GameObject.Instantiate重载变体。使用此重载可以避免为新的GameObject根变换层次结构的分配。在测试中,这加快了实例化操作所需的时间约5-10%。