Как стать автором
Обновить

Использование архитектуры Composition root в Unity. Часть 1. Настройка проекта с нуля

Время на прочтение8 мин
Количество просмотров8.2K

Привет всем, кто неравнодушен к архитектурным решениям в рамках проектов на Unity и не только. Если вопрос выбора для вас ещё актуален или просто интересуетесь вариантами, то готов рассказать о реализации архитектуры Composition root с примерами простейшей логики. Здесь есть единая точка входа и Dependency Injection, то есть всё как мы любим. Сам я уже несколько лет придерживаюсь данной архитектуры и реализовал на ней не мало проектов, от ГК прототипов, до pvp игр.

Composition root представляет собой смесь моделей MVP и MVVM и активно использует шаблон Observer, в данной статье я не буду углубляться в суть этих терминов, а попробую наглядно показать как это работает. Реализация структуры проекта идёт через связку базовых понятий:  Entity - Presenter Model (PM) - View.

Entity - сущность, отдельная логическая единица, служащая для создания PM и View и передающая им зависимости

Presenter Model - содержит бизнес логику, не имеющую отношение к Monobehaviour классам

View - Gameobject на сцене

Путь от единой точки входа, до первой игровой сущности

Посмотрим на практике, как сделать первые шаги. Создадим два объекта на сцене: пустой Canvas и GameObject Entry Point с компонентом на нём с таким же названием.

Класс EntryPoint будет содержать совсем немного кода

EntryPoint
public class EntryPoint : MonoBehaviour
{
   [SerializeField] private ContentProvider _contentProvider;
   [SerializeField] private RectTransform _uiRoot;

   private Root _root;

   private void Start()
   {
       var rootCtx = new Root.Ctx
       {
           contentProvider = _contentProvider,
           uiRoot = _uiRoot,
       };
  
       _root = Root.CreateRoot(rootCtx);
   }

   private void OnDestroy()
   {
       _root.Dispose();
   }
}

Тут стоит пояснить, что _uiRoot - этот тот самый пустой канвас, а _contentProvider - это scriptable object, в котором будет лежать всё, что в дальнейшем должно появиться на сцене. Класса Root у нас ещё нет и дальше мы создадим и его.

В будущем освещение и камеру тоже стоит подгружать из Content Provider
В будущем освещение и камеру тоже стоит подгружать из Content Provider

Тут начинается всё самое интересное, сначала создаём класс DisposableObject , от которого будут унаследованы все наши будущие сущности и PM, включая Root. Назначение DisposableObject в том, чтобы при необходимости суметь безопасно уничтожить свои экземпляры и подписки внутри них. Тут мы постепенно подходим к паттерну Observer, но обо всём по порядку. 

Класс DisposableObject
public abstract class DisposableObject : IDisposable
{
   private bool _isDisposed;
   private List<IDisposable> _mainThreadDisposables;
   private List<Object> _unityObjects;
  
   public void Dispose()
   {
       if (_isDisposed)
           return;
       _isDisposed = true;
       if (_mainThreadDisposables != null)
       {
           var mainThreadDisposables = _mainThreadDisposables;
           for (var i = mainThreadDisposables.Count - 1; i >= 0; i--)
               mainThreadDisposables[i]?.Dispose();
           mainThreadDisposables.Clear();
       }
       try
       {
           OnDispose();
       }
       catch (Exception e)
       {
           Debug.Log($"This exception can be ignored. Disposable of {GetType().Name}: {e}");
       }

       if (_unityObjects == null) return;
       foreach (var obj in _unityObjects.Where(obj => obj))
       {
           Object.Destroy(obj);
       }
   }

   protected virtual void OnDispose() {}

   protected TDisposable AddToDisposables<TDisposable>(TDisposable disposable) where TDisposable : IDisposable
   {
       if (_isDisposed)
       {
           Debug.Log("disposed");
           return default;
       }
       if (disposable == null)
       {
           return default;
       }

       _mainThreadDisposables ??= new List<IDisposable>(1);
       _mainThreadDisposables.Add(disposable);
       return disposable;
   }
}

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

Класс Root
public class Root : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }
   private readonly Ctx _ctx;
  
   private Root(Ctx ctx)
   {
       _ctx = ctx;
       CreateGameEntity();
   }

   private void CreateGameEntity()
   {
       var ctx = new GameEntity.Ctx
       {
           contentProvider = _ctx.contentProvider,
           uiRoot = _ctx.uiRoot
       };
  
       AddToDisposables(new GameEntity(ctx));
   }
}

