
Любой Unity-разработчик знаком с атрибутом [SerializeField]
, который позволяет сериализовывать непубличные члены класса и, соответственно, отображать их в инспекторе. Но, в силу его ограниченности, позже начали появляться и другие способы сериализации.
Попробую кратко рассказать, какие альтернативы используются, зачем все они нужны, как работают и, о чём не любят писать в кликбейтных постах, какие подводные камни могут скрывать.
Следить за выходом новых статей и другого контента можно в моём блоге на VK / Telegram / Dtf.
Сериализация в Unity
В Unity реализован собственный механизм сериализации, предназначенный для персистентного хранения данных объектов в виде ассетов.
В настройках Unity можно выбрать тип сериализации: Force Binary
, Force Text
или Mixed
. В последнем случае новые ассеты будут сохраняться в бинарном формате, а старые — останутся в оригинальном текстовом виде без конвертации.
Binary
— машиночитаемый и более эффективный, Text
— человекочитаемый и дружелюбный для систем контроля версий типа Git. Про машиночитаемые и человекочитаемые форматы я ранее подробнее писал в материале по игровым сохранениям.


Раньше Binary
был стандартным форматом, и приходилось для подключения Git вручную переключать настройки на Text
. Сегодня в Unity по умолчанию выбирается Text
.

С этим режимом сериализации ассеты Unity становятся YAML
и JSON
файлами, с некоторыми особенностями от непосредственно Unity. Их можно открывать в любом текстовом редакторе и видеть там те же поля, что и в инспекторе для префабов, компонентов, конфигов и т. д.
Таким образом, все данные проекта представляют собой обычные текстовые файлы. А инспектор Unity — всего лишь удобный интерфейс для их редактирования. И, например, поправить данные в ScriptableObject
можно не выходя из IDE.
При этом ссылки на какие-то ассеты сериализуются как guid
'ы, которые можно найти в .meta
-файле у каждого ассета.

