
И прежде чем погрузиться в мир SerializeReference, я рекомендую ознакомиться с документацией от самих Unity.
Там хорошо описаны базовые особенности и назначение этой функции. Вот часть пунктов, почему стоит попробовать SerializeReference:
You want multiple references to the same instance of a custom serializable class.
(Сомнительно, но окей)You want to use polymorphism on fields whose type is a custom serializable class.
(Настоящая киллер-фича)You want to serialize null values.
(Скорее дополнение ко второму пункту - как значение по умолчанию)
И ещё один пункт оттуда же:
By-value serialization is more efficient than using SerializeReference…
Если кратко: SerializeReference в первую очередь стоит использовать ради удобства и гибкости архитектуры. Производительность здесь - не главный приоритет.
И вот где SerializeReference действительно может показать себя неплохо:
всевозможные сервисы (загрузка/сохранение данных, обёртки над сторонними плагинами, платформозависимые вещи и т. д.);
базовая логика экранов UI (uGUI). Экраны обычно представлены в ограниченном количестве, а логики в них может быть довольно много и самой разной.
Однако в игровой логике или в высоконагруженных частях проекта ссылки стоит использовать с осторожностью. В играх легко может получиться так, что из 10 юнитов станет 1000 - SerializeReference рискует превратиться в бутылочное горлышко.
Теперь можно познакомиться поближе. Все примеры доступны в репозитории.
Пример 1. Serialize Reference - базовый полиморфизм
Для начала стоит посмотреть, как это работает на практике.
public class TestBehaviour : MonoBehaviour
{
[SerializeField] private IShape _shape = new BigCircle();
[SerializeField] private Circle _circle;
[SerializeReference] private IShape _shapeRef = new Circle();
}

В результате мы видим, что первый вариант даже не сработал. Вполне ожидаемо, ведь сериализация интерфейсов в Unity доступна только при использовании SerializeReference.
«Из коробки» это выглядит максимально ограниченно и не слишком удобно в использовании. К счастью, уже существует множество решений, которые расширяют возможности SerializeReference, в том числе добавляя удобный выбор типа. В качестве примера возьму свой пекейдж :)

Ещё один приятный бонус ссылок: выделяя базовую логику её можно выносить в отдельные модули.

Что же под капотом?
Если взглянуть на YAML, разница становится более заметной (разумеется, в настройках проекта должна быть включена сериализация Force Text).

При использовании SerializeReference на месте значения остаётся только rid(1), а само значение хранится в отдельном блоке references(2).
Кроме того, тип записывается в виде строки, а значит любое переименование типа может сломать существующие ссылки :(
Чтобы избежать этого, существует специальный атрибут MovedFrom
public MovedFromAttribute(
bool autoUpdateAPI,
string sourceNamespace = null,
string sourceAssembly = null,
string sourceClassName = null)
Сам кейс с переименованием выглядит не слишком привлекательно, но вот возможные варианты переименования типа:
Вариант 1: добавить только MovedFrom и оставить его. (Плохая практика: если изменился тип - стоит обновить и ассеты, которые его используют)
Вариант 2: восстановить слетевшую ссылку вручную в инспекторе.
Вариант 3:
добавить MovedFrom
вызвать AssetDatabase.ForceReserializeAssets() (в этом случае обновится весь проект, в Git появится много изменений, зато все ссылки автоматически будут иметь новый тип)
удалить MovedFrom.
Вариант 4: править YAML вручную.
Универсального решения здесь нет - используйте тот вариант, который удобнее в конкретной ситуации.
При этом последний вариант не самый плохой: в случае поломки SerializeReference Unity, как правило, выведет в консоль более-менее понятную ошибку (хотя многое зависит от версии движка).


В итоге из всего этого вытекают два главных недостатка SerializeReference:
они плохо переносят изменения типов, так как типы хранятся в виде строки;
сложно найти конкретные использования SerializeReference с определенным типом среди всех ассетов проекта в Unity.
Отдельно стоит упомянуть старые версии Unity. Начиная с версии 2021, внутренняя реализация ссылок стала значительно стабильнее. Поэтому Unity 2021 можно считать минимально допустимой версией, если вы планируете полноценно использовать SerializeReference. Более подробно это разобрано в статье.
Пример 2. Serialize Reference - MonoBehaviour lifetime
Но на единичных случаях полиморфизма применение SerializeReference не заканчивается - дальше путь становится только интереснее. Чем глубже в лес, тем дальше бежать за трактором… :)
С SerializeReference можно использовать ManagedReferenceUtility. С его помощью можно получать объекты по id из хоста (так в Unity называют всё, что может содержать ссылки: MonoBehaviour, ScriptableObject и т. п.)
В качестве примера рассмотрим следующий вариант использования. Добавим к SerializeReference жизненный цикл из MonoBehaviour.
public interface IMonoOnEnableBehaviour
{
void OnEnable();
}
public interface IMonoOnDisableBehaviour
{
void OnDisable();
}
public class BaseReferenceBehaviour : MonoBehaviour
{
//Тут большое поле для оптимизаций!
public IEnumerable<T> GetReferences<T>()
{
var rids = ManagedReferenceUtility.GetManagedReferenceIds(this);
var objects = rids.Select(id => ManagedReferenceUtility.GetManagedReference(this,id));
return objects.OfType<T>();
}
private void OnEnable()
{
foreach (var onEnableBehaviour in GetReferences<IMonoOnEnableBehaviour>())
{
onEnableBehaviour.OnEnable();
}
}
private void OnDisable()
{
foreach (var onDisableBehaviour in GetReferences<IMonoOnDisableBehaviour>())
{
onDisableBehaviour.OnDisable();
}
}
}
Что примерно здесь происходит?
В базовом MonoBehaviour мы реализуем метод, вызываемый Unity - OnEnable(). Внутри него получаем все объекты ссылок через GetReferences(), которые реализуют конкретный интерфейс IMonoOnEnableBehaviour.
Далее таким же образом можно добавить весь базовый жизненный цикл MonoBehaviour:
Awake(), Start(), OnEnable(), OnDisable(), OnDestroy(), а также, при необходимости, Inject из вашего DI-контейнера.
После этого мы можем использовать наш базовый класс. А для типов, которые будут применяться в SerializeReference, достаточно просто реализовать нужные интерфейсы и они автоматически будут вызываться из базового класса.
public interface IBaseScreenBehaviour
{
}
public class BaseScreen : BaseReferenceBehaviour
{
[SerializeReference, SerializeReferenceDropdown]
private IBaseScreenBehaviour[] _screenBehaviours;
}
[Serializable]
public class LogScreenActivation : IBaseScreenBehaviour, IMonoOnEnableBehaviour, IMonoOnDisableBehaviour
{
public void OnEnable()
{
Debug.Log("On Enable launched");
}
public void OnDisable()
{
Debug.Log("On Disable launched");
}
}