Теперь contentProvider и uiRoot являются переменными в структуре Ctx (название сокращенно от Context). Эта структура была создана в EntryPoint и передана в конструктор класса Root, что положило основу “корню” для будущего дерева нашего проекта.

Создадим Game Entity
public class GameEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }
  
   private readonly Ctx _ctx;
   private UIEntity _uiEntity;
  
   public GameEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreateUIEntity();
   }

   private void CreateUIEntity()
   {
       var UIEntityCtx = new UIEntity.Ctx()
       {
           contentProvider = _ctx.contentProvider,
           uiRoot = _ctx.uiRoot
       };
       _uiEntity = new UIEntity(UIEntityCtx);
       AddToDisposables(_uiEntity);
   }
}

Реализация простейшей логики

На данном этапе Game Entity порождает только одну сущность UIEntity, внутри которой будет реализована простая логика подсчёта кликов по кнопке. Рассмотрим реализацию UIEntity и логику связей внутри сущности при помощи реактивной переменной.

Класс UIEntity
public class UIEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }

   private readonly Ctx _ctx;
   private UIPm _pm;
   private UIviewWithButton _view;
   private readonly ReactiveProperty<int> _buttonClickCounter = new ReactiveProperty<int>();
   public UIEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreatePm();
       CreateView();
   }

   private void CreatePm()
   {
       var uiPmCtx = new UIPm.Ctx()
       {
           buttonClickCounter = _buttonClickCounter
       };
       _pm = new UIPm(uiPmCtx);
       AddToDisposables(_pm);
   }

   private void CreateView()
   {
       _view = Object.Instantiate(_ctx.contentProvider.uIviewWithButton, _ctx.uiRoot);
       _view.Init(new UIviewWithButton.Ctx()
       {
           buttonClickCounter = _buttonClickCounter
       });
   }

   protected override void OnDispose()
   {
       base.OnDispose();
       if(_view != null)
           Object.Destroy(_view.gameObject);
   }
}

Класс UIPm
public class UIPm : DisposableObject
{
   public struct Ctx
   {
       public ReactiveProperty<int> buttonClickCounter;
   }

   private Ctx _ctx;
  
   public UIPm(Ctx ctx)
   {
       _ctx = ctx;
       _ctx.buttonClickCounter.Subscribe(ShowClicks);
   }

   private void ShowClicks(int click)
   {
       Debug.Log($"clicks: {click}");
   }
}

Класс UIViewWithButton
public class UIviewWithButton : MonoBehaviour
{
   public struct Ctx
   {
       public ReactiveProperty<int> buttonClickCounter;
   }

   private Ctx _ctx;
   [SerializeField] private Button button;

   public void Init(Ctx ctx)
   {
       _ctx = ctx;
       button.onClick.AddListener( () => _ctx.buttonClickCounter.Value++);
   }
}

Сущность порождает PM c логикой вывода количества кликов в Debug.Log. Здесь всё просто и акцентировать внимание не на чем. Реализация вьюхи чуть более интересная. Для её создания пригодились content provider, в котором лежал префаб с соответствующим компонентом и uiRoot, послуживший родителем для этого префаба.

buttonClickCounter  - реактивная переменная, созданная посредством UniRx, ставшая частью контекста для вьюхи и pm. Она инициализируется в сущности и передаётся дальше. UIViewWithButton на каждый клик инкриминирует значение переменной, UIPm принимает это значение. Для это в Pm нужно создать подписку на изменение значения переменной. Эта подписка добавляется в список внутри DisposableObject и будет уничтожена, при разрушении объекта. 

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

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

Расширение логической части

Добавим логику вращения куба по нажатию на уже имеющуюся кнопку.

Для это создадим ещё одну сущность и опишем в ней создание игрового объекта и его реакцию на нажатие кнопки. Для этого переменную buttonClickCounter  необходимо вынести на уровень выше в Game Entity и добавить её в контекст UIEntity.

Обновлённый класс Game Entity