Подробнее про структуру сериализованных ассетов можно почитать в статье Understanding Unity’s serialization language, YAML.
[SerializeField]
По умолчанию Unity сериализует все публичные поля поддерживаемых типов.
Однако не всегда нужно, чтобы публичное поле сериализовалось. В таких случаях используется атрибут [System.NonSerialized]
, который исключает поле из процесса сериализации.
Также существует более известный в среде Unity атрибут [UnityEngine.HideInInspector]
. Он просто скрывает поле из инспектора, но поле продолжает сериализовываться.
Если нужно сериализовать непубличное поле, то для этого используется атрибут [SerializeField]
.
Коротко, для чего [SerializeField]
может потребоваться: для хранения предустановленных данных с вытекающей возможностью их редактирования в инспекторе, но с сохранением инкапсуляции на уровне кода.
Простыми словами: хотим редактировать поле в инспекторе, но не хотим делать его публичным — делаем [SerializeField] private
.
internal sealed class ExampleComponent : MonoBehaviour
{
public int Public_Serialized_ShownInInspector;
[HideInInspector] public int Public_Serialized_HiddenInInspector;
[NonSerialized] public int Public_NonSerialized_HiddenInInspector;
private float Private_NonSerialized_HiddenInInspector;
[SerializeField] private float Private_Serialized_ShownInInspector;
}
[FormerlySerializedAs]
Этот атрибут применяется, в основном, при переименовании сериализованного поля, чтобы смаппить новое имя поля с тем, которое было сериализовано в файл ранее, и не потерять записанные данные:
![Пример использования [FormerlySerializedAs] Пример использования [FormerlySerializedAs]](https://habrastorage.org/r/w780/getpro/habr/upload_files/750/b42/1c7/750b421c7c13bb5fe01bf0595a9ad4b0.png)
Без такого маппинга данные просто будут потеряны. Они не удалятся (если принудительно не ресериализовать ассет повторно), но больше не будут считаны в нужное поле.


В Rider подстановку [FormerlySerializedAs]
можно автоматизировать:

Более того, Rider при переименовании какой-то сущности умеет переименовывать связанные с этой другие сущности, среди которых могут оказаться и сериализуемые поля. И для них он тоже может автоматически сгенерировать [FormerlySerializedAs]
.
Такая автоматизация делает безопасным рефакторинг кода — данные случайно не потеряются. Кроме того, при просмотре Git-коммитов легко заметить сгенерированные атрибуты, от которых можно будет оперативно избавиться и актуализировать все затронутые ассеты.
Принудительная ресериализация
Вместо ручной актуализации, можно запустить ресериализацию ассета, при которой все названия заменятся на актуальные, а устаревшие неиспользуемые поля будут удалены. Периодическая ресериализация помогает подчищать ассеты от всякого накопившегося мусора.
Также иногда Unity временно кэширует внесённые через инспектор правки и сериализует их не сразу. И за это время вы можете успеть запушить коммит без актуальных данных. Ресериализация решает эту проблему, принудительно записывая все изменения в файлы.
Для этого используется метод AssetDatabase.ForceReserializeAssets. Почему-то Unity, учитывая описанные выше тонкости, не стали добавлять его в редактор, а оставили только на уровне API.
Благо, это легко исправить написанием простого инструмента:
internal static class ReserializationUtils
{
[MenuItem("Assets/? Reserialize")]
public static void ReserializeSelected() =>
ReserializeAssets(Selection.GetFiltered(typeof(Object), SelectionMode.DeepAssets));
public static void ReserializeAssets(Object[] assets)
{
IEnumerable assetPaths = assets.Select(AssetDatabase.GetAssetPath);
AssetDatabase.ForceReserializeAssets(assetPaths);
}
}

После ресериализации можно будет удалить потерявшие актуальность атрибуты [FormerlySerializedAs]
.
[field: SerializeField]
Но что, если необходимо сделать поле видимым в инспекторе и доступным для чтения извне, но при этом защищённым от внешнего изменения в коде?
Простой способ, которым долгое время и обходились:
[SerializeField] private int _privateValue;
public int GetValue() => _privateValue;
private void SetValue(int value) => _privateValue = value;
Аналогичное можно оформить в виде свойства:
[SerializeField] private int _privateValue;
public int Value
{
get => _privateValue;
private set => _privateValue = value;
}
Следующим логичным шагом по упрощению записи могло быть авто-свойство:
[SerializeField] public int Value { get; private set; }
Но [SerializeField]
можно применить только к полю. Т.е. такая запись не даст желаемого эффекта: авто-свойство не будет сериализовано и не появится в инспекторе.
Однако авто-свойство — всего лишь синтаксический сахар, который компилятор превратит в отдельное поле с get
и set
методами, как это было в первом примере.
Т.е. по сути, поле как-таковое всё же имеется. И было бы здорово уметь применять атрибуты к этому полю.
Спецификатор (attribute target specifier) field
как раз позволяет сказать компилятору: "Примени атрибут к автоматически созданному полю, которое лежит за этим авто-свойством".
Таким образом в записи
[field: SerializeField] public int Value { get; private set; }
атрибут [SerializeField]
будет применён не к свойству, а к скрытому полю, которое генерируется автоматически для этого свойства. И мы получим тот самый эффект, которого желаем добиться:
![Пример применения [field: SerializeField] Пример применения [field: SerializeField]](https://habrastorage.org/r/w780/getpro/habr/upload_files/3b7/1ea/99a/3b71ea99a6390c1d5dc7d715bbad6062.png)
Неочевидная проблема [field: SerializeField]
Если обратить внимание на то, что было сериализовано в .prefab
, то можно заметить, что способ сериаизации поля изменился: вместо ожидаемой записи Value: 5
появилось странное <Value>k__BackingField: 5
.
Взглянув на то, во что такую запись разворачивает компилятор, становится понятно, почему запись стала выглядеть именно так:

Но что, если такое свойство переименовать на RenamedValue
?
Rider пока не умеет автоматически подставлять
[FormerlySerializedAs]
для[field: SerializeField]
.Добавление
[FormerlySerializedAs("Value")]
вручную не поможет.И даже
[field: FormerlySerializedAs("Value")]
не сработает.
В результате свойство будет переименовано, но Unity не сможет связать старое значение с новым полем, и записанные ранее данные "потеряются".
Решения:
Вручную обновить в файле ассета имя для
BackingField
.Неочевидное и отсутствующее в документации Unity: использовать атрибут в подобном виде:
[field: FormerlySerializedAs("<Value>k__BackingField")]
Основная проблема: авто-рефакторинг. Для обычных полей уже существуют автоматизации в Rider, которые генерируют [FormerlySerializedAs]
и не позволяют потерять заданные в ассетах данные.
Для сериализованных авто-свойств таких встроенных автоматизаций нет. Какой-то плагин наверняка можно найти или написать самостоятельно. Но большинство тех, на кого ориентированы призывы использовать [field: SerializeField]
везде и всегда, наверняка таким не пользуются.
Соответственно, не имея какой-то автоматизации существует большой риск случайно в порыве рефакторинга где-то что-то неосторожно переименовать и потерять связь с сериализованными данными. Это неминуемо приведёт к некорректной работе проекта. При этом такие вещи довольно сложно заметить или отследить, особенно, если правки затронули много файлов.
[field: SerializeField]
— это один из инструментов, который может помочь уменьшить объём кода там, где нужно конфигать много инкапсулированных данных. Нужно только помнить о рисках, которые он за собой скрывает, и о том, что он сериализует данные в ином формате, если в проекте приходится работать с raw-данными в файлах или писать кастомные инспекторы.
[SerializeReference]
Обычный [SerializeField]
сериализует пользовательские классы путём встраивания их полей прямо в YAML
-файл родителя. Это накладывает некоторые ограничения на использование.
1) Если два поля ссылаются на один и тот же объект, Unity всё равно сериализует их отдельно дважды. При загрузке эти поля станут независимыми, и изменения в одном не повлияют на другой.
![Попытка сериализовать один объект в двух полях через [SerializeField] Попытка сериализовать один объект в двух полях через [SerializeField]](https://habrastorage.org/r/w780/getpro/habr/upload_files/059/074/f0b/059074f0b703361243d0182395ae5bc5.png)
Исключение: ссылка на Unity Component
или Unity Object
. В этом случае Unity сериализует просто guid
, и оба поля будут ссылаться на один и тот же объект.

2) Если поле объявлено как базовый класс, Unity сериализует только те данные, которые определены в этом базовом классе. Поля, добавленные в наследнике, игнорируются. Также не поддерживаются абстрактные базовые классы.
![Попытка сериализовать наследника в поле с базовым типом через [SerializeField] Попытка сериализовать наследника в поле с базовым типом через [SerializeField]](https://habrastorage.org/r/w780/getpro/habr/upload_files/0f5/68b/bcf/0f568bbcfe6ca5eb3da6e3e4199761a5.png)
Исключение: ссылка на Unity Component
или Unity Object
. Благодаря guid
, Unity может найти нужный объект и загрузить все его актуальные данные из отдельного ассета. В т.ч. это работает даже для абстрактных базовых компонентов.
Для обхода этих (и ряда других) ограничений для обычных классов был добавлен атрибут [SerializeReference]
. Он позволяет сериализовать объект как ссылку, сохраняя его данные в отдельном блоке. Т.е. по сути та же идея, что работала для Unity Component
, только для обычных классов, и их данные хранятся в том же файле, где и ссылка.
Соответственно, это позволяет как ссылаться каждому полю на один и тот же участок блока, так и сериализовывать уникальные для каждого наследника данные.
Единственное, для связи ссылки с данными вместо guid
, как у Unity Component
, используется rid
(Resource Identifier).
![Пример сериализации через [SerializeReference] Пример сериализации через [SerializeReference]](https://habrastorage.org/r/w780/getpro/habr/upload_files/bc9/a6d/05c/bc9a6d05ca8432656141fb683c1681da.png)
При этом [FormerlySerializedAs]
нормально работает как для самих [SerializeReference]
полей, так и для полей внутри их объектов. И Rider даже умеет автоматически их подставлять.
Неочевидная проблема [SerializeReference]
При использовании [SerializeReference]
Unity сериализует не только данные объекта, но и мета-данные класса: className
, namespace
и assemblyName
. При этом сериализуются они обычными строками.
Соответственно, если будет переименован class
, namespace
или его assembly
, то маппинг собьётся и данные потеряются.
Если class
можно пометить комментарием "!!! не переименовывать !!!" и обложить валидациями, то с namepsace
и asmdef
будет чуточку сложнее, т.к. внутри них находится множество других классов, файлов и пр.
Например, нажать в Rider на какой-нибудь Adjust Namespaces
, даже не видя проблемного класса, очень просто. А своевременно обнаружить образовавшуюся проблему — нет.

Если переименование всё же случилось, нужно или обновить мета-данные в ассете, или воспользоваться атрибутом
[MovedFrom(shudlAutoUpdateAPI, oldNamespace, oldAssembly, oldClass)]
который работает аналогично [FormerlySerializedAs]
, но для мета-данных класса:
![Пример использования атрибута [MovedFrom] Пример использования атрибута [MovedFrom]](https://habrastorage.org/r/w780/getpro/habr/upload_files/8ac/3de/531/8ac3de531392d99d510dcf3b4095af85.png)
В этом случае всё тоже упирается в отсутствие должных автоматизаций и валидаций, которые бы могли подстраховать и уберечь от подобных неосторожных ошибок.
Поэтому, не имея каких-то помогаторов, стоит с осторожностью применять [SerializeRefernce]
и позаботиться об информировании своей команды, что те или иные области нужно переименовывать внимательно и желательно через полнотекстовый поиск. А лучше и вовсе лишний раз не трогать.
Другие способы сериализации
У Unity есть интерфейс ISerializationCallbackReceiver, позволяющий сериализовывать кастомные структуры данных, которые по умолчанию не поддерживаются Unity.
Пример словаря из статьи Serialization in Unity:
[Serializable]
public class SerializableDictionary : Dictionary, ISerializationCallbackReceiver
{
[SerializeField] private List keys = new();
[SerializeField] private List values = new();
public void OnBeforeSerialize()
{
keys.Clear();
values.Clear();
foreach (KeyValuePair pair in this)
{
keys.Add(pair.Key);
values.Add(pair.Value);
}
}
public void OnAfterDeserialize()
{
this.Clear();
for (int i = 0; i < keys.Count; i++)
{
this[keys[i]] = values[i];
}
}
}
С точки зрения сериализации здесь не появляется ничего нового. Актуально будет всё, что было отмечено выше. Но т.к. типы кастомные, то и проблемы кастомные тоже имеют место быть.
Даже в примере выше можно обратить внимание на дополнительно использованные методы Clear()
, забыв которые, сериализация будет работать нестабильно.
Также существуют сторонние сериализаторы и плагины, которые позволяют расширять стандартные возможности Unity. Это Odin и прочие аналоги, про которые я ранее писал в своём блоге.
Например, Odin позволяет сериализовывать и рисовать словари, предоставляет возможность выбирать конкретную реализацию для базового класса или интерфейса прямо в редакторе и множество других полезных функций.

У Odin есть несколько режимов сериализации, они тоже разделяются на машиночитаемые и человекочитаемые. Работают они схожим образом и имеют схожие болячки — чувствительность к ренеймингу и автоматизированному рефакторингу.
Заключение
При всей привлекательности современных механизмов сериализации, таких как [field: SerializeField]
и [SerializeReference]
, классический [SerializeField]
остаётся самым простым, понятным и безопасным способом работы с данными в Unity.
С ним возникает меньше всего хлопот: он прост в сериализации, легко читаем в raw-формате, не имеет мета-данных, для него проще писать кастомные инспекторы и работа с ним уже автоматизирована "из коробки" хотя бы в Rider.
Все остальные способы и сторонние плагины хороши по-своему и помогают решить проблемы, которые [SerializeField]
одолеть не способен или при использовании доставляет много неудобств. Но их использование сопровождается бóльшими рисками при поддержке и рефакторинге.
Если на проекте нет инструментов по автоматизации или валидации разного рода рефакторинга, влияющего на сериализуемые данные, я бы не рекомендовал широко использовать отличные от [SerializeField]
способы там, где он вполне справляется с возложенными на него обязанностями.
Чем больше проект, чем больше в нём задействовано людей, чем масштабнее ротация кадров, тем выше шансы, что проблемы с рассогласованием данных будут появляться всё чаще. И безосновательное использование более сложных и менее контролируемых инструментов может только усугубить эту ситуацию.