Unity 序列化

TIM COOPER / UNITY TECHNOLOGIESContributor
Oct 25, 2012|14 Min
Unity 序列化
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

你在 Unity 中编写了一个非常酷的编辑器扩展,而且看起来进展非常顺利。数据结构全部理清后,你会对自己编写的工具的工作方式非常满意。

然后进入和退出播放模式。

突然之间,您输入的所有数据都消失了,您的工具被重置为默认状态,即刚初始化的状态。这是非常令人沮丧的!"为什么会这样?"你问自己。原因与 Unity 的托管(Mono)层的工作方式有关。一旦你理解了它,事情就会变得容易得多:)

重新加载程序集会发生什么情况?
当您进入/退出播放模式或更改脚本时,Unity 必须重新加载 Mono 程序集,即与 Unity 相关的 dll。

在用户方面,这个过程分为 3 个步骤:

  • 将所有可序列化数据从托管区域中提取出来,在 Unity 的 C++ 端创建数据的内部表示。
  • 销毁与 Unity 受管端相关的所有内存/信息,并重新加载程序集。
  • 将 C++ 保存的数据重新序列化为托管土地。

这意味着,要使数据结构/信息在程序集重载后仍能存活,就必须确保其能正确地序列化到 c++ 内存中并从 c++ 内存中流出。这样做还意味着(稍作修改),您可以将此数据结构保存到资产文件中,并在以后重新加载。

如何使用 Unity 的序列化?
学习 Unity 序列化的最简单方法是通过一个示例。我们将从一个简单的编辑器窗口开始,该窗口包含一个类的引用,我们要让该类在程序集重载后存活下来。

using UnityEngine;
using UnityEditor;

public class MyWindow : EditorWindow
{
	private SerializeMe m_SerialziedThing;

	[MenuItem ("Window/Serialization")]
	static void Init () {
		GetWindow ();
	}

	void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_SerialziedThing == null)
			m_SerialziedThing = new SerializeMe ();
	}

	void OnGUI () {
		GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
		m_SerialziedThing.OnGUI ();
	}
}

using UnityEditor;

public struct NestedStruct
{
	private float m_StructFloat;
	public void OnGUI ()
	{
		m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
	}
}

public class SerializeMe
{
	private string m_Name;
	private int m_Value;

	private NestedStruct m_Struct;

	public SerializeMe ()
	{
		m_Struct = new NestedStruct();
		m_Name = "";
	}

	public void OnGUI ()
	{
		m_Name = EditorGUILayout.TextField( "Name", m_Name);
		m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);

		m_Struct.OnGUI ();
	}
}

运行此程序并强制重新加载程序集时,你会发现窗口中任何已更改的值都不会继续存在。这是因为当重新加载程序集时,对 "m_SerialziedThing "的引用已经消失。它没有被标记为序列化。

要使序列化正常工作,还需要做一些事情:
在 MyWindow.cs 中

  • m_SerializedThing' 字段需要添加 [SerializeField] 属性。这就告诉 Unity,它应该在程序集重载或类似事件中尝试序列化这个字段。

在 SerializeMe.cs 中:

  • SerializeMe "类需要添加 [Serializable] 属性。这会告诉 Unity 类是可序列化的。
  • 结构体'NestedStruct'需要添加 [Serializable] 属性。
  • 要序列化的每个(非公共)字段都需要添加 [SerializeField] 属性。

添加这些标记后,打开窗口并修改字段。你会发现,在重新加载程序集后,除了来自结构体的字段外,其他字段都保留了自己的值。这就引出了第一个重要问题:结构体并不支持序列化。将 "NestedStruct "从结构体改为类可以解决这个问题。

现在的代码是这样的

using UnityEngine;
using UnityEditor;

public class MyWindow : EditorWindow
{
	private SerializeMe m_SerialziedThing;

	[MenuItem ("Window/Serialization")]
	static void Init () {
		GetWindow ();
	}

	void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_SerialziedThing == null)
			m_SerialziedThing = new SerializeMe ();
	}

	void OnGUI () {
		GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
		m_SerialziedThing.OnGUI ();
	}
}

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedStruct
{
	[SerializeField]
	private float m_StructFloat;
	public void OnGUI ()
	{
		m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private string m_Name;
	[SerializeField]
	private int m_Value;
	[SerializeField]
	private NestedStruct m_Struct;

	public SerializeMe ()
	{
		m_Struct = new NestedStruct();
		m_Name = "";
	}

	public void OnGUI ()
	{
		m_Name = EditorGUILayout.TextField( "Name", m_Name);
		m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);

		m_Struct.OnGUI ();
	}
}

一些序列化规则

  • 避免使用结构体
  • 希望可序列化的类需要用 [Serializable] 标记。
  • 公共字段被序列化(只要它们引用的是 [Serializable] 类)
  • 在某些情况下,私有字段会被序列化(编辑器)。
  • 如果希望将私有字段序列化,请将其标记为 [SerializeField]。
  • [非序列化]存在于不想序列化的字段中。

