Unity 中的序列化

本着分享更多幕后技术以及某些事情之所以如此的精神,本篇文章将概述 Unity 的序列化系统。对这一系统的充分了解会对你的开发效率和产品性能产生重大影响。开始了
事物 "的序列化是 Unity 的核心。我们的许多功能都建立在序列化系统之上:
- 存储脚本中的数据大多数人对这个问题可能都比较熟悉。
- 检查员窗口。检查器窗口不会与 C# api 对话,以确定所检查对象的属性值。它要求对象序列化自身,然后显示序列化数据。
- 预制件。在内部,预制件是一个(或多个)游戏对象和组件的序列化数据流。预制实例是一个应在该实例的序列化数据上进行修改的列表。实际上,"预制件 "这一概念只存在于编辑器中。当 Unity 进行构建时,预制件的修改会被烘焙到一个正常的序列化流中,当该序列化流被实例化时,实例化的 GameObjects 完全不知道它们在编辑器中时是一个预制件。
- 实例化。当您对预制件、场景中的 GameObject 或其他任何对象(UnityEngine.Object 派生的所有对象都可以序列化)调用 Instantiate() 时,我们会序列化对象,然后创建一个新对象,然后将数据 "反序列化 "到新对象上。(然后,我们在不同的变体中再次运行相同的序列化代码,并用它来报告哪些其他 UnityEngine.Object 正在被引用。然后,我们会检查所有引用的 UnityEngine.Object 是否属于正在 Instantiated() 的数据的一部分。如果引用指向的是 "外部 "东西(如纹理),我们会保持该引用的原样;如果引用指向的是 "内部 "东西(如子 GameObject),我们会将引用修补为相应的副本。)
- 节约。如果使用文本编辑器打开 .unity 场景文件,并将 Unity 设置为 "强制文本序列化",我们就会使用 yaml 后台运行序列化器。
- 加载中这似乎并不奇怪,但向后兼容加载系统也是建立在序列化之上的。 编辑器内的 yaml 加载使用序列化系统,以及场景和资产的运行时加载。资产捆绑包还利用了序列化系统。
- 编辑器代码的热重载更改编辑器脚本时,我们会序列化所有编辑器窗口(它们源自 UnityEngine.Objective!),然后销毁所有窗口,卸载旧的 c# 代码,加载新的 c# 代码,重新创建窗口,最后将窗口的数据流反序列化到新窗口上。
- Resource.GarbageCollectSharedAssets().这是我们的本地垃圾回收器,与 C# 垃圾回收器不同。在加载一个场景后,我们会运行它,以找出前一个场景中哪些东西不再被引用,从而卸载它们。本机垃圾回收器以一种模式运行序列化器,在这种模式下,我们使用它让对象报告对外部 UnityEngine.Objects.Objects 的所有引用。这样,当加载场景 2 时,场景 1 使用过的纹理就会被卸载。
序列化系统是用 C++ 编写的,我们将其用于所有内部对象类型(纹理、AnimationClip、相机等)。序列化发生在 UnityEngine.Object 层级,每个 UnityEngine.Object 始终作为一个整体进行序列化。它们可以包含对其他 UnityEngine.Objects 的引用,并且这些引用会被正确序列化。
现在你可能会说,这些都与你无关,你只是很高兴它能正常工作,并想继续创作一些内容。不过,这将与您有关,因为我们使用相同的序列化器来序列化 MonoBehaviour 组件,这些组件由您的脚本支持。由于序列化器对性能的要求非常高,因此它并不是在所有情况下都能像 C# 开发人员所期望的那样运行。下面我们将介绍序列化器的工作原理,以及如何充分利用它的一些最佳实践。
为了序列化,我的脚本中的字段需要是什么?
- 公开,或具有 [SerializeField] 属性
- 不是静态的
- 不是常数
- 非只读
- 字段类型必须是我们可以序列化的类型。
我们可以序列化哪些字段类型?
- 带有 [Serializable] 属性的自定义非抽象类。
- 带有 [Serializable] 属性的自定义结构体。(Unity4.5新增)
- 引用源于 UntiyEngine.Object 的对象
- 原始数据类型(int、float、double、bool、string 等)
- 可以序列化的字段类型数组
- 我们可以序列化的字段类型的 List<T>
到目前为止还不错。那么,在哪些情况下,序列化器的表现会与我的预期不同呢?
自定义类的行为类似于结构体
[可序列化]
动物类
{
公共字符串 name;
}
类 MyScript :MonoBehaviour
{
public Animal[] animals;
}
如果在 animals 数组中填充三个指向单个 Animal 对象的引用,那么在序列化流中就会出现 3 个对象。反序列化后,现在有三个不同的对象。如果需要序列化带有引用的复杂对象图,就不能依靠 Unity 的序列化器自动完成所有工作,而必须自己动手将对象图序列化。请参阅下面的示例,了解如何序列化 Unity 本身无法序列化的内容。
请注意,这只适用于自定义类,因为它们是 "内联 "序列化的,因为它们的数据会成为它们所使用的 MonoBehaviour 的完整序列化数据的一部分。当您的字段引用 UnityEngine.Object 派生类(如 "public Camera myCamera")时,该相机的数据不会内联序列化,而是序列化对相机 UnityEngine.Object 的实际引用。
自定义类不支持 null
小测验对使用此脚本的 MonoBehaviour 进行反序列化时的分配次数:
类测试 :MonoBehaviour
{
公共麻烦t;
}
[可序列化]
问题类
{
公共麻烦 t1;
公共麻烦 t2;
公共麻烦 t3;
}
如果只有一个分配,即测试对象的分配,那也没什么奇怪的。如果有两个分配,一个分配给测试对象,另一个分配给故障对象,这也并不奇怪。正确答案是 729。序列化器不支持 null。如果它序列化了一个对象,而某个字段为 null,我们就会实例化一个该类型的新对象并序列化它。显然,这可能会导致无限循环,因此我们将深度限制在相对神奇的 7 层。此时,我们只需停止序列化具有自定义类/结构体、列表和数组类型的字段即可。[1]
由于我们的许多子系统都建立在序列化系统之上,因此测试 MonoBehaviour 的序列化流出乎意料地大,这将导致所有这些子系统的运行速度比必要时更慢。我们在调查客户项目的性能问题时,几乎总能发现这个问题,因此我们在 Unity 4.5 中针对这种情况添加了警告。实际上,我们把警告的执行方式弄得一团糟,以至于会发出如此多的警告,让你别无选择,只能立即修复。我们很快就会在发布的补丁中对此进行修复,警告并不会消失,但每次 "进入游戏模式 "只会收到一次警告,这样就不会出现垃圾邮件了。您仍然需要修改代码,但您应该可以在适合自己的时间进行修改。
不支持多态性
如果您有
公共 动物
并放入一只狗、一只猫和一只长颈鹿的实例,在序列化后,就会有三个 Animal 实例。
处理这种限制的一种方法是认识到它只适用于 "自定义类",这些类会被内联序列化。对其他 UnityEngine.Object 的引用会被序列化为实际引用,对于这些引用,多态性实际上是有效的。你可以创建一个 ScriptableObject 派生类或另一个 MonoBehaviour 派生类,然后引用它们。这样做的缺点是需要将 MonoBehaviour 或脚本对象存储在某个地方,无法很好地内联序列化。
造成这些限制的原因是,序列化系统的核心基础之一是,对象的数据流布局是预先知道的,它取决于类的字段类型,而不是字段内部存储的内容。
我想序列化 Unity 序列化器不支持的东西。我该怎么办?
在许多情况下,最好的方法是使用序列化回调。通过它们,您可以在序列化器从字段读取数据之前以及向字段写入数据之后收到通知。您可以使用它在运行时对难以序列化的数据进行与实际序列化时不同的表示。 在 Unity 将数据序列化之前,您可以使用它们将数据转换成 Unity 可以理解的形式;在 Unity 将数据写入字段之后,您也可以使用它将序列化后的表单转换回运行时的数据形式。
假设您想建立一个树形数据结构。如果让 Unity 直接序列化数据结构,"不支持 null "的限制会导致数据流变得非常大,从而导致许多系统的性能下降:
using UnityEngine;
使用 System.Collections.Generic.NET;
使用 System;
public class VerySlowBehaviourDoNotDoThis :MonoBehaviour
{
[可序列化]
公共类 节点
{
public string interestingValue = "value";
//下面的字段使序列化数据变得巨大,因为
//它引入了一个 "类循环"。
public List<Node> children = new List<Node>();
}
//this gets serialized
public Node root = new Node();
void OnGUI()
{
显示屏(根);
}
void Display(Node node)
{
GUILayout.Label ("Value: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));
GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();
Foreach (var child in node.children)
显示屏(儿童);
如果 (GUILayout.Button ("Add child"))
node.children.Add (new Node ());
GUILayout.EndVertical();
GUILayout.EndHorizontal ();
}
}
相反,你可以告诉 Unity 不要直接将树序列化,然后创建一个单独的字段,以适合 Unity 序列化器的序列化格式存储树:
using UnityEngine;
使用 System.Collections.Generic.NET;
使用 System;
public class BehaviourWithTree :MonoBehaviour, ISerializationCallbackReceiver
{
//运行时使用的节点类
公共类 节点
{
public string interestingValue = "value";
public List<Node> children = new List<Node>();
}
//我们将用于序列化的节点类
[可序列化]
公共结构 SerializableNode
{
public string interestingValue;
public int childCount;
public int indexOfFirstChild;
}
//我们在运行时使用的根目录。
Node root = new Node();
// the field we give Unity to serialize.
public List<SerializableNode> 序列化节点;
public void OnBeforeSerialize()
{
//让我们确保
//我们 "及时 "将正确的数据写入该字段。
serializedNodes.Clear();
AddNodeToSerializedNodes(root);
}
void AddNodeToSerializedNodes(Node n)
{
var serializedNode = new SerializableNode () {
interestingValue = n.interestingValue,
childCount = n.children.Count,
indexOfFirstChild = serializedNodes.Count+1
};
serializedNodes.Add (serializedNode);
Foreach (var child in n.children)
AddNodeToSerializedNodes (child);
}
public void OnAfterDeserialize()
{
//Unity 刚刚向序列化节点字段写入了新数据。
// 让我们用这些新值填充实际运行时数据。
如果 (serializedNodes.Count > 0)
root = ReadNodeFromSerializedNodes (0);
不然
root = new Node ();
}
Node ReadNodeFromSerializedNodes(int index)
{
var serializedNode = serializedNodes [index];
var children = new List<Node> ();
for(int i=0; i!= serializedNode.childCount; i++)
children.Add(ReadNodeFromSerializedNodes(serializedNode.indexOfFirstChild + i));
return new Node() {
interestingValue = serializedNode.interestingValue,
儿童 = 儿童
};
}
void OnGUI()
{
显示屏(根);
}
void Display(Node node)
{
GUILayout.Label ("Value: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));
GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();
Foreach (var child in node.children)
显示屏(儿童);
如果 (GUILayout.Button ("Add child"))
node.children.Add (new Node ());
GUILayout.EndVertical();
GUILayout.EndHorizontal ();
}
}
请注意,序列化器(包括这些来自序列化器的回调)通常不在主线程上运行,因此在调用 Unity API 方面,你能做的非常有限。(作为加载场景的一部分,序列化发生在加载线程上。序列化是在主线程中调用脚本 Instantiate() 的一部分)。不过,你也可以进行必要的数据转换,将数据从非 Unity-serializer友好格式转换为 Unity-serializer友好格式。
你坚持到了最后
感谢您阅读到这里,希望您能在自己的项目中很好地利用这些信息。
再见,卢卡斯(@lucasmeijer)
PS:我们也会将这些信息添加到文档中。
[1] 我撒谎了,正确答案其实不是 729。这是因为在我们还没有 7 级深度限制之前,Unity 会无休止地循环,如果你创建了一个像我刚刚写的 "麻烦 "这样的脚本,就会耗尽内存。5 年前,我们解决这个问题的第一个办法就是不序列化与类本身类型相同的字段类型。显然,这不是最稳健的修复方法,因为使用 Trouble1->Trouble2->Trouble1->Trouble2 类很容易创建一个循环。因此,不久之后,我们实际上也实施了 7 级深度限制,以应对这些情况。不过,我想说的是,这并不重要,重要的是你要意识到,如果存在循环,你就有麻烦了。
