Сериализация в Unity: известные атрибуты и их проблемы
Любой Unity-разработчик знаком с атрибутом [SerializeField], который позволяет сериализовывать непубличные члены класса и, соответственно, отображать их в инспекторе. Но, в силу его ограниченности, позже начали появляться и другие способы сериализации.
Попробую кратко рассказать, какие альтернативы используются, зачем все они нужны, как работают и, о чём не любят писать в кликбейтных постах, какие подводные камни могут скрывать.
Сериализация в 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.
[FormerlySerializedAs]
Этот атрибут применяется, в основном, при переименовании сериализованного поля, чтобы смаппить новое имя поля с тем, которое было сериализовано в файл ранее, и не потерять записанные данные:
Без такого маппинга данные просто будут потеряны. Они не удалятся (если принудительно не ресериализовать ассет повторно), но больше не будут считаны в нужное поле.
В Rider подстановку [FormerlySerializedAs] можно автоматизировать:
Более того, Rider при переименовании какой-то сущности умеет переименовывать связанные с этой другие сущности, среди которых могут оказаться и сериализуемые поля. И для них он тоже может автоматически сгенерировать [FormerlySerializedAs].
Такая автоматизация делает безопасным рефакторинг кода — данные случайно не потеряются. Кроме того, при просмотре Git-коммитов легко заметить сгенерированные атрибуты, от которых можно будет оперативно избавиться и актуализировать все затронутые ассеты.
Принудительная ресериализация
Вместо ручной актуализации, можно запустить ресериализацию ассета, при которой все названия заменятся на актуальные, а устаревшие неиспользуемые поля будут удалены. Периодическая ресериализация помогает подчищать ассеты от всякого накопившегося мусора.
Также иногда Unity временно кэширует внесённые через инспектор правки и сериализует их не сразу. И за это время вы можете успеть запушить коммит без актуальных данных. Ресериализация решает эту проблему, принудительно записывая все изменения в файлы.
Для этого используется метод AssetDatabase.ForceReserializeAssets. Почему-то Unity, учитывая описанные выше тонкости, не стали добавлять его в редактор, а оставили только на уровне API.
Благо, это легко исправить написанием простого инструмента:
После ресериализации можно будет удалить потерявшие актуальность атрибуты [FormerlySerializedAs].
[field: SerializeField]
Но что, если необходимо сделать поле видимым в инспекторе и доступным для чтения извне, но при этом защищённым от внешнего изменения в коде?
Простой способ, которым долгое время и обходились:
Аналогичное можно оформить в виде свойства:
Следующим логичным шагом по упрощению записи могло быть авто-свойство:
Но [SerializeField] можно применить только к полю. Т.е. такая запись не даст желаемого эффекта: авто-свойство не будет сериализовано и не появится в инспекторе.
Однако авто-свойство — всего лишь синтаксический сахар, который компилятор превратит в отдельное поле с get и set методами, как это было в первом примере.
Т.е. по сути, поле как-таковое всё же имеется. И было бы здорово уметь применять атрибуты к этому полю.
Спецификатор (attribute target specifier) field как раз позволяет сказать компилятору: "Примени атрибут к автоматически созданному полю, которое лежит за этим авто-свойством".
Таким образом в записи
атрибут [SerializeField] будет применён не к свойству, а к скрытому полю, которое генерируется автоматически для этого свойства. И мы получим тот самый эффект, которого желаем добиться:
Неочевидная проблема [field: SerializeField]
Если обратить внимание на то, что было сериализовано в .prefab, то можно заметить, что способ сериаизации поля изменился: вместо ожидаемой записи Value: 5 появилось странное <Value>k__BackingField: 5.
Взглянув на то, во что такую запись разворачивает компилятор, становится понятно, почему запись стала выглядеть именно так:
Но что, если такое свойство переименовать на RenamedValue ?
- Rider пока не умеет автоматически подставлять [FormerlySerializedAs] для [field: SerializeField].
- Добавление [FormerlySerializedAs("Value")] вручную не поможет.
- И даже [field: FormerlySerializedAs("Value")] не сработает.
В результате свойство будет переименовано, но Unity не сможет связать старое значение с новым полем, и записанные ранее данные "потеряются".
Решения:
- Вручную обновить в файле ассета имя для BackingField.
- Неочевидное и отсутствующее в документации Unity: использовать атрибут в подобном виде:
Основная проблема: авто-рефакторинг. Для обычных полей уже существуют автоматизации в Rider, которые генерируют [FormerlySerializedAs] и не позволяют потерять заданные в ассетах данные.
Для сериализованных авто-свойств таких встроенных автоматизаций нет. Какой-то плагин наверняка можно найти или написать самостоятельно. Но большинство тех, на кого ориентированы призывы использовать [field: SerializeField] везде и всегда, наверняка таким не пользуются.
Соответственно, не имея какой-то автоматизации существует большой риск случайно в порыве рефакторинга где-то что-то неосторожно переименовать и потерять связь с сериализованными данными. Это неминуемо приведёт к некорректной работе проекта. При этом такие вещи довольно сложно заметить или отследить, особенно, если правки затронули много файлов.
[field: SerializeField] — это один из инструментов, который может помочь уменьшить объём кода там, где нужно конфигать много инкапсулированных данных. Нужно только помнить о рисках, которые он за собой скрывает, и о том, что он сериализует данные в ином формате, если в проекте приходится работать с raw-данными в файлах или писать кастомные инспекторы.
[SerializeReference]
Обычный [SerializeField] сериализует пользовательские классы путём встраивания их полей прямо в YAML-файл родителя. Это накладывает некоторые ограничения на использование.
1) Если два поля ссылаются на один и тот же объект, Unity всё равно сериализует их отдельно дважды. При загрузке эти поля станут независимыми, и изменения в одном не повлияют на другой.
Исключение: ссылка на Unity Component или Unity Object. В этом случае Unity сериализует просто guid, и оба поля будут ссылаться на один и тот же объект.
2) Если поле объявлено как базовый класс, Unity сериализует только те данные, которые определены в этом базовом классе. Поля, добавленные в наследнике, игнорируются. Также не поддерживаются абстрактные базовые классы.
Исключение: ссылка на Unity Component или Unity Object. Благодаря guid, Unity может найти нужный объект и загрузить все его актуальные данные из отдельного ассета. В т.ч. это работает даже для абстрактных базовых компонентов.
Для обхода этих (и ряда других) ограничений для обычных классов был добавлен атрибут [SerializeReference]. Он позволяет сериализовать объект как ссылку, сохраняя его данные в отдельном блоке. Т.е. по сути та же идея, что работала для Unity Component, только для обычных классов, и их данные хранятся в том же файле, где и ссылка.
Соответственно, это позволяет как ссылаться каждому полю на один и тот же участок блока, так и сериализовывать уникальные для каждого наследника данные.
Единственное, для связи ссылки с данными вместо guid, как у Unity Component, используется rid (Resource Identifier).
При этом [FormerlySerializedAs] нормально работает как для самих [SerializeReference] полей, так и для полей внутри их объектов. И Rider даже умеет автоматически их подставлять.
Неочевидная проблема [SerializeReference]
При использовании [SerializeReference] Unity сериализует не только данные объекта, но и мета-данные класса: className, namespace и assemblyName. При этом сериализуются они обычными строками.
Соответственно, если будет переименован class, namespace или его assembly, то маппинг собьётся и данные потеряются.
Если class можно пометить комментарием "!!! не переименовывать !!!" и обложить валидациями, то с namepsace и asmdef будет чуточку сложнее, т.к. внутри них находится множество других классов, файлов и пр.
Например, нажать в Rider на какой-нибудь Adjust Namespaces, даже не видя проблемного класса, очень просто. А своевременно обнаружить образовавшуюся проблему — нет.
Если переименование всё же случилось, нужно или обновить мета-данные в ассете, или воспользоваться атрибутом
который работает аналогично [FormerlySerializedAs], но для мета-данных класса:
В этом случае всё тоже упирается в отсутствие должных автоматизаций и валидаций, которые бы могли подстраховать и уберечь от подобных неосторожных ошибок.
Поэтому, не имея каких-то помогаторов, стоит с осторожностью применять [SerializeRefernce] и позаботиться об информировании своей команды, что те или иные области нужно переименовывать внимательно и желательно через полнотекстовый поиск. А лучше и вовсе лишний раз не трогать.
Другие способы сериализации
У Unity есть интерфейс ISerializationCallbackReceiver, позволяющий сериализовывать кастомные структуры данных, которые по умолчанию не поддерживаются Unity.
Пример словаря из статьи Serialization in Unity:
С точки зрения сериализации здесь не появляется ничего нового. Актуально будет всё, что было отмечено выше. Но т.к. типы кастомные, то и проблемы кастомные тоже имеют место быть.
Даже в примере выше можно обратить внимание на дополнительно использованные методы Clear(), забыв которые, сериализация будет работать нестабильно.
Также существуют сторонние сериализаторы и плагины, которые позволяют расширять стандартные возможности Unity. Это Odin и прочие аналоги, про которые я ранее писал в своём блоге.
Например, Odin позволяет сериализовывать и рисовать словари, предоставляет возможность выбирать конкретную реализацию для базового класса или интерфейса прямо в редакторе и множество других полезных функций.
У Odin есть несколько режимов сериализации, они тоже разделяются на машиночитаемые и человекочитаемые. Работают они схожим образом и имеют схожие болячки — чувствительность к ренеймингу и автоматизированному рефакторингу.
Заключение
При всей привлекательности современных механизмов сериализации, таких как [field: SerializeField] и [SerializeReference], классический [SerializeField] остаётся самым простым, понятным и безопасным способом работы с данными в Unity.
С ним возникает меньше всего хлопот: он прост в сериализации, легко читаем в raw-формате, не имеет мета-данных, для него проще писать кастомные инспекторы и работа с ним уже автоматизирована "из коробки" хотя бы в Rider.
Все остальные способы и сторонние плагины хороши по-своему и помогают решить проблемы, которые [SerializeField] одолеть не способен или при использовании доставляет много неудобств. Но их использование сопровождается бóльшими рисками при поддержке и рефакторинге.
Если на проекте нет инструментов по автоматизации или валидации разного рода рефакторинга, влияющего на сериализуемые данные, я бы не рекомендовал широко использовать отличные от [SerializeField] способы там, где он вполне справляется с возложенными на него обязанностями.
Чем больше проект, чем больше в нём задействовано людей, чем масштабнее ротация кадров, тем выше шансы, что проблемы с рассогласованием данных будут появляться всё чаще. И безосновательное использование более сложных и менее контролируемых инструментов может только усугубить эту ситуацию.