可编写脚本的对象
到目前为止,我们已经了解了如何使用普通类进行序列化。遗憾的是,在 Unity 中使用纯类会产生一些序列化问题。让我们来看一个例子。

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedClass
{
	[SerializeField]
	private float m_StructFloat;
	public void OnGUI()
	{
		m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public void OnGUI ()
	{
		if (m_Class1 == null)
			m_Class1 = new NestedClass ();
		if (m_Class2 == null)
			m_Class2 = m_Class1;

		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

这只是一个虚构的例子,目的是展示 Unity 序列化系统的一个非常特殊的角落,稍不注意就会被它抓住。您会发现我们有两个 NestedClass 类型的字段。第一次绘制窗口时,它将同时显示两个字段,由于 m_Class1 和 m_Class2 指向相同的引用,因此修改其中一个就会修改另一个。

现在尝试通过进入和退出播放模式来重新加载程序集...引用已经解耦。这是因为当你把一个类简单地标记为 [Serializable] 时,序列化是如何工作的。

在序列化标准类时,Unity 会遍历类中的字段,并逐个序列化,即使多个字段共享引用。这意味着同一个对象可以被序列化多次,但在反序列化时,系统并不知道它们是同一个对象。如果你正在设计一个复杂的系统,这是一个令人沮丧的限制,因为它意味着无法正确捕捉类之间复杂的交互。

输入 ScriptableObjects!可脚本对象是一种能正确序列化为引用的类,因此只序列化一次。这样,复杂的类交互就能以你所期望的方式存储。在 Unity 内部,ScriptableObject 和 MonoBehaviours 是相同的;在用户态代码中,您可以拥有一个未附加到 GameObject 的 ScriptableObject;这与 MonoBehaviour 的工作方式不同。它们非常适合一般的数据结构序列化。

让我们修改一下示例,以正确处理序列化:

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedClass : ScriptableObject
{
	[SerializeField]
	private float m_StructFloat;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public void OnGUI()
	{
		m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public SerializeMe ()
	{
		m_Class1 = ScriptableObject.CreateInstance ();
		m_Class2 = m_Class1;
	}

	public void OnGUI ()
	{
		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

这里值得注意的三个变化是

  • NestedClass 现在是一个 ScriptableObject。
  • 我们使用 CreateInstance<> 函数创建实例,而不是调用构造函数。
  • 我们还设置了隐藏标记......稍后将对此进行说明

这些简单的更改意味着 NestedClass 的实例将只被序列化一次,该类的每个引用都指向同一个实例。

可脚本对象初始化
现在我们知道,对于需要外部引用的复杂数据结构,使用 ScriptableObjects 是个不错的主意。但是,从用户代码中使用可脚本对象的正确方法是什么?首先要研究的是可脚本对象是如何初始化的,尤其是从 Unity 序列化系统中初始化的。

在 ScriptableObject 上调用构造函数。

数据从 Unity 的 c++ 端序列化到 Object 中(如果存在此类数据)。

在 ScriptableObject 上调用 OnEnable()。

利用这些知识,我们可以说一些事情:

  • 在构造函数中进行初始化并不是一个好主意,因为数据有可能被序列化系统重写。
  • 序列化发生在构造之后,所以我们应该在序列化之后再进行配置。
  • OnEnable() 似乎是初始化的最佳选择。

让我们对 "SerializeMe "类做一些修改,使其成为一个可脚本对象。这样我们就能看到 ScriptableObjects 的正确初始化模式。

// also updated the Window to call CreateInstance instead of the constructor
using System;
using UnityEngine;

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_Class1 == null)
		{
			m_Class1 = CreateInstance ();
			m_Class2 = m_Class1;
		}
	}

	public void OnGUI ()
	{
		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

从表面上看,我们似乎并没有对这个类做太大的改动,它现在继承自 ScriptableObject,并且没有使用构造函数,而是使用了 OnEnable()。需要注意的重要部分稍显微妙...OnEnable() 在序列化之后调用;因此我们可以看到 [SerializedFields] 是否为 null。如果它们为 null,则表明这是第一次初始化,我们需要构建实例。如果它们不为 null,则表示它们已被加载到内存中,无需构建。在 OnEnable() 中,通常还会调用自定义初始化函数来配置对象上的任何私有/非序列化字段,就像在构造函数中一样。

HideFlags
在使用 ScriptableObjects 的示例中,你会发现我们将对象的 "hideFlags "设置为 HideFlags.HideAndDontSave。在编写场景中没有根的自定义数据结构时,需要进行这种特殊设置。这是为了绕过 Unity 中场景加载的工作方式。

在内部加载场景时,unity 会调用 Resources.UnloadUnusedAssets。如果没有任何资产被引用,垃圾回收器就会找到它。GC 将场景作为 "根",并遍历层次结构,查看哪些内容可以被 GC。在 ScriptableObject 上设置 HideAndDontSave 标志后,Unity 会将该对象视为根对象。因此,它不会因为装配重装而消失。该对象仍可通过调用 Destroy() 销毁。

一些可脚本对象规则

  • 可脚本对象只会被序列化一次,这样就可以正确使用引用。
  • 使用 OnEnable 初始化脚本对象。
  • 请勿调用脚本对象的构造函数,而应使用 "创建实例"(CreatInstance)。
  • 对于只引用一次的嵌套数据结构,不要使用 ScriptableObject,因为它们的开销更大。
  • 如果您的脚本对象没有扎根于场景中,请将 hideFlags 设置为 HideAndDontSave。

具体数组序列化
让我们来看一个简单的示例,它将一系列具体类序列化。

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

[Serializable]
public class BaseClass
{
	[SerializeField]
	private int m_IntField;
	public void OnGUI() {m_IntField = EditorGUILayout.IntSlider ("IntField", m_IntField, 0, 10);}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_Instances == null)
			m_Instances = new List ();
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Simple"))
			m_Instances.Add (new BaseClass ());
	}
}

这个基本示例有一个 BaseClasses 列表,点击 "添加简单 "按钮就会创建一个实例并将其添加到列表中。由于为序列化正确配置了 SerializeMe 类(如前所述),所以它 "就是能用"。Unity 发现 List 已标记为序列化,于是将 List 的每个元素序列化。

通用数组序列化
让我们修改示例,序列化一个包含基类和子类成员的列表:

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

[Serializable]
public class BaseClass
{
	[SerializeField]
	private int m_IntField;
	public virtual void OnGUI() { m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10); }
}

[Serializable]
public class ChildClass : BaseClass
{
	[SerializeField]
	private float m_FloatField;
	public override void OnGUI()
	{
		base.OnGUI ();
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List ();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Base"))
			m_Instances.Add (new BaseClass ());
		if (GUILayout.Button ("Add Child"))
			m_Instances.Add (new ChildClass ());
	}
}

该示例经过扩展,现在有了 ChildClass,但我们使用 BaseClass 进行序列化。如果您创建了几个 ChildClass 和 BaseClass 的实例,它们就会正常呈现。在装配重装时就会出现问题。重载完成后,每个实例都将是一个 BaseClass,所有 ChildClass 信息都将被删除。序列化系统正在对实例进行剪切。

绕过序列化系统这一限制的方法是再次使用可脚本对象:

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

[Serializable]
public class MyBaseClass : ScriptableObject
{
	[SerializeField]
	protected int m_IntField;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public virtual void OnGUI ()
	{
		m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
	}
}

[Serializable]
public class ChildClass : MyBaseClass
{
	[SerializeField]
	private float m_FloatField;

	public override void OnGUI()
	{
		base.OnGUI ();
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Base"))
			m_Instances.Add(CreateInstance());
		if (GUILayout.Button ("Add Child"))
			m_Instances.Add(CreateInstance());
	}
}

运行此程序、更改一些值并重新加载程序集后,你会发现即使是序列化派生类型,在数组中使用 ScriptableObject 也是安全的。原因是当你序列化一个标准的 [Serializable] 类时,它是 "就地 "序列化的,而 ScriptableObject 则是外部序列化的,并将引用插入 Collections 中。出现剪切是因为序列化系统认为该类型属于基类型,因此无法对其进行正确的序列化。

抽象类的序列化
现在,我们已经看到了序列化普通列表的可能性(只要成员是 ScriptableObject 类型)。让我们看看抽象类的行为方式:

using System;
using UnityEditor;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public abstract class MyBaseClass : ScriptableObject
{
	[SerializeField]
	protected int m_IntField;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public abstract void OnGUI ();
}

[Serializable]
public class ChildClass : MyBaseClass
{
	[SerializeField]
	private float m_FloatField;

	public override void OnGUI()
	{
		m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Child"))
			m_Instances.Add(CreateInstance());
	}
}

该代码的工作原理与前一个示例非常相似。但这很危险。让我们来看看为什么。

函数 CreateInstance<>() 希望使用继承自 ScriptableObject 的类型,而事实上类 "MyBaseClass "确实继承自 ScriptableObject。这意味着可以在 m_Instances 数组中添加抽象类 MyBaseClass 的实例。如果你这样做了,然后试图访问抽象方法,就会发生不好的事情,因为没有该函数的实现。在这种特定情况下,这就是 OnGUI 方法。

使用抽象类作为列表和字段的序列化类型是可行的,只要它们继承自 ScriptableObject,但不推荐这种做法。我个人认为,最好使用带有空虚方法的具体类。这样就能确保事情不会变得糟糕。

何时将可脚本对象持久化为场景/预制文件?
默认情况下,GameObject 及其组件会保存到场景中。通过代码创建的资产类型(材质/网格/动画剪辑/序列化对象)只要被场景中的游戏对象或其组件引用,就会被保存在场景中。

资产类型也可使用 AssetDatabase.CreateAsset 明确标记为资产。在这种情况下,它们不会保存在场景中,而只是被引用。如果资产类型或游戏对象类型被标记为 HideAndDontSave,也不会保存在场景中。

有问题吗?