本系列文章翻译自: https://learn.unity.com/tutorial/assets-resources-and-assetbundles

这是一系列文章,深入讨论了 Unity引擎中的资产(Asset)和资源管理(Resource Management)。旨在为高级开发人员提供 Unity 资产和序列化系统的深入原理的知识。研究了 Unity 的 AssetBundle 系统的技术基础和当前使用它们的最佳使用方法。

该指南分为四章:

  1. 资产,对象和序列化(Assets,Objects and serialization)讨论了Unity如何序列化资产和处理资产之间引用的底层细节。强烈建议读者从本章开始,因为它介绍了整个指南中使用的术语。
  2. Resources 文件夹 讨论了内置的 Resources API。
  3. AssetBundle 基础知识 是建立在第1章的知识基础上,用于描述 AssetBundles 的运作方式,并讨论 AssetBundles 的加载和 AssetBundles 的资产加载。
  4. AssetBundle 使用指南 是一篇长篇文章,讨论 AssetBundles 实际使用的许多方式。包括将有关将资产分配给 AssetBundles 和管理已加载资产的部分,并描述了使用 AssetBundles 的开发人员遇到的许多常见问题。

注意:本指南的 ObjectsAssets 术语与Unity的API中命名有所不同。

本指南调用 Objects 的数据在许多 Unity API 中称为 Assets,例如AssetBundle.LoadAssetResources.UnloadUnusedAssets。本指南称之为 Assets 的文件很少暴露给任何公共API。当它们被公开时,通常只在与构建相关的代码中,例如 AssetDatabaseBuildPipeline。在这些情况下,它们在公共 API 中称为文件 (files)

0×00 前言

这是关于Unity 5中资产,资源和资源管理的系列文章的第2章。

本章介绍 Unity 的序列化系统的内部原理,以及 Unity 如何在 Unity Editor 和运行时维护不同对象之间的引用关系。它还讨论了对象和资产之间的技术区别。这里涉及的主题是理解如何在 Unity 中有效加载和卸载资产的基础。正确的资产管理对于缩短加载时间和降低内存使用率至关重要。

0×01 内部资产和对象

要了解如何在Unity中正确管理数据,了解Unity如何识别和序列化数据非常重要。第一个关键点是 AssetsUnityEngine.Objects 之间的区别。

一个资产是磁盘上的文件,存储在资产为 Unity 项目的文件夹中。纹理,3D 模型或音频剪辑是常见的资产类型。有些资产以 Unity 原生格式包含数据,例如材质。其他资产需要处理为本机格式,例如 FBX 文件。

UnityEngine.Object,或以大写“O”为开头的对象 (Object),是一组序列化的数据统称为描述资源的特定实例。这可以是 Unity Engine 使用的任何类型的资源,例如网格 (mesh),精灵 (sprite),音频剪辑 (AudioClip) 或动画文件 (AnimationClip)。所有对象都是UnityEngine.Object基类的子类。

虽然大多数对象类型都是内置的,但有两种特殊类型。

  1. 一种 ScriptableObject 提供了一个方便的系统,开发人员可以定义自己的数据类型。Unity 可以对这些类型进行本机序列化和反序列化,并在 Unity Editor的Inspector 窗口中进行操作。
  2. 一种 MonoBehaviour 提供了一个链接到 MonoScript 的包。MonoScript 是一种内部数据类型,Unity 用于保存对特定程序集和命名空间内特定脚本类的引用。该 MonoScript 并没有包含任何实际的可执行代码。

资产和对象之间存在一对多的关系; 也就是说,任何给定的 Asset 文件都包含一个或多个 Objects。

0×02 对象间引用

所有 UnityEngine.Objects 都可以引用其他 UnityEngine.Objects。这些其他对象可以保存在同一资产文件中,也可以从其他资产文件导入。例如,材质 Object 通常具有一个或多个对纹理对象的引用。这些纹理对象通常从一个或多个纹理资源文件(例如PNG或JPG)导入。

序列化时,这些引用由两个单独的数据组成:文件GUID本地ID。文件GUID 标识存储目标资源的资产文件。本地惟一的(引用)ID标识资产文件中的每个对象,因为一个资产文件可能包含多个对象。

文件GUID 存储在 .meta 文件中。这些 .meta 文件是在 Unity 首次导入资产时生成的,并存储在与资产相同的目录中。

可以在文本编辑器中查看上述标识和引用系统:创建一个新的 Unity 项目并更改其编辑器设置以公开可见元文件并将资产序列化为文本。创建材质并将纹理导入到项目中。将材质指定给场景中的立方体并保存场景。

使用文本编辑器打开与材料关联的 .meta 文件。标有 “guid” 的行将出现在文件顶部附近。此行定义材料 Asset 的文件GUID。要查找本地ID,请在文本编辑器中打开材料文件。材质 Object 的定义如下所示:

  1. --- !u!21 &2100000
  2. Material:
  3. serializedVersion: 3
  4. ... more data …

