И прежде чем погрузиться в мир 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, в том числе добавляя удобный выбор типа. В качестве примера возьму свой пекейдж  :)

SerializeReferenceDropdown
SerializeReferenceDropdown

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

Выделение базового функционала
Выделение базового функционала

Что же под капотом?

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

YAML + инспектор
YAML + инспектор

При использовании 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, как правило, выведет в консоль более-менее понятную ошибку (хотя многое зависит от версии движка).

Ошибка с сломанной ссылкой
Ошибка с сломанной ссылкой
Пекейдж показывает сломанную ссылку и можно тут же исправить (пока только с ScriptableObject)
Пекейдж показывает сломанную ссылку и можно тут же исправить (пока только с ScriptableObject)

В итоге из всего этого вытекают два главных недостатка 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, проверяется нужный тип и вот, вся реализация готова.

RefTo в инспекторе
RefTo в инспекторе

Эти знания нам помогут дальше нырнуть по полной в пучину SerializeReference.

Пример 3. SerializeReference и RefTo - скрываем, что скрывается

Опять экраны, снова!

Какая-то рандомная игра
Какая-то рандомная игра

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

Вариант 1. Убер-экран
Вариант 1. Убер-экран

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

Вариант 2. Компонентный экран
Вариант 2. Компонентный экран

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

Вариант 3. Компонентный экран с RefTo + Serialize Reference
Вариант 3. Компонентный экран с RefTo + Serialize Reference

Судя по схеме, это очень похоже на второй вариант. Только вместо конкретных компонентов используется хост, который просто «держит» 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!

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