Unity最佳实践-资产,对象和序列化

Best Practices(2) - Assets, Objects and serialization

  • 适用版本:2017.3
  • 原文地址:https://unity3d.com/cn/learn/tutorials/topics/best-practices/assets-objects-and-serialization?playlist=30089

本章将深入Unity的序列化系统,以及Unity如何在编辑器下和运行时,不同对象之间维护健壮的引用,并且还讨论了对象(Objects)和资产(Assets)之间的原理差别,本章的注意还涵盖了如何从有效的加载和卸载Unity中的资产,正确的资产管理对于缩短加载时间和降低内存使用量至关重要。

目录

  • Best Practices(2) - Assets, Objects and serialization
      • 目录
    • 1.1 内部资产和对象
    • 1.2 内部对象引用
    • 1.3 为什么使用GUID和本地ID
    • 1.4 综合资产和导入器
    • 1.5 序列化和实例化
    • 1.6 Mono脚本
    • 1.7 资源的生命周期
    • 1.8 加载较大的层次结构

1.1 内部资产和对象

要理解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类型都是内置的,但是有两类特殊类型

  • ScriptableObject为开发者提供了一个很方便的系统,可以让开发者定义他们自己的数据类型。这些类型可以通过Unity进行本地序列化和反序列化,并在Unity编辑器的Inspector面板中进行操作。
  • MonoBehavior提供了一个链接到MonoScript的封装器。MonoScript是Unity用来在特定程序集和命名空间内,保存对特定脚本类的引用的内部数据类型。MonoScript不包含任何实际的可执行代码。

资产和对象之间有一对多的关系,也就是说,任何给定的资产文件都包含了一个或者多个对象。

1.2 内部对象引用

所有的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”的组合。

1.3 为什么使用GUID和本地ID

为什么Unity需要GUID和本地ID?,答案是为了健壮性,并提供一个灵活,并且与平台无关的工作流程。

文件的GUID提供文件特定位置的抽象表达,只要有与特定文件关联的GUID,那么这个文件在磁盘上的位置就无关紧要了,该文件可以自由移动,而无需更新引用该文件的对象。

由于任何给定的资产文件可能包含(或者通过导入生成的)多个UnityEngine.Object资源,因此,需要本地ID来明确区分每个不同的对象。

如果与资产文件关联的文件GUID丢失,那么对该资产文件中的所有对象的引用也将丢失,这就是为什么.meta文件必须和对应的资产文件保持相同的文件名,并且保存在相同的文件夹中,请注意,Unity会重新生成已删除或者错位的.meta文件。

Unity编辑器具有到已知GUID的文件的映射,无论何时加载或导入资产,都会记录一个映射关系,映射关系将资产的路径链接到资产的GUID,如果Unity编辑器在打开状态下,meta文件丢失,但是资源路径没有更改,编辑器可以确保保留相同的GUID。

如果在Untiy编辑器关闭时丢失了meta文件,或者资产的路径发生变化,而meta文件没有和资产一起移动,那么所有对该资产中对象的引用都会丢失。

1.4 综合资产和导入器

正如上文提到的,非原生资产类型必须导入Unity才能使用,这会通过一个资产导入器完成,虽然这些导入器会在资产导入的时候自动调用,不过这些API也通过AssetImporter把一些细节暴露了出来,例如,TextureImporter可以让你去更改相应的纹理资源,当然,这些导入的资源必须是有效的,就像PNG。

导入的结果是产生一个或者多个UnityEngine.Objects,这些在Unity编辑器中为父资产中的多个自资产,例如嵌套在已作为精灵图集导入的纹理资产下的多个精灵,每一个对象都共享一个文件GUID,因为它们的源数据储存在相同的资产文件中,它们将通过本地ID在导入的纹理资源中区分。