public class GameEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }
  
   private readonly Ctx _ctx;
   private UIEntity _uiEntity;
   private CubeEntity _cubeEntity;
   private readonly ReactiveProperty<int> _buttonClickCounter = new ReactiveProperty<int>();

  
   public GameEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreateUIEntity();
       CreteCubeEntity();
   }

   private void CreateUIEntity()
   {
       var UIEntityCtx = new UIEntity.Ctx()
       {
           contentProvider = _ctx.contentProvider,
           uiRoot = _ctx.uiRoot,
           buttonClickCounter = _buttonClickCounter
       };
       _uiEntity = new UIEntity(UIEntityCtx);
       AddToDisposables(_uiEntity);
   }

   private void CreteCubeEntity()
   {
       var cubeEntityCtx = new CubeEntity.Ctx()
       {
           contentProvider = _ctx.contentProvider,
           buttonClickCounter = _buttonClickCounter
       };
       _cubeEntity = new CubeEntity(cubeEntityCtx);
       AddToDisposables(_cubeEntity);
   }
}

Класс CubeEntity
public class CubeEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public ReactiveProperty<int> buttonClickCounter;

   }

   private Ctx _ctx;
   private CubePm _pm;
   private CubeView _view;
   private readonly ReactiveProperty<float> _rotateAngle = new ReactiveProperty<float>();
  
   public CubeEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreatePm();
       CreteView();
   }

   private void CreatePm()
   {
       var cubePmCtx = new CubePm.Ctx()
       {
           buttonClickCounter = _ctx.buttonClickCounter,
           rotateAngle = _rotateAngle
       };
       _pm = new CubePm(cubePmCtx);
       AddToDisposables(_pm);
   }

   private void CreteView()
   {
       _view = Object.Instantiate(_ctx.contentProvider.cubeView, Vector3.zero, Quaternion.identity);
       _view.Init(new CubeView.Ctx()
       {
           rotateAngle = _rotateAngle
       });
   }
  
   protected override void OnDispose()
   {
       base.OnDispose();
       if(_view != null)
           Object.Destroy(_view.gameObject);
   }
}

В контекст созданной CubeEntity тоже входит переменная buttonClickCounter, которая доходит до CubePm. Там же на неё подписан метод задающий значение для другой реактивной переменной rotateAngle, на которую, в свою очередь, подписана CubeView. 

Обращу внимание что способы организации подписки в Pm и View различаются. Если внутри pm подписку достаточно добавить в список на “разрушение”, то внутри MonoBehaviour  вьюхи, подписке нужно указать, что она принадлежит именно этому объекту, реализовано с помощью .addTo(this). Такая привязка поможет уничтожить подписку вместе с GameObject, когда до этого дойдёт дело.

Класс CubePm

public class CubePm : DisposableObject
{
   public struct Ctx
   {
       public ReactiveProperty<float> rotateAngle;
       public ReactiveProperty<int> buttonClickCounter;
   }

   private Ctx _ctx;
  
   public CubePm(Ctx ctx)
   {
       _ctx = ctx;
       AddToDisposables(_ctx.buttonClickCounter.Subscribe(AddRotationAngle));
   }

   private void AddRotationAngle(int clickCount)
   {
       _ctx.rotateAngle.Value = clickCount * 30;
   }
}

Класс CubeView
public class CubeView: MonoBehaviour
{
   public struct Ctx
   {
       public ReactiveProperty<float> rotateAngle;
   }

   private Ctx _ctx;

   public void Init(Ctx ctx)
   {
       _ctx = ctx;
       _ctx.rotateAngle.Subscribe(RotateMe).AddTo(this);
   }

   private void RotateMe(float angle)
   {
       transform.eulerAngles = new Vector3(0, angle, 0);
   }
}

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

Скачать и посмотреть проект в рабочем состоянии можно тут.

Напоследок

Я знаю, что много чего не указал, например, можно добавить singleton проверку в классе root, чтобы уберечь корневой класс от дубликата или рассказать побольше о  возможностях UniRx, например, о создании реактивных событий. Но об этом, возможно, в другой раз. Здесь я хотел дать больше прикладного материала, о том как стартануть проект с нуля с понятной и устойчивой архитектурой.

В следующей части статьи я расскажу о том как можно в рамках composition root сменить значимые структуры на ссылочные интерфейсы глобального класса. Это имеет свои плюсы и минусы и как минимум, может быть интересно для изучения.

p.s. вышла вторая часть статьи, добро пожаловать в рефакторинг

Теги:
Хабы:
Всего голосов 2: ↑2 и ↓0+2
Комментарии16

Публикации

Истории

Работа

Ближайшие события

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
24 – 25 октября
One Day Offer для AQA Engineer и Developers
Онлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань