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

LUCAS MEIJER / UNITY TECHNOLOGIESContributor
Jun 24, 2014|11 Мин
Сериализация в Unity
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

В духе стремления рассказать о технологиях за кулисами и причинах, по которым некоторые вещи работают именно так, как они работают, этот пост содержит обзор системы сериализации Unity. Хорошее понимание этой системы может оказать большое влияние на эффективность Вашей разработки и производительность вещей, которые Вы производите. Вот так.

Сериализация "вещей" лежит в самой основе Unity. Многие из наших функций построены на основе системы сериализации:

  • Хранение данных, хранящихся в Ваших скриптах. Большинство людей, вероятно, немного знакомы с этой темой.
  • Окно инспектора. Окно инспектора не обращается к C# api, чтобы выяснить значения свойств того, что он проверяет. Он просит объект сериализовать себя, а затем отображает сериализованные данные.
  • Префабс. Внутри префаб представляет собой сериализованный поток данных одного (или нескольких) игровых объектов и компонентов. Экземпляр prefab - это список модификаций, которые должны быть сделаны с сериализованными данными для этого экземпляра. Понятие prefab на самом деле существует только в редакторе. Модификации префаба встраиваются в обычный поток сериализации, когда Unity делает сборку, и когда он инстанцируется, инстанцированные GameObjects даже не подозревают, что они были префабом, когда жили в редакторе.
  • Инстанция. Когда Вы вызываете Instantiate() для префаба, или игрового объекта, который находится в сцене, или для чего-либо еще (все, что происходит от UnityEngine.Object, может быть сериализовано), мы сериализуем объект, затем создаем новый объект, а затем "десериализуем" данные на новом объекте. (Затем мы снова запускаем тот же код сериализации в другом варианте, где мы используем его, чтобы сообщить, на какие другие объекты UnityEngine.Object's ссылаются. Затем мы проверяем все ссылающиеся объекты UnityEngine.Object, если они являются частью данных Instantiated(). Если ссылка указывает на что-то "внешнее" (например, на текстуру), мы сохраняем эту ссылку как есть, если же она указывает на что-то "внутреннее" (например, на дочерний GameObjects), мы исправляем ссылку на соответствующую копию).
  • Экономия. Если Вы открыли файл сцены .unity в текстовом редакторе и установили для unity значение "принудительная сериализация текста", мы запустим сериализатор с помощью yaml-бэкенда.
  • Загрузка. Это может показаться неудивительным, но загрузка с обратной совместимостью - это система, которая также построена на сериализации. Загрузка yaml в редакторе использует систему сериализации, так же как и загрузка сцен и активов во время выполнения. Пучки активов также используют систему сериализации.
  • Горячая перезагрузка кода редактора. Когда Вы изменяете сценарий редактора, мы сериализуем все окна редактора (они происходят от UnityEngine.Object!), затем уничтожаем все окна, выгружаем старый код c#, загружаем новый код c#, заново создаем окна и, наконец, десериализуем потоки данных окон обратно в новые окна.
  • Resource.GarbageCollectSharedAssets(). Это наш родной сборщик мусора, который отличается от сборщика мусора C#. Это та штука, которую мы запускаем после загрузки сцены, чтобы выяснить, на какие вещи из предыдущей сцены больше нет ссылок, и выгрузить их. Собственный сборщик мусора запускает сериализатор в режиме, в котором мы используем его для того, чтобы объекты сообщали обо всех ссылках на внешние объекты UnityEngine.Objects. Именно благодаря этому текстуры, которые использовались в сцене1, выгружаются при загрузке сцены2.

Система сериализации написана на C++, мы используем ее для всех наших внутренних типов объектов (Текстуры, AnimationClip, Камера и т.д.). Сериализация происходит на уровне UnityEngine.Object, каждый UnityEngine.Object всегда сериализуется как единое целое. Они могут содержать ссылки на другие объекты UnityEngine.Objects, и эти ссылки будут сериализованы должным образом.

Теперь Вы можете сказать, что все это Вас не очень волнует, Вы просто счастливы, что это работает, и хотите приступить к созданию контента. Однако это не должно Вас волновать, поскольку мы используем этот же сериализатор для сериализации компонентов MonoBehaviour, которые поддерживаются Вашими скриптами. Из-за очень высоких требований к производительности, предъявляемых к сериализатору, он не во всех случаях ведет себя именно так, как ожидает разработчик C# от сериализатора. Здесь мы расскажем о том, как работает сериализатор, а также о некоторых лучших практиках, как использовать его наилучшим образом.

Каким должно быть поле моего скрипта, чтобы его можно было сериализовать?

  • Быть общедоступным или иметь атрибут [SerializeField].
  • Не быть статичным
  • Не быть постоянным
  • Не быть доступным для чтения
  • Fieldtype должен быть такого типа, который мы можем сериализовать.

Какие типы полей мы можем сериализовать?

  • Пользовательские не абстрактные классы с атрибутом [Serializable].
  • Пользовательские структуры с атрибутом [Serializable]. (новое в Unity4.5)
  • Ссылки на объекты, производные от UntiyEngine.Object
  • Примитивные типы данных (int, float, double, bool, string и т.д.)
  • Массив типа поля, который мы можем сериализовать
  • Список<T> типов полей, которые мы можем сериализовать

Пока все хорошо. Так что же это за ситуации, в которых сериализатор ведет себя не так, как я ожидаю?

Пользовательские классы ведут себя как структуры

[Serializable]
класс Животное
{
public string name;
}

class MyScript : MonoBehaviour
{
public Животные[] животные;
}

Если Вы заполните массив animals тремя ссылками на один объект Animal, в потоке сериализации Вы найдете 3 объекта. При десериализации теперь есть три разных объекта. Если Вам нужно сериализовать сложный граф объектов со ссылками, Вы не можете полагаться на то, что сериализатор Unity сделает это автоматически за Вас, и должны будете проделать определенную работу, чтобы сериализовать этот граф объектов самостоятельно. Смотрите пример ниже о том, как сериализовать то, что Unity не сериализует самостоятельно.

Обратите внимание, что это справедливо только для пользовательских классов, поскольку они сериализуются "в линию", так как их данные становятся частью полной сериализации данных для MonoBehaviour, в котором они используются. Когда у Вас есть поля, содержащие ссылку на что-то, являющееся производным классом UnityEngine.Object, например, "public Camera myCamera", данные из этой камеры не сериализуются inline, а сериализуется фактическая ссылка на камеру UnityEngine.Object.

Нет поддержки null для пользовательских классов

Викторина. Сколько выделений будет сделано при десериализации MonoBehaviour, использующего этот скрипт:

класс Тест : MonoBehaviour
{
public Trouble t;
}

[Serializable]
класс Неприятности
{
public Trouble t1;
public Trouble t2;
public Trouble t3;
}

Не странно было бы ожидать, что будет выделено только одно место, то есть объект Test. Также не будет странным ожидать 2 выделения, одно для объекта Test и одно для объекта Trouble. Правильный ответ - 729. Сериализатор не поддерживает null. Если при сериализации объекта какое-то поле оказывается null, мы просто создаем новый объект этого типа и сериализуем его. Очевидно, что это может привести к бесконечным циклам, поэтому мы установили относительно волшебный предел глубины в 7 уровней. В этот момент мы просто перестаем сериализовать поля, имеющие типы пользовательских классов/структур, списков и массивов. [1]

Поскольку многие наши подсистемы строятся на основе системы сериализации, этот неожиданно большой поток сериализации для моноповедения Test заставит все эти подсистемы работать медленнее, чем нужно. Когда мы исследуем проблемы производительности в проектах клиентов, мы почти всегда находим эту проблему, и мы добавили предупреждение для этой ситуации в Unity 4.5. На самом деле мы испортили реализацию предупреждений таким образом, что они выдают Вам столько предупреждений, что у Вас не остается другого выбора, кроме как сразу же их исправить. Мы скоро выпустим исправление этого в патче, предупреждение не исчезнет, но Вы будете получать только одно предупреждение за "вход в игровой режим", так что Вас не будут забрасывать спамом. Вы все равно захотите исправить свой код, но у Вас должна быть возможность сделать это в удобное для Вас время.

Нет поддержки полиморфизма

Если у Вас есть

public Животные[] животные

Если Вы поместите в него экземпляры собаки, кошки и жирафа, то после сериализации у Вас будет три экземпляра Animal.

Один из способов справиться с этим ограничением - понять, что оно применимо только к "пользовательским классам", которые сериализуются inline. Ссылки на другие объекты UnityEngine.Object's сериализуются как реальные ссылки, и для них полиморфизм действительно работает. Вы создадите производный класс ScriptableObject или другой производный класс MonoBehaviour и будете ссылаться на него. Недостатком этого способа является то, что Вам нужно где-то хранить этот объект MonoBehaviour или scriptable и Вы не можете сериализовать его в режиме inline.

Причина этих ограничений заключается в том, что одна из основ системы сериализации заключается в том, что схема потока данных для объекта известна заранее и зависит от типов полей класса, а не от того, что хранится в этих полях.

Я хочу сериализовать что-то, что сериализатор Unity не поддерживает. Что мне делать?

Во многих случаях лучшим подходом является использование обратных вызовов сериализации. Они позволяют Вам получать уведомления до того, как сериализатор прочитает данные из Ваших полей, и после того, как он завершит запись в них. Вы можете использовать это для того, чтобы иметь другое представление Ваших трудносериализуемых данных во время выполнения программы, чем во время сериализации. Вы используете их для преобразования Ваших данных в то, что Unity понимает, непосредственно перед тем, как Unity захочет их сериализовать. Вы также используете их для преобразования сериализованной формы обратно в форму, в которой Вы хотели бы видеть Ваши данные во время выполнения, сразу после того, как Unity записала данные в Ваши поля.

Допустим, Вы хотите иметь древовидную структуру данных. Если Вы позволите Unity напрямую сериализовать структуру данных, ограничение "no support for null" приведет к тому, что Ваш поток данных станет очень большим, что приведет к снижению производительности во многих системах:

используя UnityEngine;
Используя System.Collections.Generic;
Используя System;

public class VerySlowBehaviourDoNotDoThis : MonoBehaviour
{
[Serializable]
public class Node
{
public string interestingValue = "значение";

//Поле ниже - это то, из-за чего данные сериализации становятся огромными, потому что
//это вводит "цикл класса".
public List<Node> children = new List<Node>();
}

//это будет сериализовано
public Node root = new Node();

void OnGUI()
{
Дисплей (корень);
}

void Display(Node node)
{
GUILayout.Label ("Значение: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));

GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();

foreach (var child in node.children)
Дисплей (ребенок);

if (GUILayout.Button ("Добавить ребенка"))
node.children.Add (новый узел ());

GUILayout.EndVertical ();
GUILayout.EndHorizontal ();
}
}

Вместо этого Вы говорите Unity не сериализовать дерево напрямую, а создаете отдельное поле для хранения дерева в сериализованном формате, подходящем для сериализатора Unity:

используя UnityEngine;
Используя System.Collections.Generic;
Используя System;

public class BehaviourWithTree : MonoBehaviour, ISerializationCallbackReceiver
{
// класс узла, который используется во время выполнения
public class Node
{
public string interestingValue = "значение";
public List<Node> children = new List<Node>();
}

// класс узла, который мы будем использовать для сериализации
[Serializable]
public struct SerializableNode
{
public string interestingValue;
public int ChildCount;
public int indexOfFirstChild;
}

//Корень того, что мы используем во время выполнения. не сериализован.
Node root = new Node();

//поле, которое мы передаем Unity для сериализации.
public List<SerializableNode> serializedNodes;

public void OnBeforeSerialize()
{
// Unity собирается прочитать содержимое сериализованного поляNodes. Давайте убедимся в этом.
//Мы записываем правильные данные в это поле "точно в срок".
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 (дочерний);
}

public void OnAfterDeserialize()
{
//Unity только что записала новые данные в поле serializedNodes.
//Давайте заполним наши фактические данные времени выполнения этими новыми значениями.

if (serializedNodes.Count > 0)
root = ReadNodeFromSerializedNodes (0);
else
root = новый Узел ();
}

Node ReadNodeFromSerializedNodes(int index)
{
var serializedNode = serializedNodes [index];
var children = новый список 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 ("Значение: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));

GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();

foreach (var child in node.children)
Дисплей (ребенок);

if (GUILayout.Button ("Добавить ребенка"))
node.children.Add (новый узел ());

GUILayout.EndVertical ();
GUILayout.EndHorizontal ();
}
}

Имейте в виду, что сериализатор, включая обратные вызовы, поступающие от сериализатора, обычно не выполняются в основном потоке, поэтому Вы очень ограничены в том, что Вы можете делать в плане вызова Unity API. (Сериализация, происходящая в процессе загрузки сцены, происходит в потоке загрузки. Сериализация происходит в процессе вызова Instantiate() из скрипта в главном потоке). Однако Вы можете выполнить необходимые преобразования данных, чтобы перевести их из формата, не подходящего для сериализатора Unity, в формат, подходящий для сериализатора Unity.

Вы дошли до конца!

Спасибо, что дочитали до этого места, надеюсь, что Вы сможете использовать эту информацию в своих проектах.

Пока, Лукас.(@lucasmeijer)

PS: Мы также добавим всю эту информацию в документацию.

[1] Я солгал, правильный ответ на самом деле не 729. Это потому, что в очень старые времена, до того, как у нас появилось это ограничение глубины в 7 уровней, Unity просто бесконечно зацикливалась и заканчивала память, если Вы создавали скрипт, подобный тому, что я только что написал в Trouble. Наше самое первое исправление этой проблемы 5 лет назад заключалось в том, чтобы просто не сериализовать типы полей, которые имеют тот же тип, что и сам класс. Очевидно, что это не самое надежное исправление, поскольку легко создать цикл, используя класс Trouble1->Trouble2->Trouble1->Trouble2. Поэтому вскоре после этого мы ввели ограничение глубины в 7 уровней, чтобы учесть и эти случаи. Для того, что я пытаюсь сказать, это не имеет значения, важно то, что Вы понимаете, что если существует цикл, то у Вас проблемы.