Недавно мы побывали в Амстердаме на конференции Unite Europe 2016, где получили массу эмоций и интересного опыта. На этой конференции было очень много увлекательных докладов по разным направлениям и различного уровня сложности. Темой одного из выступлений была “Overthrowing the MonoBehaviour tyranny in a glorious ScriptableObject revolution”, на котором Ричард Файн (https://twitter.com/superpig / https://github.com/richard-fine), специалист из Unity Technologies, подробно рассказал о ScriptableObject и на примерах показал, как он может быть применен в проекте.
В своем докладе Ричард затронул такие вопросы:
- Почему MonoBehaviour (далее просто MB) — отстой (sucks).
- Что такое ScriptableObject (далее SO) и почему он лучше.
- Как использовать SO.
- Паттерны использования SO.
- Демонстрация мощи SO на примере демо-проекта Pimp My Tanks (в этой статье о нем говорить не будем, для ознакомления можете посмотреть https://bitbucket.org/richardfine/scriptableobjectdemo).
Далее — свободный перевод / пересказ того, о чем рассказывал Ричард, с различными дополнениями.
Тирания MB
Общие сведения о MB:
- Большинство скриптов пишется как MB.
- Они прикрепляются к GameObject (далее GO).
- Живут в сценах или префабах.
- Получают некоторые колбеки от движка (такие как Start, Update и т. д.).
Каковы недостатки?
- Сбрасываются при выходе из плеймода.
- При инстанцировании создается полная копия.
- Инстансы неудобно «шарить» между сценами.
- Инстансы неудобно «шарить» между проектами.
- Плохая VCSгрануляция (при изменении скрипта на объекте сцены изменяется вся сцена, часто возникают конфликты и т. д.).
- Могут быть несогласованно кастомизированы. При наличии нескольких одинаковых объектов в сцене, есть вероятность случайного изменения свойств одного из них, что не всегда просто обнаруживается и является не совсем верным с точки зрения геймдизайна, ведь если все игровые объекты одинаковые, то и свойства у них должны быть одинаковые. Хотя бывают и исключения, когда такое поведение удобно, но это более редкие случаи.
- Не совсем уместны концептуально: очень часто бывает необходимо оперировать чистыми данными с возможностью нативной и автоматической сериализации в инспекторе, а не компонентом / объектом с некой позицией в пространстве и т. д.)
Как можно спастись от тирании MB?
Чистые статические C# классы?
- Всё еще сбрасываются при выходе из плеймода.
- Приходится сериализовать их вручную.
- Внутри таких классов неудобно оперировать Unity-объектами.
- Приходится писать собственный инспектор.
Но ведь мы же специально используем движок, который предоставляет всё это «из коробки», чтобы избежать неудобств!
Как насчет префабов?
Они решают проблему хранение и переноса от сцены к сцене и между проектами, и не нарушают VCS-грануляцию. Но у этого решения тоже есть недостатки:
- Можно легко всё испортить, например, случайно инстанцировав / перетащив в сцену.
- Могут быть дополнительные компоненты (например, AudioSource), но зачем они нужны, ведь данные должны быть отделены от таких вещей!
- Концептуально всё еще не идеальное решение, возможно, приемлемое, но...
Тут на помощь приходит SO
SO — это класс, который позволяет хранить большое количество передаваемой информации независимо от образцов скрипта. От этого класса можно унаследоваться, если есть необходимость создавать объекты, которые не будут прикрепляться к GO.
Представим, что есть префаб со скриптом, который имеет массив из миллиона целых чисел. Массив занимает 4 мегабайта памяти и принадлежит префабу. Каждый раз, создавая экземпляр этого префаба, создаётся и экземпляр этого массива. Если создать 10 игровых объектов, тогда размер памяти, занимаемой массивами для этих 10 экземпляров, будет равен 40 мегабайтам.
При использовании SO результат будет совершенно другим. Unity сериализует все типы примитивов, строк, массивов, списков, специфичных типов, таких как Vector3 и пользовательских классов с атрибутом Serializable в качестве копий, относящихся к объекту, в котором они определены. Это означает, что при создании экземпляра класса SO с объявленным в нем массивом из миллиона целых чисел, этот массив будет передаваться вместе с образцом. При этом экземпляры считают, что обладают разными данными. Поля SO или любые UnityEngine.Object-поля, такие как MonoBehaviour, Mesh, GameObject и т.д, хранятся в ссылках, в отличие от значений. Если имеется скрипт, ссылающийся на SO, содержащий миллион целых чисел, то Unity сохранит в данных скрипта лишь ссылку на SO. свою очередь, SO хранит массив. 10 экземпляров префаба, которые ссылаются на класс SO, использующий 4 мегабайта памяти, в итоге заняли бы 4 мегабайта вместо 40, о которых упоминалось выше. Это особенно важно, когда речь идет о большом количестве объектов и/или больших объемах данных в скриптах.
Итак, SO:
- Это как MB, но не компонент (стоит наследовать пользовательские классы от SO, а не от MB).
- Не может быть прикреплен к GO / префабам.
- Может быть сериализован и инспектирован в Инспекторе, как и MB.
- Может быть помещен в .asset файл (можно создавать кастомные ассеты, помещая туда свои текстуры / материалы и прочее).
- Решает некоторые проблемы полиморфизма. Если заглянуть глубже в код сериализации в Unity, то оказывается, что при наследовании не все вещи могут быть корректно сериализованы, в случае с SO таких проблем нет.
Как SO спасает нас от проблем:
- Хранение в ассетах предотвращает сброс при выходе из плеймода.
- На него можно ссылаться, а не копировать при инстанцировании (уменьшается расход памяти, возрастает скорость инстанцирования).
- Как и любой другой ассет, может использоваться и «шариться» между сценами.
- Проще «шарить» между проектами: достаточно перенести всего один файл ассета, нет необходимости копаться во всей иерархии и искать зависимости.
- Идеальная VCS-грануляция (один файл — один объект).
- Нет дополнительных ненужных частей (например, Transform).
Но SO тоже не идеален:
- Всего три колбека: OnEnable / OnDisable, OnDestroy. Хотя эту проблему можно решить, используя MB в качестве прокси.
- Общие данные, на самом деле, не совсем общие. Их нельзя изменять для конкретной сущности, что имеет свои достоинства и недостатки, это зависит от выбранного флоу.
Как использовать SO
Класс SO необходимо использовать в тех случаях, когда нужно снизить расход памяти путём избежания копирования значений. Но его также можно использовать для определения включаемых наборов данных. Он отлично подходит для конфигурационных файлов, например для настроек уровня, глобальных настроек игры или индивидуальных настроек для персонажей / врагов / зданий и так далее (можно, например, хранить максимальный запас здоровья, урон и прочие параметры). Также SO удобен при написании кастомных инструментов, редакторов уровней и т.д.
Из экземпляров SO можно быстро и удобно создавать кастомные ассеты, переиспользовать их, подгружать по сети и т. д. При объявлении наследника класса SO его можно пометить атрибутом CreateAssetMenu, что добавит пункт создания ассета для этого объекта в контекстное меню Assets/Create.
Пример простого скрипта с настройками:
using UnityEngine;
[CreateAssetMenu(fileName="EnemyData", menuName="Prefs/Characters/Enemy", order=1)]
public class EnemyPrefs : ScriptableObject
{
public string objectName = "Enemy";
[Range(10f, 100f)]
public float maxHP = 50f;
[Range(1f, 10f)]
public float maxDamage = 5f;
public Vector3[] spawnPoints;
}
Создание ассета:
Сериализация ассета с данными в инспекторе:
При работе в инспекторе с экземплярами SO можно дважды нажать на поле ссылки, чтобы открыть инспектор для своего SO. Также есть возможность создавать пользовательский редактор для определения вида инспектора своего типа, чтобы помочь управлять данными, которые он представляет.
Экземпляр SO может быть создан без привязки к .asset файлу: программно, при помощи SсriptableObject.CreateInstance<>.
Время жизни SO:
- Такое же, как и любого другого ассета.
- Когда он персистентен (привязан к .asset файлу, AssetBundle, и т. д.):
- SO может быть выгружен GC через Resources.UnloadUnusedAssets,
- продолжает существовать в памяти, если на него есть ссылки в других скриптах,
- при необходимости может быть загружен заново,
- Когда он не персистентен (создан с помощью CreateInstance<> и не привязан к каким либо .asset):
- может быть уничтожен GC, а не просто выгружен,
- может быть оставлен в памяти с помощью HideFlags.HideAndDontSave.
Паттерны использования
Большинство разработчиков считают SO контейнером данных, но на самом деле он представляет из себя нечто большее. Рассмотрим некоторые паттерны его применения.
Как объекты данных и таблицы:
- Plain-Old-Data (POD) класс, привязанный к .asset файлу.
- Можно редактировать в Инспекторе, коммитить в VCS как один файл. Например, гейм-дизайнер может изменить какие-то настройки или прочие данные, не затрагивая при этом сцены или префабы, что очень удобно.
- При помощи кастомного редактора можно сделать его еще более удобным при использовании и настройках в Инспекторе.
- Стоит выбрать подход при использовании SO: применять один объект на сущность или один на таблицу / набор сущностей. Например, при создании таблицы локализации или конфигурационного файла будет достаточно одного общего объекта, а если нужно создать шаблоны для разных юнитов, то на каждого нужен будет свой собственный.
- Примеры использования: таблицы локализаций, айтемы инвентаря, шаблоны юнитов, конфигурации уровней и т. д. Ничто из перечисленного не требует позиции в пространстве, либо какой-то логики. Это просто данные, которые можно будет менять, не затрагивая сцены и префабы, тем самым, не пересекаясь с работой других членов команды.
Пример:
class EnemyInfo : ScriptableObject
{
public int MaximumHealth;
public int DamagePerMeleeHit;
}
class Enemy : MonoBehaviour
{
public EnemyInfo info;
}
Как расширяемые перечисления:
- В виде пустого SO, привязанного .asset файлу.
- Могут быть использованы только для проверки на равенство с другими такими объектами, или для проверки на null.
- Как перечисления (Enums), но вместо написания кода, могут быть созданы дизайнерами прямо в редакторе.
- Примеры использования: айтемы инвентаря, категории событий, типы урона, типы юнитов и т. д.
- При необходимости, их легко можно будет расширить до объектов данных / таблиц, добавив нужные свойства.
Пример:
class AmmoType : ScriptableObject { }
…
if (inventory[weapon.ammoType] == 0)
{
PlayOutOfAmmoSound();
return;
}
…
inventory[weapon.ammoType] -= 1;
...
Двойная сериализация
Как было сказано ранее, одним из достоинств SO является полная совместимость с системой сериализации Unity. При этом:
- SO могут взаимодействовать с JsonUtility.
- В итоге можно получить смесь ассетов, созданных на этапе дизайна при помощи сериализатора Unity, и ассетов, созданных после этого программно при помощи JsonUtility.
- Примеры использования: встроенные уровни (дизайнерские, сохраненные сериализатором Unity) + пользовательские уровни (созданные в рантайме и сохраненные при помощи JsonUtility). С точки зрения архитектуры не стоит беспокоиться, при помощи чего были созданы такие уровни, их можно будет загружать в память как SO и работать с ними универсально.
Пример:
[CreateAssetMenu] class LevelData : ScriptableObject { /*...*/ }
- можно создать прямо в редакторе через меню, настроить значения и т. д.
LevelData LoadLevelFromFile(string path) { string json = File.ReadAllText(path); LevelData result = CreateInstance<>(LevelData); JsonUtility.FromJsonOverwrite(result, json); return result; }
- метод позволяет считывать текстовый файл с JSON (который был создан локально в процессе создания уровня или, например, пришел с сервера) и перегонять его в SO
Как синглтоны:
- SO + статическая инстанс-переменная (создается инстанс SO, а ссылка на него сохраняется в статической переменной).
- FindObjectWithType для восстановления инстанса после перезагрузки уровня.
- Примеры использования: глобальные игровые состояния.
Пример:
class GameState: ScriptableObject
{
public int lives, score;
static GameState _instance;
public GameState Instance
{
get
{
if (!_instance) _instance = FindObjectOfType<GameState>();
if (!_instance) _instance = CreateDefaultGameState();
return _instance;
}
}
}
Как делегаты:
- SO, которые содержат методы.
- MB передает ссылку на себя в методы SO, SO выполняет работу, при необходимости используя MB.
- Это дает возможность реализовать встраиваемое и настраиваемое поведение.
- Примеры использования: типы AI, различные поверапы, баффы / дебаффы.
Пример:
abstaract class PowerupEffect : ScriptableObject
{
public abstract void ApplyTo(GameObject go);
}
class HealthBooster : PowerupEffect
{
public int Amount;
public override void ApplyTo(GameObject go)
{
go.GetComponent<Health>().currentValue += Amount;
}
}
class Powerup : MonoBehaviour
{
public PowerupEffect effect;
public void OnTriggerEnter(Collider other)
{
effect.ApplyTo(other.gameObject);
}
}
Таким образом, Powerup MB делегирует свою работу PowerupEffect SO, в чем и заключается паттерн.
Заключение
Резюмируя вышесказанное, можно сделать вывод, что у SO есть свои плюсы и минусы, часть из которых приведена ниже:
Преимущества:
- Отлично сериализуется «из коробки», как с помощью JsonUtility, так и визуально в редакторе (в отличие от статических классов, например).
- Изменения сохраняются и не сбрасываются после выхода из плеймода.
- Можно удобно шарить между другими скриптами.
- Можно легко наследовать.
- Не размещается в инспекторе, тем самым не захламляет его.
- Не имеет оверхеда и является более легковесным, чем MB.
- Не прикрепляется к GO, соответственно, и не удаляется при уничтожении объекта.
- Может храниться в сцене до тех пора, пока на него ссылается хотя бы один объект в этой сцене.
- Может быть сохранен в ассет и переиспользован в других сценах при помощи AssetDatabase, в рантайме в уже развернутых билдах и т. д.
Недостатки:
- Не отображается в сцене — визуально непонятно, сколько экземпляров SO сейчас используется, что может затруднить дебаггинг.
- Есть вероятность неявно уничтожить его, если уничтожить последний объект, который на него ссылается (в сцене).
- Не так удобно использовать в префабах.
- Не очевидно, кому принадлежит объект, когда он должен быть уничтожен, в какой области видимости он находится.
- Так как SO не прикрепляется к GO, то нет возможности быстрого получение ссылок через GetComponents.
SO — очень важный инструмент, который хоть и не может полностью заменить MB, но может быть использован в сочетании с ним, избавляя от его тирании. SO не стоит использовать для всего подряд, но он дает большую гибкость. Куда больше, чем думают те, кто знаком с SO поверхностно или не знаком вообще. Он также предоставляет возможность построить отличный рабочий процесс для вашего проекта, создавать удобные и полезные инструменты и шаблоны для дизайнеров и т. д.
Ричард в начале своего выступления заявил: “MonoBehaviour sucks” (MonoBehaviour отстой :) ). Сложно полностью с этим согласиться, ведь так или иначе, без него (MB) почти не обойтись. Но главное — это понять, что не стоит использовать его всегда и везде, и что существуют различные альтернативы, одной из которых является мощный, гибкий и удобный ScriptableObject. Нужно грамотно выбирать те или иные средства, исходя из поставленной задачи при конкретных условиях.
Автор: NIX Solutions