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