Вообще, этот пример можно было бы упростить выкинув интерфейс IBaseScreenBehaviour.
public class BaseScreen : BaseReferenceBehaviour
{
[SerializeReference, SerializeReferenceDropdown]
private object[] _screenBehaviours;
}
Однако, я бы не стал этого делать. Так мы хотя бы гарантируем, что у объекта будут использоваться только типы с этим интерфейсом. Их проще найти при выборе типов в инспекторе и при поиске реализаций в IDE.
Где это можно использовать?
Когда нужна конкретная «небольшая» логика, но при этом её недостаточно, чтобы выносить в отдельный MonoBehaviour. И при этом нам всё ещё очень нужен полиморфизм.
Пример: всё те же экраны.
Нам нужно добавить фичу - ставить игру на паузу. За это отвечает экран паузы: именно он инициирует паузу. Но не всегда. Например, в мультиплеере пауза не имеет смысла. При этом экран паузы может ещё и отключать рендер, чтобы уменьшить потребление ресурсов в простое.
Теперь возникает новая задача: выделить эти две фичи («пауза» и «отключение рендера») и добавить их к другому экрану - экрану инвентаря. Он может открываться на весь экран в игре, поэтому схожая логика там тоже может быть уместна.
И вот здесь SerializeReference оказывается очень кстати. Если вынести эту логику в отдельные мини-компоненты и собрать композицию фич экрана, то мы сможем удобно переиспользовать их в дальнейшем.
Все примеры какие-то абстрактные, но ведь это и есть основа SerializeReference!
Неочевидные поведения при копировании
Попробовав и вкусив фишечки SerializeReference, хочется добавить полиморфизм и в различные конфиги (в нашем случае - ScriptableObject). В итоге реализуется базовый вариант примерно в таком духе.
[CreateAssetMenu(fileName = "SampleConfig", menuName = "SampleConfig")]
public class SampleConfig : ScriptableObject
{
[SerializeField] private Data[] _shapes;
[Serializable]
private class Data
{
public string ID;
[SerializeReference] public IShape Shape = new Circle();
}
}
Решение получается вполне неплохим, но есть одно но…

Чтобы понять, в чём проблема, достаточно заглянуть внутрь YAML.

У двух разных элементов оказывается один и тот же rid, а значит они ссылаются на один и тот же объект. Такое может происходить при добавлении сложных объектов в массивы: объекты копируются и вместе с ними копируются их rid, которые по сути являются просто числами.
Неприятненько, однако! Предупрежден - значит вооружен!