在上面的示例中,以&符号开头的数字是素材的本地ID。如果此材料 Object 位于由文件GUID“abcdefg”标识的资产内,则可以将材料 Object 唯一标识为文件GUID“abcdefg” 和本地ID“2100000” 的组合。

0×03 为什么必需文件GUID 和本地ID?

为什么 Unity 的文件GUID 和本地ID 系统是必需的?答案是稳健性,并提供灵活的,独立于平台的工作流程。

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

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

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

Unity Editor 具有指向已知文件GUID 的特定文件路径的映射。只要加载或导入资产,就会记录映射条目。映射条目将Asset的特定路径链接到 Asset 的文件GUID。如果在 .meta 文件丢失且资产路径未更改时 Unity 编辑器处于打开状态,则编辑器可以确保资产保留相同的文件GUID。

如果在关闭 Unity 编辑器时丢失 .meta 文件,或者资产的路径发生更改而 .meta 文件并没有随资产一起移动时,那么对该资产中对象的所有引用都将被破坏。

0×04 综合资产和进口商

如 “内部资产和对象” 部分所述,必须将非本机资产类型导入 Unity。这是通过资产导入者操作完成的。虽然这些导入器通常是自动调用的,但它们也通过AssetImporter API 暴露给脚本。例如,TextureImporter API 提供对导入单个纹理资源(如PNG文件)时使用的设置的访问。

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

导入过程将源资源转换为适合 Unity Editor 中选择的目标平台(如 windows)的格式。导入过程可以包括许多其他特殊操作,例如纹理压缩。由于这通常是一个耗时的过程,因此导入的资产会缓存在 Library 文件夹中,从而无需在下次编辑器启动时再次重新导入 Assets。

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

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

0×05 序列化和实例

