这是 Unity 5 [资产, 资源和资源管理系列文章]的第二篇。
这篇文章涵盖了 Unity 序列化系统的底层知识和 Unity 怎么在它的编辑器及运行时维持不同对象的稳定的引用,理解怎么样在 Unity 中高效的加载和卸载资产。正确的资产管理是保持低内存和快速加载的关键。
资产和对象的内部
要理解怎么样正确的管理数据,Unity 怎么鉴别和序列化数据很重要。其中首先的关键点是理解 资产 和 对象(UnityEngine.Objects) 的区别。
一个资产是存储在 Unity Project 中资产文件夹下的文件。比如纹理文件,材质文件和 FBX 文件都是资产。有些资产包含 Unity 原生数据格式,比如材质。而有些资产需要转换成 Unity 原生数据格式,比如 FBX 文件。
一个 UnityEngine.Object 或者大写字母 O 的 Object,是个序列化数据集合,用来表述某个资源的具体实例。它可以是任何 Unity 引擎使用的资源,比如一个网格,一个精灵,一个音频剪辑和一个动画片段。所有的对象都是 UnityEngine.Object 的子类。
在 Unity 中基本所有的对象类型都是内置的,除了两种特殊的类型。
- ScriptableObject 给开发者提供了一个便捷的,可以定义自己数据类型的系统。这些类型能被 Unity 序列化和反序列化和在 Unity 编辑器的 Inspector Window 中使用。
- A MonoBehaviour 提供了一个链接到 MonoScript 的封装。MonoScript 是一个内置的数据类型,在 Unity 中用来保持程序集或者命名空间中一个脚本的引用。MonoSript 不包含任何可以执行的代码。
内置对象的引用
所有的 UnityEngine.Objects 都能拥有其他 UnityEngine.Objects 的引用。而这些其他的 UnityEngine.Objects 可能同一个资产文件,或者从其他资产文件中导入。比如,一个材质对象通常拥有一个或者多个纹理对象的引用。这些纹理对象一般都是从一个或者多个纹理资产文件中导入的(比如 PNG 和 JPG 文件)。
当被序列化之后,由两部分数据组成了这些引用:一个是 文件 GUID 另外一个是 本地 ID。文件 GUID 用来 标识目标资源存储位置下的资产文件。本地ID(唯一的 [1])用来标识一个资产文件中的每个对象,因为一个资产文件可能包含多个对象。
文件 GUID 存储在 .meta 文件中。这些 .meata 文件在 Unity 导入资产的时候创建,存储在和资产文件同一个目录中。
上面提到的鉴别和引用系统可以在文本编辑器中看到:创建一个新 Unity 项目,然后将 编辑器 设置为 Visible Meta Files 和 序列化为文本。然后创建一个材质和导入一个纹理到项目中。将新创建的材质指定到场景中的一个立方体上面,然后保存场景。
我们用文本编辑器打开和材质关联的 .meta 文件。在文件头部会有标识了 “guid” 的行。这行定义了材质的文件 GUID。查找本地 ID, 我们可以在文本编辑器中打开材质文件。我们会看到类似于如下所示的材质对象的定义:
— !u!21 &2100000
Material:
serializedVersion: 3
… more data …
在上面的例子中,前面带 & 符号的数字就是材质的本地 ID。如果一个材质对象在一个文件 GUID 为 abcdefg
的资产里面,这个材质对象就可以用文件 GUID abcdefg
和本地 ID 2100000
组合唯一地标识。
为什么要文件 GUID 和本地 ID
为什么需要 Unity 的文件 GUID 和本地 ID 系统?答案是为了健壮性和提供一个灵活和平台独立的工作流。
文件 GUID 提供了文件位置的抽象。只要文件 GUID 和一个文件关联上,那文件在磁盘上的位置就变得无关紧要了。这个文件可以随意移动,而不必更新所有引用了该文件的对象。
一个资产文件可能包含多个 UnityEngine.Object,为了清楚的区分它们,需要本地 ID。
如果和资产文件相关的文件 GUID 丢失了,所有对象对这个文件的引用就会丢失。这就是为什么 .meta 文件必须和它们相关联的资产文件存储在同一个位置,并且拥有相同的文件名很重要。注意 Unity 会重新生成被删除的或者被乱放的 .meta 文件。
Unity 编辑器拥有已知文件 GUID 到文件路径的映射。这个映射实体会把资产的文件路径和文件 GUID 关联起来。如果 Unity 编辑器打开时,一个 .meta 文件丢失而资产的路径并没有改变的资产,编辑器会确保这个资产得到相同的文件 GUID。
如果 .meta 文件在 Unity Editor 关闭时丢失,或者资产的路径变化时没有把 .meta 文件一起跟资产文件移动,所有对资产内对象的引用都会丢失。
组合资产和 Importers
在 资产和对象的内部 章节中提到,非原生的资产类型需要导入到 Unity 中。这是靠 Assets Importer 完成的。这些 Importers 都是自动调用的,他们靠 AssetImporter API 和它的子类来暴露给脚本。比如,TextureImporter API 提供了导入 PNG 和 JPG 等纹理资产时的设置访问。
导入过程的结果是一个或者多个 UnityEngine.Objects。这些对象在 Unity 编辑器中显示为一个资产的子资产。比如设置成精灵图集方式导入纹理资产,会有多个精灵嵌套在这个资产下。这些精灵对象会共享一个文件 UIID 作为他们的源资产文件。而在导入的纹理资产中靠本地 ID 来区分他们。
导入过程会将源资产文件转换成在 Unity 编辑器中选中的目标平台合适的格式。导入过程也可能会带有比较重的操作,比如纹理压缩。如果每次 Unity 编辑器打开的时候都要执行导入过程的话会是 Unity 编辑器变得特别没有效率。
作为解决方案,Unity 会讲资产导入后的结果缓存到 Libraray 文件夹。导入后的结果会缓存到以资产的文件 GUID 前两个字母命名的文件夹中。这个文件夹在 Library/metadat/ 文件夹内。每个独立的对象都会被序列化为单独的以它们资产文件 GUID 命名的二进制文件。
这对所有的资产都适用,而不仅仅是非原生的资产。但是原生的资产不需要做长时间的转换或者重新序列化。
序列化和实例
文件 GUID 和本地 ID 系统健壮的同时,GUID 的比较是比较慢的,这就需要一个在运行期时更高效的系统。Unity 内部维持了一个能把文件 GUID 和本地 ID 换成在独立会话内唯一的,简单的数字的缓存[2]。这个数字叫做实例 ID。当新的对象注册到缓存时,会给它分配一个严格递增的值。
这个缓存维护了给定的实例 ID、对象源文件中定义的文件 GUID 和本地 ID 和内存中对象(如果有的话)的映射关系。它让 UnityEngine.Objects 稳定的维护各个对象间的引用成为可能。通过一个实例 ID 的引用可以快速的返回这个 ID 对应的对象。如果这个对象没有加载,Unity 就可以根据 FileID 和本地 ID 来实时的加载对象。
在启动的时候,实例 ID 缓存会初始化所有被场景引用的的对象和 Resources 文件夹下的所有对象数据。运行时导入的新资产[3]和从 AssetBundles 里面加载的对象会被额外的添加到缓存中。当实例 ID 不在有用的时候他们会从缓存中移除。当一个 AssetBunld 访问未加载的文件 GUID 和本地 ID 时会生这种情况。
卸载 AssetBundle 时引起实例 ID 无效时,为了节省内存,实例 ID 和文件 GUID 及 本地 ID 间的映射将会被移除。当 AssetBundle 重新被加载时,将会给从 AssetBundle 中重新加载的对象分配一个新的实例 ID。
更深入的探讨卸载 AssetBundles 带来的问题,可以参照 AssetBundle 使用模式 中的 管理加载的资产 这一节。
注意,在某些平台上的特定的事件会强制从内存中删除对象。比如,在 iOS平台,当程序挂起的时候,可以从图形内存里面卸载图形资产。如果这些对象是从已卸载的 AssetBundle 里加载的,Unity 将会无法从对象的源数据重新加载对象了。所有对这些对象的引用也会变成无效。在这个例子中可能会出现看不见的网格或者模型带有洋红色的纹理和材质的现象。
提示: 在运行时,上述控制流程不是特别精确。对于重度加载操作,比较文件 GUID 和本地 ID 是非常不高效的。当构建一个 Unity 项目时,文件 GUID 和本地 ID 都被映射到了一个简单的格式上。但是概念依然一样,运行时考虑使用文件 GUID 和本地 ID 来做对比依然很有用。
这也是为什么资产的文件 GUID 不能再运行时查询的一个原因。(因为被转换成了其他简单格式。)
MonoScripts
理解 MonoBehaviour 拥有一个 MonoScript 的引用和 MonoScript 仅包含简单的用来定位特定脚本的信息是比较重要的。MonoScript 并不包括脚本的执行代码。
MonoScript 包含 3 个字符串:程序集的名字, 一个类名和一个命名空间。
当构建项目的时候,Unity 收集所有 Assets 文件下零散放置的脚本,然后将他们编译成 Mono 程序集。Unity 会为 Assets 文件夹下的不同语言和 Assets/Plugins 文件夹下的脚本构建单独的程序集。在 Plugins 子文件夹外的 C# 脚本会编译到 Assembly-CSharp.dll 中,而 Plugins 子文件夹内的脚本会编译到 Assembly-CSharp-firstpass.dll 中。
这些程序集(包括预先编译好的程序集的 DLL)会被包含到 Unity 应用的最终构建里面。他么也是 MonoScript 引用的程序集。与其他资源不同,所有 Unity 程序内的程序集会在程序第一次启动时加载。
因为有 MonoScript 对象,AssetBundle(或者是场景文件,或者是预设)中 MonoBehaviour 组件可以不包含实际运行代码。这使得不同的 MonoBehaviour 可以指向特定的共享的类,即使这些不同的 MonoBehaviour 在不同的 AssetBundle 中。
Resouce 的生命周期
UnityEingine.Objects 会在具体或者特定的时间从内存中加载/卸载。为了减少加载时间和管理应用的内存,理解 UnityEngine.Objects 的生命周期是比较重要的。
有两种方式可以加载 UnityEngine.Objects: 自动的和显式的。当一个实例 ID 映射到一个源数据存在,但是没有加载进内存并被间接引用的对象时,对象会被自动创建。对象可以在脚本中显式的加载。显式加载方式要可以是直接创建他们,也可以是通过资源加载的 API, 例如 AssetBundle.LoadAsset。
当一个对象被加载,Unity 会尝试将所有引用从文件 GUID 和本地 ID 转换成实例 ID。
当满足下面两个条件时,一个对象在它的实例 ID 被第一次引用时按需加载:
- 实例 ID 引用了没有加载的对象
- 实例 ID 在缓存中有有效的、对应文件 GUID 和本地 ID
这通常发生在引用被加载和处理后的非常短的时间里。
如果一个文件 GUID 和本地 ID 不包含实例 ID, 或者一个实例 ID 关联一个引用无效的文件 GUID 和本地 ID 的未加载的对象,实例 ID 引用将会保留但是实际对象缺不能加载。这个在 Unity 编辑器里面显示为 (Missing)
。在程序运行时或者场景视图里, 基于 (Missing)
对象的类型,会有下面几种显示:比如网格不可见,纹理显示成洋红色。
对象会在下面 3 种情况下被卸载:
- 对象会在未使用的资产被清理时卸载。这个过程当场景破坏性的改变时(例如使用非增量的 Application.LoadLevel API)或者在代码里面调用 Resources.UnloadUnusedAssets API 时自动发生。这个过程只能卸载未被引用的对象:一个对象仅当没有 Mono 变量引用它,和没有其他对象保持其引用时才会卸载。
- 从 Resources 文件夹内加载的对象可以用 Resources.UnloadAsset API 来卸载。它们的实例 ID 会保持有效,依然对应着有效的文件 GUID 和本地 ID。如果任何 Mono 变量或者对象保留着被 Resources.UnloadAsset 卸载的对象的引用,这些对象则会被重新加载。
- 当我们调用 AssetBundle.Unload(true) API 时,从 AssetBundle 加载的对象会被立即自动的卸载。这会使对象的实例 ID 的文件 GUID 和本地 ID 引用无效,并且任何引用的已卸载的对象的引用将会变成
(Missing)
。从 C# 脚本中尝试访问已卸载的对象的方法和属性会抛出 NullReferenceException 异常。
如果调用 AssetBundle.Unload(false),从已卸载的 AssetBundle 中得到的活动的对象将不会被销毁,但是 Unity 会使这些对象的实例 ID 对其文件 GUID 和本地 ID 引用无效。如果这些对象从内存中卸载并且对这些已卸载的对象的引用依然保持着,Unity 将无法重新加载对象。
加载大层次结构
当序列化有大层次结构的 Unity 游戏对象(比如序列化预设)时,重要的是要记住整个层次结构都会被序列化。也就是说层次结构中的每个游戏对象和组件都会被单独的序列化到序列化后的数据里面。这对加载和实例化对象层次需索的时间有影响。
当创建游戏对象层次结构时,CPU 时间会花费在下面几种方面上
- 从存储,别的游戏对象等读取源数据的时间
- 构建新 Transform 间父-子关系的时间
- 实例化新游戏对象和组件的时间
- 激活新游戏对象和组件的时间
后面三种时间花费一般是不变的,无论是从现成的层次结构中拷贝或者从存储中加载(比如 AssetBundle)。但是读取源数据的时间与层次结构中的组件和游戏对象成线性增加的关系,当然还要乘以读取源数据的速度。
在当前支持的所有平台中,从内存中读取数据会比从设备存储中读取明显快不少。而且不同平台上的存储媒介在性能上有很大差别–比如从 PC 上加载数据的数据会比移动设备快很多。
所以在低速存储设备的平台上加载预设,读取从存储上读取预设的实际会快速的超过实例化预设所花费的实际。也就是说,设备 I/O 的时间主导了加载操作所消耗的时间。
前面提到过,序列化大预设的时候,每个游戏对象和组件的数据都会单独的被序列化,即使这些数据是重复的。一个拥有 30 个一样的元素的 UI,这 30 一样的元素都会被序列化。这将会产生大量的数据。当在加载时期,这些重复数据元素必须从磁盘中读取,然后传递给新实例化的对象。文件读取时间主导了大预设实例化花费的时间。
直到 Unity 支持可嵌套预设之前,对于加载大层次游戏对象预设的项目,可以通过将冗余的元素拆分出来,然后在运行时加载它们的,而不依赖于 Unity 的序列化和预设系统来加载整个大对象的方式来大幅减少加载时间
一旦预设或者对象已经构建了,从拷贝已存在的对象会比从存储中加载快一个新拷贝快很多。
Unity 5.4: Unity 5.4 修改了内存中 tranform 的呈现方式。每个根 tranform 的所有子物体都存在内存中一段紧凑的,连续的区域中。实例化会重指定父级的新游戏对象时,考虑用 GameObject.Instantiate 的接受父物体为参数的新重载。
使用这个重载可以避免给新物体新分配根 tranform 层次。测试结果中,这个可以提高 5 - 10 % 的实例化时间。
脚记
- [1] 本地 ID 在包含它文件中是唯一的。也就是说在同一个资产文件中一个本地 ID 会区别于其他本地 ID。
- [2] 在 Unity 内部,这个缓存叫做 PresistenManager。这个转换过程在 Unity 的 C++ Remapper 类中发生。Remapper 类当前不能被任何 C# API 访问。
- [3] 一个在运行时创建资产的例子就是在脚本中创建 Texture2D 对象,例如
1 | var myTexture = new Texture2D(1024, 768); |
- [4] 最常见的是,当 Unity 丢失了对已在运行时从内存中移除的对象的 图形上下文的控制的时候,对象不会被 Unity 卸载。例如,这会发生在一个移动端应用被强制切到后台并挂起的时候,移动端系统通常会将从 GPU 显存中删除所有图形资源。当应用切回到前台,Unity 在恢复渲染当前场景之前, 必须重新上次所有需要的纹理,着色器和网格到 GPU 中。
原文地址:https://unity3d.com/learn/tutorials/topics/best-practices/assets-objects-and-serialization