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

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


  1. bezarius
    18.12.2022 23:56

    Здесь есть единая точка входа и Dependency Injection

    Единую точку входа увидел, но что касается DI, то этого я не увидел, как и задела под него.

    Если используете рх, то не стоит смешивать подходы и делать так:

    button.onClick.AddListener( () => _ctx.buttonClickCounter.Value++);

    А еще, по хорошему, ReactiveProperty нужно освобождать.

    Непонятно, зачем вам ctx в качестве valueType, если из этого не извлекается какого либо профита.

    _ctx.buttonClickCounter.Subscribe(ShowClicks);

    Тоже нужно освобождать.

    Что мешает какому нить джуну вызвать в `CubeView` метод `Init` несколько раз, и наплодить подписок, которые в результате могут привести к весьма "забавным" багам?

    В целом, на уровне дизайна - очень сомнительно.

    Во первых, UI построенный на GameObject в Unity3d крайне ущербный из за своего перформенса. Следовательно если вы делаете что то, более менее серьезное, должны быть пулы и ресайклинг элементов UI.

    Во вторых, как правило, вьюхи могут реюзаться 100500 раз, с небольшими изменениями в плане отображения и поведения. Другими словами, у объектов из пула должны поддерживаться возможность конфигурирования отображения и подмены поведения. Если вы конечно не хотите плодить в пуле объекты, у которых небольшие различия.

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

    В четвертых, если работаем с управляемыми ресурсами, должен быть лайфсайкл менеджмент и защита от дурака. Например, при повторном вызове `Init`, предыдущие подписки должны автоматически подчиститься.

    На самом деле это далеко не все, что можно было бы выделить, но коммент и так уже большой получился.


    1. eonyanov
      19.12.2022 07:55
      +1

      UI построенный на GameObject в Unity3d крайне ущербный из за своего перформенса

      А на чем еще UI делать? Не на IMGUI же его писать, это совсем древность неудобная. А новая UI Toolkit еще сырая.


      1. bezarius
        20.12.2022 10:02

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


  1. eonyanov
    19.12.2022 08:02

    Не понятно, как масштабировать эту архитектуру. Хотелось бы увидеть код более сложного проекта, чем подсчет кликов на кнопочке. С несколькими сценами, физикой и 5+ экранами.


  1. Nedfreetoplay
    20.12.2022 16:34

    Очень интересное решение хотя и имеет подводные камне. Можно попробовать на похожих принципах встроить работу с ECS(не имеющие интеграцию с Unity/ну или имеющие). Entry Point порождает ECSWorld как Root и на него уже крепятся остальные сущности и через View создаются нужные GameObject для вывода результатов, так мы получим почти пустые GameObject'ы со скрытой логикой где-то в сущностях.