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

Тут начинается всё самое интересное, сначала создаём класс 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 сменить значимые структуры на ссылочные интерфейсы глобального класса. Это имеет свои плюсы и минусы и как минимум, может быть интересно для изучения.
ссылка на оригинал статьи https://habr.com/ru/post/706140/
Добавить комментарий