Unity资源管理-资源(Assets)、对象(Objects)和序列化

发表于2018-05-30
评论7 1.28w浏览

Unity资源管理-资源(Assets)、对象(Objects)和序列化

参考文献:https://unity3d.com/cn/learn/tutorials/topics/best-practices/assets-objects-and-serialization

1.1 Asset和Object

理解怎样在Unity中合适的管理数据的重点是理解Unity如何标识和序列化数据。第一个关键点是Asset和Object之间的区别。为了避免与资源(resources)和对象(object)混淆,下文中将不对Asset和Object进行翻译。

Asset 是存储在Unity项目的 Assets 文件夹中的磁盘文件。纹理(texture)、3D模型以及音频都属于常见的Asset。一些Asset中含有Unity原生格式的数据,例如材质(Material);其他Asset则需要被转换成原生格式,例如FBX文件。

Object(UnityEngine.Object) 是用于描述某个资源(resource)的特定实例的序列化数据集合。它可以是由Unity引擎所使用的任何类型的资源,例如网格(Mesh)、精灵(Sprite)、音频和动画等。所有的Object都是UnityEngine.Object类的子类。

大多数的Object都是Unity内置类型,但有两种特殊的类型:

  • ScriptableObject 为开发者提供了一个便捷的自定义数据类型的系统。这些数据类型可以被Unity原生的序列化和反序列化,并且可以在Unity编辑器的Inspector窗口中进行操作。
  • MonoBehaviour 提供了对MonoScript的包装。MonoScript是Unity内部的数据类型,Unity通过它来持有对特定的程序集、命名空间下的脚本的引用。MonoScript中不包含任何实际的可执行代码。

Asset和Object之间的关系是一对多关系,任何Asset文件中都含有一个或多个Object。

1.2 Object间的引用

所有的Object都可以持有对其他Object的引用,被引用的Object可能在同一个Asset文件中,也可能在导入的其他Asset文件中。例如,一个材质Object通常含有对一个或多个纹理Object的引用,这些纹理Object通常从一个或多个Asset文件中导入(例如PNG或JPG)。

在序列化后,这些引用由两部分独立的数据组成:File GUID 和 Local ID。File GUID标识Asset文件的目标资源的存储位置;Local ID标识Asset中的每个Object,Local ID在它所属的Asset中是唯一(Unique)的。

File GUID存储在 .meta 文件中。在首次将Asset导入Unity时会生成meta文件,它与Asset存储在同一个目录中。

上述的标识和引用系统可以通过文本编辑器查看。

1.3 为什么要使用File GUID和Local ID

File GUID和Local ID的意义在于保证系统的稳健性和提供灵活、平台无关的工作流程。

File GUID提供了对文件的具体位置的抽象。只要某个File GUID可以关联到某个文件,那么这个文件在磁盘上的位置就不再重要,它可以被所以的移动而不必更新所有引用这个文件的Object。

因为任何Asset文件都可能包含(或者通过导入而产生)多个Object资源,因此需要Local ID来明确区分每个不同的Object。

如果于某个Asset文件相关联的File GUID丢失了,那么这个Asset文件中所有对Object的引用也会丢失。因此meta文件必须与相关联的Asset文件同名,并且放在相同文件夹中。Unity会重新生成被删除或者位置错误的meta文件。

Unity编辑器中含有具体文件路径和已知的File GUID的映射表(map)。当Asset被加载或导入时,会记录一条映射关系(Map Entry),它将Asset的具体路径与Asset的File GUID链接起来(Link)。如果在Unity编辑器处于打开状态时,某个meta文件丢失了,而且Asset的路径没有改变,那么编辑器可以确保Asset持有相同的File GUID;如果在Unity编辑器处于关闭状态时,meta文件丢失或者移动了Asset文件却没有同时移动meta文件,则这个Asset中的所有对象引用都会被破坏。

1.4 复合Asset和导入器

在1.1节中提到过,非原生的Asset类型必须通过导入才能在Unity中使用,这一操作由导入器完成。导入器通常会自动被调用,不过Unity也提供了在脚本中访问导入器的API接口AssetImporter。例如,TextureImporter API提供了对单独的纹理Asset的导入设置的访问接口。

导入过程会生成一个或多个Object,它们以“一个父Asset带有多个子Asset”的形式显示在Unity编辑器中,例如,以纹理集(Sprite Atlas)形式导入的纹理Asset中含有多个嵌套的精灵。这些Object共享同一个File GUID,因为它们的源数据存储于同一个Asset文件中,他们之间通过Local ID来区分。

导入过程会将源Asset转换成适合目标平台的格式。导入过程可以包含一些耗时的重量级操作,例如纹理压缩(Texture Compression)。导入的Asset被缓存在 Library 文件夹中,这样在下次启动编辑器时可以免除重新导入。