RefTo - ссылки на SerializeReference
Представьте себе мир, где нельзя оставить ссылку на компонент Unity и приходится обращаться к другим компонентам через GetComponent(). Представили? Ужас какой-то.
Но именно так пришлось бы делать, если бы нужно было получить другую SerializeReference.
А что если хранить id ссылки и потом просто получать объект через ManagedReferenceUtility? Поздравляю - вы придумали RefTo!
Применение очень простое: указываем тип ссылки и тип хоста. Либо можно опустить тип хоста, тогда туда можно будет назначить любой объект (это часто удобнее).
public class RefToExample : MonoBehaviour
{
[SerializeField] private RefTo<IShape> _shapeRef;
}
В рантайме ссылка извлекается с помощью ManagedReferenceUtility, проверяется нужный тип и вот, вся реализация готова.

Эти знания нам помогут дальше нырнуть по полной в пучину SerializeReference.
Пример 3. SerializeReference и RefTo - скрываем, что скрывается
Опять экраны, снова!

У нас есть экран и в нём есть некоторые дочерние элементы. Как бы мы сделали это, используя исключительно MonoBehaviour?

В первом варианте у нас один компонент, который хранит ссылки на всё.

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

Судя по схеме, это очень похоже на второй вариант. Только вместо конкретных компонентов используется хост, который просто «держит» SerializeReference нужного типа. А в рутовом префабе находятся ссылки на эти ссылки. (тут должен быть мем про монитор в мониторе, но для него нужна будет еще одна ссылка)
Но самое интересное происходит в коде…
public class ReferencesGameScreen : MonoBehaviour
{
[SerializeField] private RefTo<Compass> _compass;
[SerializeField] private RefTo<KillBar> _killBar;
[SerializeField] private RefTo<Map> _map;
[SerializeField] private RefTo<MapPhase> _mapPhase;
[SerializeField] private RefTo<PlayerStats> _playerStats;
[Serializable]
private class Compass : IScreenElement
{
public Image CompassImage;
public TMP_Text CompassDirectionLabel;
}
[Serializable]
private class KillBar : IScreenElement
{
public TMP_Text AliveCounter;
}
[Serializable]
private class Map : IScreenElement
{
public Image MapImage;
}
[Serializable]
private class MapPhase : IScreenElement
{
public TMP_Text PhaseTimer;
public TMP_Text PhaseCounter;
public Image PhaseProgress;
}
[Serializable]
private class PlayerStats : IScreenElement
{
public Image PlayerState;
public Image BackpackCapacity;
public Image HeadArmor;
public Image BodyArmor;
public Image WeaponMode;
public TMP_Text CurrentWeaponAmmo;
public TMP_Text CurrentWeaponFullAmmo;
public Image PlayerStamina;
public Image PlayerHealth;
}
}
Все эти классы вложенные и приватные. То есть никто кроме ReferencesGameScreen не может получить к ним доступ. И хотя код большой и монолитный, как в первом варианте, здесь есть псевдокомпоненты, как во втором варианте.
Мы можем перемещать логику туда, куда удобно, а если код разрастается - вложенные классы можно вынести. Главное - не забыть пофиксить слетевшие ссылки!
Может возникнуть справедливое замечание: «Но ведь в примере разные элементы и логика - связанности будет мало». И я с этим согласен. Но экраны бывают разные, а что если…

Пример 4. Пулинг SerializeReference + RefTo
Погружение в пучину ссылок годится для рубрики «ненормальное программирование». Итак, пример.

