Сериализация Unity

Итак, Вы пишете действительно классное расширение редактора в Unity, и кажется, что все идет очень хорошо. Вы разобрались со структурами данных и очень довольны тем, как работает написанный Вами инструмент.
Затем Вы входите и выходите из режима игры.
Внезапно все данные, которые Вы ввели, исчезли, а Ваш инструмент был возвращен в состояние по умолчанию, только что инициализированное. Это очень расстраивает! "Почему это происходит?" - спрашиваете Вы себя. Причина кроется в том, как работает управляемый (mono) слой Unity. Как только Вы поймете это, тогда все станет намного проще :)
Что происходит, когда сборка перезагружается?
Когда Вы входите/выходите из режима игры или меняете сценарий, Unity приходится перезагружать сборки Mono, то есть dll, связанные с Unity.
Со стороны пользователя это 3-х шаговый процесс:
- Вытащите все сериализуемые данные из управляемой земли, создав внутреннее представление данных на стороне Unity в C++.
- Уничтожьте всю память / информацию, связанную с управляемой стороной Unity, и перезагрузите сборки.
- Переопределите данные, которые были сохранены в 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' исчезает. Он не размечен для сериализации.
Есть несколько вещей, которые необходимо сделать, чтобы эта сериализация работала правильно:
In 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], если Вы хотите, чтобы они были сериализованы.
- [NonSerialized] существует для полей, которые Вы не хотите сериализовать.
Объекты, управляемые сценарием
До сих пор мы рассматривали использование обычных классов при сериализации. К сожалению, использование простых классов имеет некоторые проблемы, когда речь идет о сериализации в 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! ScriptableObjects - это тип класса, который правильно сериализуется как ссылки, так что они сериализуются только один раз. Это позволяет хранить сложные взаимодействия между классами так, как Вы этого ожидаете. Внутри Unity ScriptableObjects и MonoBehaviour одинаковы; в пользовательском коде Вы можете иметь ScriptableObject, который не привязан к GameObjects; это отличается от того, как работает 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 будет сериализован только один раз, а все ссылки на класс будут указывать на один и тот же класс.
Инициализация ScriptableObject
Итак, теперь мы знаем, что для сложных структур данных, где необходимы внешние ссылки, лучше всего использовать ScriptableObjects. Но как правильно работать со ScriptableObjects из пользовательского кода? Первое, что следует изучить, - это КАК инициализируются объекты скриптов, особенно в системе сериализации Unity.
Конструктор вызывается на объекте ScriptableObject.
Данные сериализуются в объект со стороны c++ в Unity (если такие данные существуют).
OnEnable() вызывается на объекте ScriptableObject.
Работая с этими знаниями, мы можем сказать несколько вещей:
- Выполнять инициализацию в конструкторе - не самая лучшая идея, поскольку данные могут быть переопределены системой сериализации.
- Сериализация происходит ПОСЛЕ конструирования, поэтому мы должны делать наши конфигурационные вещи после сериализации.
- OnEnable() кажется лучшим кандидатом для инициализации.
Давайте внесем некоторые изменения в класс 'SerializeMe', чтобы он стал объектом ScriptableObject. Это позволит нам увидеть правильный шаблон инициализации для 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. Установка флага HideAndDontSave для объекта ScriptableObject указывает Unity на то, что этот объект должен считаться корневым объектом. Поэтому он не исчезнет просто так из-за перезагрузки сборки. Объект все еще можно уничтожить, вызвав Destroy().
Некоторые правила ScriptableObject
- Объекты ScriptableObjects будут сериализованы только один раз, что позволит Вам правильно использовать ссылки.
- Используйте OnEnable для инициализации ScriptableObjects.
- Никогда не вызывайте конструктор объекта ScriptableObject, вместо этого используйте 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), нажав на кнопку 'Add Simple', Вы создадите экземпляр и добавите его в список. Благодаря тому, что класс SerializeMe правильно настроен для сериализации (как обсуждалось ранее), он "просто работает". Unity видит, что Список помечен для сериализации, и сериализует каждый из элементов Списка.
Общая сериализация массивов
Давайте изменим этот пример, чтобы сериализовать список, содержащий члены базового и дочернего классов:
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 будет удалена. Экземпляры подвергаются сортировке системой сериализации.
Способ обойти это ограничение системы сериализации - снова использовать ScriptableObjects:
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());
}
}
После выполнения этой операции, изменения некоторых значений и перезагрузки сборок Вы заметите, что ScriptableObjects безопасно использовать в массивах, даже если Вы сериализуете производные типы. Причина в том, что когда Вы сериализуете стандартный [Serializable] класс, он сериализуется "на месте", а ScriptableObject сериализуется извне, и ссылка на него вставляется в коллекцию. Измельчение происходит потому, что тип не может быть правильно сериализован, так как система сериализации считает, что он относится к базовому типу.
Сериализация абстрактных классов
Итак, теперь мы увидели, что можно сериализовать общий список (при условии, что его члены имеют тип 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, но это не рекомендуемая практика. Лично я считаю, что лучше использовать конкретные классы с пустыми виртуальными методами. Это гарантирует, что дела у Вас не пойдут плохо.
Когда объекты ScriptableObjects сохраняются в файлах сцены / префаба?
GameObjects и их компоненты по умолчанию сохраняются в сцене. Типы активов (Materials / Meshes / AnimationClip / SerializedObject's), которые создаются из кода, сохраняются в сцене до тех пор, пока на них ссылается игровой объект или их компоненты в сцене.
Типы активов также могут быть явно отмечены как активы с помощью AssetDatabase.CreateAsset. В этом случае они не будут сохранены в сцене, а просто на них будет сделана ссылка. Если тип актива или тип игрового объекта отмечен как HideAndDontSave, он также не сохраняется в сцене.
Вопросы?