导入过程的结果存储在以Asset的File GUID的前两个字符命名的文件夹中,这些文件夹位于 Library/metadata/ 文件夹下。来自Asset的每个Object会被序列化到与Asset的File GUID同名的二进制文件中。

这一过程会应用到所有的Asset中,而不只是非原生的Asset。原生Asset不需要漫长的转换处理和重新序列化。

1.5 序列化与实例

尽管File GUID和Local ID足够稳健,但GUID对比速度慢,而且在运行时也需要一个更高效的系统。Unity在内部维护了一份缓存(PersistentManager),它将File GUID和Local ID转换成简单的、会话唯一(Session-unique)的整数。这些整数被称为 Instance ID,当由新的Object注册到缓存时,Instance ID自增。

缓存中维护了Instance ID、由File GUID和Local ID定义的Object源数据位置以及Object在内存中的实例(如果有)之间的映射关系。这使Object之间可以维持稳健的引用关系。解析Instance ID引用能够快速的返回已加载的Object的Instance ID。如果Object还没有被加载,Unity会通过File GUID和Local ID定位到Object的源数据,然后进行即时(Just-in-time)加载。

在启动时,游戏会立即初始化包含所有被项目需要的Object的数据的Instance ID缓存(例如,已构建(build)的Scene中引用的Object),以及所有在 Resources 文件夹中的Object的数据。在运行时导入新的Asset或者从AssetBundle中加载Object时,会向缓存中新增条目。只有在提供具体File GUID和Local ID的AssetBundle被卸载时,才会从换从中移除Instance ID条目。这时原来的Instance ID、File GUID和Local ID会被删除,如果再次加载了这个AssetBundle,则会重新生成新的Instance ID。

关于下载AssetBundle所带来的影响的更深层的讨论,请查看文章AssetBundle使用模式中的管理已加载的资源章节。

在一些特殊的平台上,某些事件会迫使Object被从内存中清除。例如,在iOS平台上,当程序被挂起(Suspend)时,图形Asset会被从显存中卸载。如果来自AssetBundle的Object被卸载了,Unity没办法重新加载这个Object的数据,现有的所有对该Object的引用都将失效。在前面的案例中,游戏画面中可能出现不可见的网格或者品红色的纹理。

说明:在运行时,上述的控制流程并不是十分准确。在进行重量级加载操作时去比较File GUID和Local ID的性能并不是足够高效。当构建Unity项目时,File GUID和Local ID会被确切地映射到更简单的格式。然而,其中的概念仍然是相同的,而且File GUID和Local ID的思想仍然是有用的类比。这也是Asset的File GUID不能在运行时查询的原因。

1.6 MonoScript

MonoBehaviour中含有一个对MonoScript的引用,而MonoScript简单地包含了用于定位具体的脚本类的信息。[?1]

[?1]:原文:It is important to understand that a MonoBehaviour has a reference to a MonoScript, and MonoScripts simply contain the information needed to locate a specific script class. Neither type of Object contains the executable code of script class.

MonoScript中含有3个字符串:程序集名称、类名和命名空间。

在构建项目时,Unity将 Assets 文件夹中的所有脚本文件编译到Mono程序集中。不在 Plugins 文件夹中的C#脚本会被放到 Assembly-CSharp.dll 中,在 Plugins 文件夹中的脚本会被放到 Assembly-CSharp-firstpass.dll 中。另外,Unity 2017.3还提供了自定义托管程序集

这些程序集以及预构建的程序集DLL文件,都被包含在最终构建的Unity应用程序中。它们也是MonoScript所引用的程序集。不像是其他的资源,Unity应用程序中的所有程序集都会在启动时全部加载。

这个MonoScript Object是AssetBundle(或者Scene、预制体)中的任何MonoBehaviour组件都不能包含实际的可执行代码的原因。这使不同的MonoBehaviour可以引用具体的共享类,即使这些MonoBehaviour在不同的AssetBundle中。[?2]

[?2]:原文:This MonoScript Object is the reason why an AssetBundle (or a Scene or a prefab) does not actually contain executable code in any of the MonoBehaviour Components in the AssetBundle, Scene or prefab. This allows different MonoBehaviours to refer to specific shared classes, even if the MonoBehaviours are in different AssetBundles.

1.7 资源生命周期

为了减少加载时间和管理应用程序内存占用,有必要去了解Object的资源生命周期。Object会在明确具体的时刻被加载到内存或者从内存卸载。

Object会在下列时刻被自动加载:

  1. 映射到该Object的Instance ID被反向引用(Dereference)
  2. Object当前没有被加载到内存中
  3. Object的源数据可以被定位

也可以在脚本中通过创建或者调用资源加载API(例如AssetBundle.LoadAsset)显式地加载Object。当Object被加载后,Unity会通过把每个引用的File GUID和Local ID转换到Instance ID来查找引用目标。如果满足了下面的两个条件,在Object的Instance ID首次被反向引用时它会被请求式(On-demand)的加载:

  1. Instance ID引用了一个当前没有被加载的Object
  2. Instance ID在缓存中含有合法的File GUID和Local ID

