Любой Unity-разработчик знаком с атрибутом [SerializeField], который позволяет сериализовывать непубличные члены класса и, соответственно, отображать их в инспекторе. Но, в силу его ограниченности, позже начали появляться и другие способы сериализации.

Попробую кратко рассказать, какие альтернативы используются, зачем все они нужны, как работают и, о чём не любят писать в кликбейтных постах, какие подводные камни могут скрывать.

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

  2. [SerializeField]

  3. [FormerlySerializedAs]

  4. Принудительная ресериализация

  5. [field: SerializeField]

  6. Неочевидная проблема [field: SerializeField]

  7. [SerializeReference]

  8. Неочевидная проблема [SerializeReference]

  9. Другие способы сериализации

  10. Заключение

Следить за выходом новых статей и другого контента можно в моём блоге на VK / Telegram Dtf.


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

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

В настройках Unity можно выбрать тип сериализации: Force Binary, Force Text или Mixed. В последнем случае новые ассеты будут сохраняться в бинарном формате, а старые — останутся в оригинальном текстовом виде без конвертации.

Binary — машиночитаемый и более эффективный, Text — человекочитаемый и дружелюбный для систем контроля версий типа Git. Про машиночитаемые и человекочитаемые форматы я ранее подробнее писал в материале по игровым сохранениям.

Пример изменений в файле формата Text
Пример изменений в файле формата Text
Пример содержания файла в формате Binary
Пример содержания файла в формате Binary

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

Режимы сериализации в Unity
Режимы сериализации в Unity

С этим режимом сериализации ассеты Unity становятся YAML и JSON файлами, с некоторыми особенностями от непосредственно Unity. Их можно открывать в любом текстовом редакторе и видеть там те же поля, что и в инспекторе для префабов, компонентов, конфигов и т. д.

Таким образом, все данные проекта представляют собой обычные текстовые файлы. А инспектор Unity — всего лишь удобный интерфейс для их редактирования. И, например, поправить данные в ScriptableObject можно не выходя из IDE.

При этом ссылки на какие-то ассеты сериализуются как guid'ы, которые можно найти в .meta-файле у каждого ассета.

Пример сериализации через guid
Пример сериализации через guid

Подробнее про структуру сериализованных ассетов можно почитать в статье 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]

Без такого маппинга данные просто будут потеряны. Они не удалятся (если принудительно не ресериализовать ассет повторно), но больше не будут считаны в нужное поле.

Состояние скрипта до переименования полей
Состояние скрипта до переименования полей
Состояние скрипта после переименования полей
Состояние скрипта после переименования полей

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

Пример автоматизации Rider
Пример автоматизации Rider

Более того, 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]

Неочевидная проблема [field: SerializeField]

Если обратить внимание на то, что было сериализовано в .prefab, то можно заметить, что способ сериаизации поля изменился: вместо ожидаемой записи Value: 5 появилось странное <Value>k__BackingField: 5.

Взглянув на то, во что такую запись разворачивает компилятор, становится понятно, почему запись стала выглядеть именно так:

Результат работы компилятора
Результат работы компилятора

Но что, если такое свойство переименовать на RenamedValue?

  1. Rider пока не умеет автоматически подставлять [FormerlySerializedAs] для [field: SerializeField].

  2. Добавление [FormerlySerializedAs("Value")] вручную не поможет.

  3. И даже [field: FormerlySerializedAs("Value")] не сработает.

В результате свойство будет переименовано, но Unity не сможет связать старое значение с новым полем, и записанные ранее данные "потеряются".

Решения:

  1. Вручную обновить в файле ассета имя для BackingField.

  2. Неочевидное и отсутствующее в документации Unity: использовать атрибут в подобном виде:

[field: FormerlySerializedAs("<Value>k__BackingField")]

Основная проблема: авто-рефакторинг. Для обычных полей уже существуют автоматизации в Rider, которые генерируют [FormerlySerializedAs] и не позволяют потерять заданные в ассетах данные.

Для сериализованных авто-свойств таких встроенных автоматизаций нет. Какой-то плагин наверняка можно найти или написать самостоятельно. Но большинство тех, на кого ориентированы призывы использовать [field: SerializeField] везде и всегда, наверняка таким не пользуются.

Соответственно, не имея какой-то автоматизации существует большой риск случайно в порыве рефакторинга где-то что-то неосторожно переименовать и потерять связь с сериализованными данными. Это неминуемо приведёт к некорректной работе проекта. При этом такие вещи довольно сложно заметить или отследить, особенно, если правки затронули много файлов.