Нужно сделать динамический список элементов. В идеале объекты не должны инстанцироваться заново - поэтому нужен пулинг.
Как всё это реализовать с помощью ссылок? Смотрим на пример реализации.
public class InventoryScreen : MonoBehaviour
{
[SerializeField] private RefToElementsDataPool<ItemView, ItemData, ScreenReferencesElement> _inventoryItems;
[SerializeField] private Button _addNewItem;
private record struct ItemData(string name, int count, Color32 color);
[Serializable]
private class ItemView : IScreenElement
{
//Callback сохраняется в View, т.к. элемент будет переиспользоваться в пуле
//и он должен быть использован только одного инстанса ItemData
//можно оптимизировать сделав другими способами
public Action UseButtonClick;
public Button UseButton;
public TMP_Text ItemName;
public TMP_Text ItemCount;
public Image ItemIcon;
}
private async void Start()
{
_inventoryItems.Init(createAction: CreateItemView, refreshAction: RefreshInventoryItem);
await _inventoryItems.PrewarmPoolAsync(10);
_addNewItem.onClick.AddListener(AddRandomElement);
}
private void CreateItemView(ItemView view, ScreenReferencesElement element)
{
view.UseButton.onClick.AddListener(Click);
void Click()
{
view.UseButtonClick?.Invoke();
}
}
private void RefreshInventoryItem(ItemView view, ItemData data)
{
view.ItemName.text = data.name;
view.ItemCount.text = data.count.ToString();
view.ItemIcon.color = data.color;
view.UseButtonClick = UseButtonClick;
void UseButtonClick()
{
var itemsData = _inventoryItems.GetDataList();
var currentDataIndex = itemsData.IndexOf(data);
if (currentDataIndex < 0)
{
throw new Exception($"Not exist element: {data}");
}
var newItemData = data with { count = data.count - 1 };
if (newItemData.count <= 0)
{
itemsData.RemoveAt(currentDataIndex);
}
else
{
itemsData[currentDataIndex] = newItemData;
}
}
}
private static readonly string[] names =
{
"Apple",
"Sword",
"Potion",
"Shield",
"Gem",
"Scroll"
};
private void AddRandomElement()
{
var newElement = new ItemData(
name: names[Random.Range(0, names.Length)],
count: Random.Range(1, 5),
color: Random.ColorHSV(
hueMin: 0f, hueMax: 1f,
saturationMin: 0.6f, saturationMax: 1f,
valueMin: 0.2f, valueMax: 0.6f));
_inventoryItems.GetDataList().Add(newElement);
}
}
В примере показана только работа с пулом: инициализация и добавление данных (которые должны откуда-то появляться - хотя бы по клику кнопки). Никакого Instantiate - исключительно работа с Data (чистые данные, aka DTO) и View (слой отображения, в нашем случае компоненты интерфейса uGUI в Unity).
Немного подробнее.
У нас появился интересный «зверь» - RefToElementsDataPool. Он принимает SerializeReference с вложенным типом ItemView, в качестве данных используется вложенный рекорд ItemData, а в качестве хоста - ScreenReferencesElement (он нужен только для хранения ItemView).
Чтобы лучше понять, как работает пул, посмотрим на реализованный им интерфейс.
public interface IDataPool<out TReference, TData, out TMono>
where TMono : MonoBehaviour where TReference : class
{
void Init(Action<TReference, TMono> createAction = null,
Action<TReference, TData> refreshAction = null,
Action<TReference, TMono> releaseAction = null,
Action<TReference, TMono> destroyAction = null);
IList<TData> GetDataList();
Task PrewarmPoolAsync(int elementsCount);
void DestroyElements();
}
Есть метод Init(), в котором задаются колбеки для настройки пула — как минимум, чтобы применять Data к View (в нашем случае ItemData к ItemView).
Для управления пулом мы всего лишь изменяем данные в списке, который возвращается методом GetDataList().
Если изучить код самого пула, то его идея следующая:
чтобы изменение данных автоматически обновляло View, используется ObservableCollection, которая заполняет другой список, где есть связь Data + View;
элементами View (обычными GameObject, но в нашем случае RefTo) управляет ObjectPool, доступный в Unity.
Возможностей для изменений пула очень много: от выбора коллекций и алгоритмов до различной логики обновления элементов.
Идеи на будущее: можно сделать пул, который будет отображать только видимые элементы в скролл-списке. (Это уже реализовано в ListView, который есть в UiToolkit, а у нас используется uGUI)
Пример 4.2 Generic ???
В примере 4 мы говорили про интерфейс пула, но SerializeReference для него не использовался. Почему? Неужели generic ограничивают ссылки? Погнали разбираться.
public class GenericInventoryScreen : MonoBehaviour
{
[SerializeReference, SerializeReferenceDropdown]
private IDataPool<ItemView, ItemData, ScreenReferencesElement> _inventoryItems;
В обновлённой версии наш пул теперь помечен SerializeReference и заменён на интерфейс, а остальное менять не пришлось.

Если присмотреться, в типе ссылки в инспекторе виднеется невероятно страшная конструкция.
А если заглянуть в YAML - слабонервным лучше не смотреть!
Generic + YAML
- rid: 1988581737856762085
type: {class: 'RefToElementsDataPool`3[[Samples.Sample_4.GenericInventoryScreen/ItemView, Samples],[Samples.Sample_4.GenericInventoryScreen/ItemData, Samples],[Samples.Sample_3.References.ScreenReferencesElement, Samples]]', ns: Samples.Sample_4.Pool, asm: Samples}
Если до этого были варианты исправления ссылок с помощью MovedFrom или вручную, то здесь это просто не имеет смысла. Если что-то сломалось - придётся заново настраивать SerializeReference.
Наверное, generic можно использовать с SerializeReference, но всё же стоит ограничивать их спектр и максимально точно определять параметры дженерика.
Собственно, это и объясняет, почему в нашем случае всё сработало легко: все необходимые типы для параметров были указаны.
Вывод
Этой статьей я хотел показать, что SerializeReference - очень интересная вещь, и безумию здесь нет предела.
Большого вывода делать не буду, но могу отметить, что помимо пакета для Unity, постепенно разрабатывается плагин для Rider.
Успехов вам в ковырянии SerializeReference!