这通常在引用本身被加载和解析后很快就得到执行。

如果一个File GUID和Local ID没有对应的Instence ID,或者一个已卸载的Object的Instance ID引用了非法的File GUID和Local ID,那么这个引用会被保存下来但是不会加载任何实际的Object,这会在Unity编辑器中显示“(Missing)”引用。在正在运行的应用程序或者Scene视图中,“(Missing)”的Object会以不同的形式显示,具体取决于它们的类型。例如,网格会变得不可见,而纹理会变成品红色。

Object会在下列3中情况下被卸载:

  1. 在无用的Asset被清理时会自动卸载Object。该过程在Scene被破坏性地改变时自动发生(例如,通过SceneManager.LoadScene非增量地加载Scene),或者在脚本调用Resources.UnloadUnusedAssets时被触发。这一过程仅卸载那些没有被引用地Object —— 一个Object只会在没有任何Mono变量或其他的活动Object持有对它的引用的时候才能被卸载。另外,所有被标记为HideFlags.DontUnloadUnusedAssetHideFlags.HideAndDontSave的对象都不会被卸载。
  2. 通过调用Resources.UnloadAsset精确地卸载Resources文件夹中的Object。这些Object的Instance ID仍然是有效的,并且含有有效的File GUID和Local ID条目。如果任何Mono变量或者Object持有对这类被卸载的Object的引用,那么在任意引用被反向引用时,这个被卸载的Object都会被立刻重新加载。
  3. 来自AssetBundle的Object会在调用AssetBundle.Unload(true)时立即被自动卸载。这会使Object的Instance ID的File GUID和Local ID失效,并且所有对已卸载的Object的活动引用都会变为“(Missing)”引用。在C#脚本中,尝试访问已卸载Object的方法或属性将会引发 NullReferenceException

如果调用了AssetBundle.Unload(false),来自已卸载的AssetBundle的活动Object不会被销毁,但是Unity会使它们的Instance ID所引用的的File GUID和Local ID失效。如果Object被从内存中卸载而系统中仍然有对这些Object的活动引用,Unity没办法再重新加载这些Object。[?3]

[?3]:原文:It will be impossible for Unity to reload these Objects if they are later unloaded from memory and live references to the unloaded Objects remain.

Object在运行时被从内存中移除但却没有被卸载这种情况最常发生在Unity失去对图形上下文的控制的时候。例如,移动平台上的应用被挂起并且强制退入后台,这时,移动操作系统通常会清除显存中的所有图形化资源。当应用返回前台时,Unity必须在Scene恢复渲染前把全部所需的纹理、着色器和网格重新加载到GPU。

1.8 加载大型层级结构(Hierarchy)

当序列化Unity GameObject的层级结构时,例如序列化预制体,整个层级结构都会被完全序列化。也就是说,这个层级结构中的每个GameObject和Component都会被单独地序列化到数据中。这会对GameObject层级结构的加载和实例化所需的时间产生微妙的影响。

当创建GameObject层级结构时,会有几种不同的耗费CPU时间的形式:

  • 读取源数据(从存储设备、AssetBundle、其他GameObject等)
  • 在新的Transform之间设置父子关系
  • 实例化新的GameObject和Component
  • 在主线程中唤醒新的GameObject和Component

后三个时间消耗通常是不变的,无论层级结构是从已有的层级结构克隆的还是从存储设备中加载的。然而,读取源数据消耗的时间会随着序列化的层级结构中的GameObject和Component的数量线性增长,而且受到读取速度的影响。

在现有的所有平台上,从内存中读取数据都比从存储设备中读取数据快很多。另外,在不同平台上的不同存储媒介上性能特征差异很大。因此,在低速存储设备上加载预制体时,读取预制体的序列化数据消耗的时间很容易超过实例化预制体所花费的时间。也就是说,加载操作的开销受到了存储设备I/O时间的限制。

前面提到过,在序列化整个预制体时,其中的每个GameObject和Component的数据都会被单独地序列化,这里面可能含有重复的数据。例如,一个UI屏幕上由30个相同的元素,这些元素就会被序列化30次,产生一大团二进制数据。在加载时,这30个相同的元素上的每个GameObject和Component的数据都要全部从磁盘读取出来,然后才能转换成新的Object实例。实例化预制体的整体开销中,文件读取时间占了占了很大比重。对于大型的层级结构,应该将其分模块进行实例化,然后再在运行时将他们整合到一起。

Unity 5.4注释:Unity 5.4改变了Transform在内存中的存储形式。每个根Transform的全部子层级都被存储在紧凑连续的内存区块中。当实例化新的需要立即被放置到其他层级下的GameObject时,考虑使用GameObject.Instantiate方法的含有父节点参数的重载。使用此重载可以避免为新的GameObject的父层级分配内存空间,在测试中,这可以将实例化操作的时间花费减少5-10%。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引