虽然文件GUID 和本地ID 的引用记录是健壮的,但 GUID 比较慢,并且在运行时需要更高性能的系统。 Unity 内部维护着一个缓存(,它将文件GUID 和本地ID转换为simple。这些被称为实例ID,并且在向缓存注册新对象时以简单,单调递增的顺序分配实例ID。

缓存维护给定实例ID、文件GUID 和定义对象源数据位置的本地ID 之间的映射,以及对象在内存中的实例(如果有的话)。这允许 UnityEngine.Objects 可以稳健地维护对彼此的引用。解析实例ID 引用可以快速返回由实例ID 表示的已加载对象。如果尚未加载目标对象,则可以将文件GUID 和本地ID 解析为对象的源数据,从而允许 Unity 即时加载对象。

在启动时,实例ID 缓存被初始化为项目立即需要的所有对象的数据(即,在构建场景中引用),以及 Resources 文件夹中包含的所有对象。 当运行时 (3) 导入新资产时,以及从 AssetBundle 加载对象时,额外的条目被添加到缓存中。仅当卸载提供对特定文件GUID 和本地ID 的访问权限的 AssetBundle时,才会从缓存中删除实例ID条目。发生这种情况时,将删除实例ID,其文件GUID 和本地ID 之间的映射以节省内存。如果重新加载 AssetBundle,将为从重新加载的 AssetBundle 加载的每个 Object 创建一个新的实例ID。

有关卸载 AssetBundle 的含义的更深入讨论,请参阅AssetBundle Usage Patterns一文中的Managing Loaded Assets部分。

在特定平台上,某些事件可能会强制对象内存不足。例如,当应用程序暂停时,可以从 iOS 上的图形内存卸载图形资产。如果这些对象源自已卸载的 AssetBundle,则 Unity 将无法重新加载对象的源数据。对这些对象的任何现有引用也将无效。在前面的示例中,场景可能看起来具有不可见的网格或洋红色纹理。

实现注意事项:在运行时,上述控制流程并不准确。在运行时比较文件GUID 和本地ID ,以及在负载操作期间不会有足够的性能。构建 Unity 项目时,文件GUID 和本地ID 被确定地映射成更简单的格式。但是,这个概念仍然是相同的,并且在运行时期间考虑文件GUID 和本地ID 仍然是一个有用的类比。这也是为什么资产文件GUID 不能在运行时查询的原因。

0×06 MonoScripts

重要的是要理解 MonoBehaviour 有对 MonoScript 的引用,而 MonoScripts 仅仅包含定位特定脚本类所需的信息。 这两种类型的对象都不包含脚本类的可执行代码。

MonoScript 包含三个字符串:程序集名称,类名称和命名空间。

构建项目时,Unity 会将 Assets 文件夹中的所有松散脚本文件编译为 Mono 程序集。插件子文件夹之外的 C#脚本放在 Assembly-CSharp.dll 中。Plugins 子文件夹中的脚本放在 Assembly-CSharp-firstpass.dll 中,依此类推。此外,Unity 2017.3 还引入了定义自定义托管程序集的功能

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

这个 MonoScript 对象是 AssetBundle(场景或预制体) 中的任何一个 MonoBehaviour 组件中实际上不包含可执行代码的原因。这允许不同的 MonoBehaviours 引用特定的共享类,即使 MonoBehaviours 位于不同的 AssetBundle 中。

0×07 资源生命周期

要减少加载时间并管理应用程序的内存占用,理解 UnityEngine.Objects 的资源生命周期非常重要。在特定和定义的时间将对象加载到内存中或从内存中卸载。

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

  1. 映射到该 Object 的实例ID 将被取消引用
  2. Object 当前未加载到内存中
  3. 可以定位 Object 的源数据。

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

  1. 实例ID 引用当前未加载的 Object
  2. 实例ID 具有在缓存中注册的有效文件GUID 和本地ID

这通常在加载和解析引用本身后不久发生。

如果文件GUID 和本地ID 没有实例ID,或者具有卸载的对象的实例ID 引用无效的文件GUID 和本地ID,则保留引用但不会加载实际的对象。参考在 Unity 编辑器中显示为“(Missing)”。在正在运行的应用程序中,或在“场景视图(Scene View)” 中,“(Missing)” 对象将以不同的方式显示,具体取决于其类型。例如,网格看起来是不可见的,而纹理可能看起来是洋红色。

在三种特定方案中卸载对象:

  • 对象在发生未使用的资产清理时自动卸载。当场景被破坏性地更改时(即 SceneManager.LoadScene 时),或者当脚本调用Resources.UnloadUnusedAssets API 时,此过程将自动触发。此过程仅卸载未引用的对象; 只有当没有 Mono 变量持有对对象的引用,并且没有其他活动对象持有对对象的引用时,才会卸载对象。此外,请注意,不会卸载任何标有HideFlags.DontUnloadUnusedAssetHideFlags.HideAndDontSave的内容。
  • 可以通过调用Resources.UnloadAsset API 显式卸载源自 Resources文件夹的对象。这些对象的实例ID 仍然有效,并且仍将包含有效的文件GUID 和本地ID。如果任何 Mono 变量或其他 Object 包含对使用Resources.UnloadAsset卸载的 Object 的引用,则只要取消引用任何实时引用,就会重新加载该 Object。
  • 在调用AssetBundle.Unload(true)API 时,会立即自动卸载源自 AssetBundles 的对象。这将使对象实例ID 的文件GUID 和本地ID 无效,并且对已卸载对象的任何实时引用将变为“(Missing)”引用。从C#脚本尝试访问卸载对象上的方法或属性时将产生NullReferenceException

如果调用 AssetBundle.Unload(false),来自卸载的 AssetBundle 的活动对象将不会被销毁,但 Unity 将使实例ID的文件GUID 和本地ID 引用无效。如果稍后从内存中卸载这些对象并且仍然存在对已卸载对象的实时引用,则 Unity 将无法重新加载这些对象。

0×08 加载大型层次结构

序列化 Unity 游戏对象的层次结构时,例如在预制体序列化期间,重要的是要记住整个层次结构将完全序列化。也就是说,层次结构中的每个 GameObject 和 Component 将在序列化数据中单独表示。这对加载和实例化 GameObjects 层次结构所需的时间产生了有趣的影响。

在创建任何 GameObject 层次结构时,CPU 时间以几种不同的方式使用:

  • 读取源数据(来自存储,来自 AssetBundle,或来自另一个 GameObject 等)
  • 在新转换之间建立父子关系
  • 实例化新的 GameObjects 和组件
  • 在主线程上唤醒新的 GameObjects 和 Components

后三种时间成本通常是不变的,无论层次结构是从现有层次结构克隆还是从存储加载。但是,读取源数据的时间随序列化到层次结构中的组件和游戏对象的数量线性增加,并且还要乘以数据源的速度。

在所有当前平台上,从内存中的其他位置读取数据要比从存储设备加载数据快得多。此外,可用存储介质的性能特征在不同平台之间变化很大。因此,当在存储速度较慢的平台上加载预制体时,从存储中读取预制体的序列化数据所花费的时间可能很快超过实例化预制体所花费的时间。也就是说,加载操作的成本与存储I / O 时间有关。

如前所述,在序列化单片预制体时,每个 GameObject 和组件的数据都是单独序列化的,这可能会重复复制数据。例如,具有 30 个相同元素的 UI 画布将具有序列化30次的相同元素,从而产生大量二进制数据。在加载时,必须从磁盘读取这30个重复元素的每一个游戏对象(gameobject) 和组件(compontent) 的数据,然后再传输到新实例化的对象。此文件读取时间是实例化大型预制体的总体成本的重要因素。大型层次结构应该在模块化块中实例化,然后在运行时拼接在一起。

Unity 5.4 注意: Unity 5.4 改变了内存中转换的表示。每个根变换的整个子层次结构都存储在紧凑的,连续的内存区域中。当实例化新的 GameObject(游戏对象) 时,请考虑使用新的 GameObject.Instantiate 接受父参数的重载变量。使用此重载可避免为新 GameObject 分配根变换层次结构。在测试中,这将实例化操作所需的时间加速约 5-10%。

-EOF-