[field: SerializeField] — это один из инструментов, который может помочь уменьшить объём кода там, где нужно конфигать много инкапсулированных данных. Нужно только помнить о рисках, которые он за собой скрывает, и о том, что он сериализует данные в ином формате, если в проекте приходится работать с raw-данными в файлах или писать кастомные инспекторы.


[SerializeReference]

Обычный [SerializeField] сериализует пользовательские классы путём встраивания их полей прямо в YAML-файл родителя. Это накладывает некоторые ограничения на использование.

1) Если два поля ссылаются на один и тот же объект, Unity всё равно сериализует их отдельно дважды. При загрузке эти поля станут независимыми, и изменения в одном не повлияют на другой.

Попытка сериализовать один объект в двух полях через [SerializeField]
Попытка сериализовать один объект в двух полях через [SerializeField]

Исключение: ссылка на Unity Component или Unity Object. В этом случае Unity сериализует просто guid, и оба поля будут ссылаться на один и тот же объект.

Сериализация одной ссылки на Unity Component в двух полях
Сериализация одной ссылки на Unity Component в двух полях

2) Если поле объявлено как базовый класс, Unity сериализует только те данные, которые определены в этом базовом классе. Поля, добавленные в наследнике, игнорируются. Также не поддерживаются абстрактные базовые классы.

Попытка сериализовать наследника в поле с базовым типом через [SerializeField]
Попытка сериализовать наследника в поле с базовым типом через [SerializeField]

Исключение: ссылка на Unity Component или Unity Object. Благодаря guid, Unity может найти нужный объект и загрузить все его актуальные данные из отдельного ассета. В т.ч. это работает даже для абстрактных базовых компонентов.

Для обхода этих (и ряда других) ограничений для обычных классов был добавлен атрибут [SerializeReference]. Он позволяет сериализовать объект как ссылку, сохраняя его данные в отдельном блоке. Т.е. по сути та же идея, что работала для Unity Component, только для обычных классов, и их данные хранятся в том же файле, где и ссылка.

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

Единственное, для связи ссылки с данными вместо guid, как у Unity Component, используется rid (Resource Identifier).

Пример сериализации через [SerializeReference]
Пример сериализации через [SerializeReference]

При этом [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]

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

Поэтому, не имея каких-то помогаторов, стоит с осторожностью применять [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 &lt; keys.Count; i++)
        {
            this[keys[i]] = values[i];
        }
    }
}

С точки зрения сериализации здесь не появляется ничего нового. Актуально будет всё, что было отмечено выше. Но т.к. типы кастомные, то и проблемы кастомные тоже имеют место быть.

Даже в примере выше можно обратить внимание на дополнительно использованные методы Clear(), забыв которые, сериализация будет работать нестабильно.

Также существуют сторонние сериализаторы и плагины, которые позволяют расширять стандартные возможности Unity. Это Odin и прочие аналоги, про которые я ранее писал в своём блоге.

Например, Odin позволяет сериализовывать и рисовать словари, предоставляет возможность выбирать конкретную реализацию для базового класса или интерфейса прямо в редакторе и множество других полезных функций.

Пример сериализации через Odin
Пример сериализации через Odin

У Odin есть несколько режимов сериализации, они тоже разделяются на машиночитаемые и человекочитаемые. Работают они схожим образом и имеют схожие болячки — чувствительность к ренеймингу и автоматизированному рефакторингу.


Заключение

При всей привлекательности современных механизмов сериализации, таких как [field: SerializeField] и [SerializeReference], классический [SerializeField] остаётся самым простым, понятным и безопасным способом работы с данными в Unity.

С ним возникает меньше всего хлопот: он прост в сериализации, легко читаем в raw-формате, не имеет мета-данных, для него проще писать кастомные инспекторы и работа с ним уже автоматизирована "из коробки" хотя бы в Rider.

Все остальные способы и сторонние плагины хороши по-своему и помогают решить проблемы, которые [SerializeField] одолеть не способен или при использовании доставляет много неудобств. Но их использование сопровождается бóльшими рисками при поддержке и рефакторинге.

Если на проекте нет инструментов по автоматизации или валидации разного рода рефакторинга, влияющего на сериализуемые данные, я бы не рекомендовал широко использовать отличные от [SerializeField] способы там, где он вполне справляется с возложенными на него обязанностями.

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

Комментарии (0)