导入的过程,会把资源转换为适合在Unity中选择的平台的格式,导入过程可能会包括许多耗时的操作,例如纹理压缩,因为这个过程很长,所以导入的资源会缓存在项目根目录下的Library文件夹下,无需在下次启动时,重新导入。

具体来说,导入的结果会存储在一个以文件GUID前两位命名的文件夹中,该文件夹储存在Library/metadata/folder中,资产中的单个对象会被序列化为一个与资产的文件GUID名称相同的二进制文件。

此流程适用于所有资产,而不仅仅是非原生资产,原生资产不需要冗长的转换过程,或者重新序列化。

1.5 序列化和实例化

虽然文件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在运行时无法查询的原因。

1.6 Mono脚本

理解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中。

1.7 资源的生命周期

为了减少加载时间和应用的内存占用空间,了解UnityEngine.Objects的资源生命周期非常重要,对象在特定和自定义的时间从内存中加载或卸载。

以下情况会自动加载对象:

  • 映射到该对象的Instance ID被引用
  • 该对象目前还没有加载到内存中
  • 对象的源数据可以被定位

也可以通过创建对象或通过资源加载API(如AssetBundle.LoadAsset)将对象现式加载到脚本中,加载对象时,Unity会尝试通过将每个引用的文件GUID和本地ID转换为Instance ID来解析任何引用。如果两个条件为真,则会在Instance ID第一次被引用时按需加载对象:

  • Instance ID引用当前未加载的对象
  • Instance ID具有在缓存中注册的有效的文件GUID和本地ID

这个情况通常发生在引用本身被加载或者解析之后的很短时间内。

如果文件GUID和本地ID没有Instance ID,或者如果一个Instance ID引用了一个无效的文件GUID和本地ID,例如引用一个已卸载的对象,那么引用被保留,但实际的对象不会被加载,这在Unity编辑器中显示为Missing,在运行的时候,或在场景视图中,Missing对象将以不同的方式可见,例如,网格丢失,会不可见,纹理丢失会显示红色等。

对象在三种特定的情况中卸载:

  • 当未使用的资产开始清理时,对象将自动卸载,当场景被强制切换(即SceneManager.LoadScene被非增加式的调用时),或者脚本调用Resources.UnloadUnusedAssets时,这个过程会被自动出发,此功能只卸载没有被Mono变量引用和其他对象引用的对象,不过请注意,被标为HideFlags.DontUnloadUnusedAsset和HideFlags.HideAndDontSave的对象不会被卸载
  • 可以用过调用Resources.UnloadAsset显式卸载Resources文件夹中的资产,被卸载的对象的Instance ID仍然是有效的,并且依然包含有效的文件GUID和本地ID,如果任何变量或其他对象持有使用Resources.UnloadAsset卸载过的对象,如果这些引用被引用,对象会被重新加载
  • 来自于AssetBundle的对象,可以用过调用AssetBundle.Unload(true),立即自动卸载,这会使对象Instance ID和文件GUID和本地ID变为无效,如果一个对象引用了它,那么这个对象会变为Missing状态。C#中如果有访问未加载的对象的方法或属性,会产生空指针异常。

如果调用了AssetBundle.Unload(false),那么来自这个AssetBundle的实例对象不会被销毁,但Unity会使其Instance ID的文件GUID和本地ID引用无效。如果这些对象从内存中卸载,但是有其他对象对它的引用,那么Unity是无法重新加载这些对象的(最常见的情况是,在运行时将对象从内存中移除而未被卸载时,Unity会失去对其图形上下文的控制权。 当移动应用程序被暂停并且该应用程序被强制置于后台时,可能会发生这种情况。 在这种情况下,移动操作系统通常会从GPU内存中清除所有图形资源。 当应用程序返回到前景时,Unity必须在场景渲染恢复之前将所有需要的纹理,着色器和网格重新加载到GPU)。

1.8 加载较大的层次结构

在序列化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%。

你可能感兴趣的:(最佳实践,Unity,Unity,最佳实践)