Автор статьи: Игорь Гулькин

Senior Unity Developer

Всем привет! ????

Меня зовут Игорь Гулькин, и я Unity разработчик. За свои 5 лет накопилось много опыта, поэтому в этой статье хотел бы поделиться принципами и подходами, с помощью которых можно реализовать архитектуру игры просто и гибко без фреймворка. Цель доклада, дать не просто готовое решение, а показать ход мыслей и паттерны, с помощью которых ее можно выстроить. Если вы не читали первую и вторую части, то рекомендую начать с них :).

Давайте посмотрим, как у нас выглядит архитектура в конце второй части:

Выглядит не очень понятно, давайте рефакторить с помощью шаблонов GRASP с целью упрощения понимания…

Рефакторинг архитектуры

Первым делом мы можем использовать шаблон Pure Fabrication и объединить логику классов GameMachine и GameLocator в некий Фасад, который назовем GameContext:

public sealed class GameContext : MonoBehaviour, IGameLocator, IGameMachine
    {
        public GameState GameState
        {
            get { return this.gameMachine.GameState; }
        }

        private readonly GameMachine gameMachine = new();

        private readonly GameLocator serviceLocator = new();
       public GameContext()
        {
            this.serviceLocator.AddService(this.gameMachine);
        }

        [ContextMenu("Start Game")]
        public void StartGame()
        {
            this.gameMachine.StartGame();
        }

        [ContextMenu("Pause Game")]
        public void PauseGame()
        {
            this.gameMachine.PauseGame();
        }

        [ContextMenu("Resume Game")]
        public void ResumeGame()
        {
           this.gameMachine.ResumeGame();
        }

        [ContextMenu("Finish Game")]
        public void FinishGame()
        {
            this.gameMachine.FinishGame();
        }

        public void AddListener(object listener)
        {
            this.gameMachine.AddListener(listener);
        }

        public void RemoveListener(object listener)
        {
            this.gameMachine.RemoveListener(listener);
        }
        
        public void AddService(object service)
        {
            this.serviceLocator.AddService(service);
        }

        public void RemoveService(object service)
        {
            this.serviceLocator.RemoveService(service);
        }

        public T GetService<T>()
        {
            return this.serviceLocator.GetService<T>();
        }
    }

Применив шаблон GRASP, получаем один монобех, который содержит всю логику для работы с сервисами и состоянием игры. При этом GameContext реализует интерфейсы IGameMachine и IGameLocator, но бизнес-логику делегирует теперь уже обычным классам GameMachine и GameLocator.

Добавим GameContext на сцену:

Теперь давайте глянем на инсталлеры в схеме:

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

Теперь GameContextInstaller будет регистрировать все сервисы и листенеры и заниматься внедрением зависимостей. А вместо классов GameObservableInstaller, GameServiceInstaller и GameAssembler будут интерфейсы IGameServiceProviderIGameListenerProvider, IGameConstructor. Вот как это будет выглядеть в коде:

public sealed class GameContextInstaller : MonoBehaviour
    {
        [SerializeField]
        private GameContext gameContext;

        [SerializeField]
        private MonoBehaviour[] installers;

        private void Awake()
        {
            foreach (var installer in this.installers)
            {
                if (installer is IGameServiceProvider serviceProvider)
                {
                    this.gameContext.AddServices(serviceProvider.GetServices());
                }

                if (installer is IGameListenerProvider listenerProvider)
                {
                    this.gameContext.AddListeners(listenerProvider.GetListeners());
                }
            }
        }

        private void Start()
        {
            foreach (var installer in this.installers)
            {
                if (installer is IGameConstructor constructor)
                {
                    constructor.ConstructGame(this.gameContext);
                }
            }
        }
    }

В методе GameContextInstaller.Awake() происходит регистрация сервисов и слушателей в GameContext, а в методе GameContextInstaller.Start() происходит внедрение зависимости.

Теперь пару слов про новые интерфейсы:

IGameListenerProvider предоставляет слушателей для регистрации в GameContext.

public interface IGameListenerProvider
    {
        IEnumerable<object> GetListeners();
    }

IGameServiceProvider предоставляет сервисы для регистрации в GameContext.

public interface IGameServiceProvider
    {
        IEnumerable<object> GetServices();
    }

IGameConstructor занимается разрешением зависимостей, получая центральный реестр в качестве аргумента.

 public interface IGameConstructor
    {
        void ConstructGame(IGameLocator serviceLocator);
    }

Теперь нам нужно написать классы, которые будут реализовывать эти контракты. 

В нашем проекте, фактически есть два модуля: система игрока и система пользовательского ввода. Поэтому для каждого модуля и напишем свою реализацию

  1. В классе PlayerInstaller будем описывать систему игрока.

  2. В классе InputInstaller будем описывать систему ввода.

Прилагаю код:

public sealed class PlayerInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider,
        IGameConstructor
    {
        [SerializeField]
        private Player player;

        [SerializeField]
        private MoveController moveController;
        
        //TODO: Подключить контроллер камеры:
        //[SerializeField]
        //private CameraController cameraController;

        IEnumerable<object> IGameServiceProvider.GetServices()
        {
            yield return this.player;
        }

        IEnumerable<object> IGameListenerProvider.GetListeners()
        {
            yield return this.moveController;
            //yield return this.cameraController;
        }

        void IGameConstructor.ConstructGame(IGameLocator serviceLocator)
        {
            var keyboardInput = serviceLocator.GetService<IMoveInput>();
            this.moveController.Construct(keyboardInput, this.player);
            
            //var camera = serviceLocator.GetService<WorldCamera>();
            // this.cameraController.Construct(camera, this.player)
        }
    }


public sealed class InputInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider
    {
        [SerializeField]
        private KeyboardInput keyboardInput;

        //TODO: подключить ввод с мыши
        //[SerializeField]
        //private MouseInput mouseInput;

        IEnumerable<object> IGameServiceProvider.GetServices()
        {
            yield return this.keyboardInput;
            //yield return this.mouseInput;
        }

        IEnumerable<object> IGameListenerProvider.GetListeners()
        {
            yield return this.keyboardInput;
            //yield return this.mouseInput;
        }
    }

Отлично, код написан! Добавляем GameContextInstaller, PlayerInstaller и InputInstaller на сцену:

Запускаем игру. Сейчас регистрация компонентов системы происходит в методе GameContextInstaller.Awake(), а внедрение зависимости — в методе GameContextInstaller.Start(). Дальше в “монобехе” GameContext через контекстное меню вызываем метод GameContext.StartGame(). Вуаля, все работает!

В результате наша система выглядит так:

Оптимизация игры

Теперь было было здорово, чтобы наша игровая логика не зависела от монобехов. Во-первых, такие классы проще переиспользовать и тестировать, так как можно создать экземпляр в любом месте кода. Во-вторых, уйдя от монобехов и гейм-объектов, можно оптимизировать память и производительность. В третьих, появляется возможность работать в многопоточном коде, поскольку GameObject’ы и  “монобехи” можно использовать только в главном потоке

Таким образом, MoveController и KeyboardInput можно сделать обычными классами и создавать экземпляры этих классов прямо в инсталлере: 

 public sealed class InputInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider
    {
        private readonly KeyboardInput keyboardInput = new();

//Other code…
    }

public sealed class PlayerInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider,
        IGameConstructor
    {
        private readonly MoveController moveController = new();

        //private readonly CameraController cameraController = new();

//Other code…
           }

Но у KeyboardInput есть метод Update(), который вызывается из движка, чтобы трекать пользовательский ввод. Этот нюанс тоже можно решить, если сделать специальный интерфейс IUpdateGameListener, который будет вызывать GameContext:

public interface IUpdateGameListener
    {
        void OnUpdate(float deltaTime);
    }

Таким образом, KeyboardInput будет выглядеть так:

 public sealed class KeyboardInput :
        IMoveInput,
        IStartGameListener,
        IUpdateGameListener,
        IFinishGameListener
    {
        public event Action<Vector3> OnMove;

        private bool isActive;

        void IUpdateGameListener.OnUpdate(float deltaTime)
        {
            if (this.isActive)
            {
                this.HandleKeyboard();
            }
        }

//Other code…
}

А в класс GameContext добавляем следующий код:

public sealed class GameContext : MonoBehaviour, IGameLocator, IGameMachine
    {
        private readonly List<IUpdateGameListener> updateListeners = new();

        private void Awake()
        {
            this.enabled = false;
        }

        //Вызывается только, если игра запущена
        private void Update()
        {
            var deltaTime = Time.deltaTime;
            for (int i = 0, count = this.updateListeners.Count; i < count; i++)
            {
                var listener = this.updateListeners[i];
                listener.OnUpdate(deltaTime);
            }
        }

//Other code…
}

Ремарка: при необходимости можно также прикрутить методы FixedUpdate() и LateUpdate(), реализовав соответствующие интерфейсы: IFixedUpdateListener и ILateUpdateListener

Еще одним преимуществом написания кода без монобехов будет разделение ответственности на уровне проекта. Поскольку код игры пишется на чистом C#, то и все внесение изменений тоже будет решаться на уровне кода… Тем самым это уменьшает необходимость трогать игровые объекты при переписывании кода, и позволяет другим специалистам, таким как левел-дизайнерам работать на сцене параллельно с большей уверенностью, что они ничего не сломают :).

В результате оптимизации видим, что теперь монобехи больше не нужны, так как с игровой логикой теперь можно работать на уровне кода:

Проверяем работоспособность игры… Все работает!

Единая точка входа

Несложно обнаружить, что на игровой сцене нет единой точки входа, и запуск игры происходит как попало:

  1. Компоненты регистрируются в методе GameContextInstaller.Awake().

  2. Внедрение зависимостей происходит  в методе GameContextInstaller.Start().

  3. А игра вообще запускается вручную через инспектор.

Пока что это все выглядит немного ridiculous. Так что давайте сделаем единую точку входа в игру.

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

Например, давайте выделим ответственность новому классу GameLauncher, который будет заниматься запуском игры в одном месте.

 public sealed class GameLauncher : MonoBehaviour
    {
        [SerializeField]
        private GameContextInstaller installer;

        [SerializeField]
        private GameContext context;

        [ContextMenu("Launch Game")]
        public void LaunchGame()
        {
            this.installer.RegisterComponents();
            this.installer.ConstructGame();
            this.context.StartGame();
        }
    }

Если посмотреть на класс GameLauncher, можно заметить, что методы класса GameContextInstaller.RegisterComponents() и GameContextInstaller.ConstructGame() стали публичными. Теперь они вызываются в методе GameLauncher.LaunchGame(), а не через методы Awake() и Start(), как было ранее. Это сделано, для того, чтобы у разработчика всегда был контроль над управлением системы, и это хорошо :)

Опытные читатели, взглянув на класс GameLauncher, быстро заметят, что этот класс нарушает 2-й принцип SOLID Open-Closed, так при добавлении или удалении команды в процесс загрузки, нам придется изменять этот класс. Поэтому давайте отрефакторим GameLauncher, используя полиморфизм, чтобы порядок инициализации был более гибким:

public abstract class GameTask : ScriptableObject
    {
        public abstract Task Do();
    }

public sealed class GameLauncher : MonoBehaviour
    {
        [SerializeField]
        private bool autoRun = true;
        
        [SerializeField]
        private List<GameTask> taskList;

        private async void Start()
        {
            if (this.autoRun)
            {
                await this.LaunchGame();
            }
        }

        [ContextMenu("Launch Game")]
        public async Task LaunchGame()
        {
            foreach (var task in this.taskList)
            {
                await task.Do();
            }
        }
    }

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

Каждую задачу можно сделать ScriptableObject’ом, чтобы потом легко ее можно было добавить в список загрузки. Давайте посмотрим, как это выглядит в коде:

Задача регистрации компонентов:

 [CreateAssetMenu(
        fileName = "Task «Register Components»",
        menuName = "GameTasks/Task «Register Components»"
    )]
    public sealed class GameTask_RegisterComponents : GameTask
    {
        public override Task Do()
        {
            var installer = FindObjectOfType<GameContextInstaller>();
installer.RegisterComponents();
            return Task.CompletedTask;
        }
    }

Задача разрешения зависимостей:

 [CreateAssetMenu(
        fileName = "Task «Construct Game»",
        menuName = "GameTasks/Task «Construct Game»"
    )]
    public sealed class GameTask_ConstructGame : GameTask
    {
        public override Task Do()
        {
            var installer = FindObjectOfType<GameContextInstaller>();
            installer.ConstructGame();
            return Task.CompletedTask;
        }
    }

Задача запуска игры:

  [CreateAssetMenu(
        fileName = "Task «Start Game»",
        menuName = "GameTasks/Task «Start Game»"
    )]
    public sealed class GameTask_StartGame : GameTask
    {
        public override Task Do()
        {
            var gameContext = FindObjectOfType<GameContext>();
            gameContext.StartGame();
            return Task.CompletedTask;
        }
    }

Ремарка: В данном примере мы видим использование методов GameObject.FindObjectOfType, которые помогают найти ключевые компоненты на сцене. Несмотря на то, что производительность метода GameObject.FindObjectOfType напрямую зависит от кол-ва объектов на сцене, на этапе запуска это будет не критично, так как процесс загрузки не относиться к самому геймплею игры.

Ремарка: Если потребуется задача оптимизации, то можно сделать поиск объектов через метод GameObject.FindWithTag(“Game”). Поскольку Unity делает кеширование объектов по тегу в массивы, то такой подход будет гораздо быстрее. Ну или в самом-самом крайнем случае сделайте синглтон GameContext.Instance, к которому можно обращаться только на этапе загрузки…

Ну что ж, добавляем GameLauncher на сцену, определяем список задач:

Запускаем игру… Все работает :)

Теперь можно убрать класс GameContextInstaller и перенести его логику в задачи, чтобы упростить систему:

[CreateAssetMenu(
        fileName = "Task «Construct Game»",
        menuName = "GameTasks/Task «Construct Game»"
    )]
    public sealed class GameTask_ConstructGame : GameTask
    {
        public override Task Do()
        {
            var gameContext = GameObject
                .FindGameObjectWithTag(TagManager.GAME_CONTEXT)
                .GetComponent<GameContext>();
            
            var installers = GameObject
                .FindGameObjectsWithTag(TagManager.GAME_INSTALLER);   
            
            foreach (var installer in installers)
            {
                if (installer.TryGetComponent(out IGameServiceProvider serviceProvider))
                {
                    gameContext.AddServices(serviceProvider.GetServices());
                }

                if (installer.TryGetComponent(out IGameListenerProvider listenerProvider))
                {
                    gameContext.AddListeners(listenerProvider.GetListeners());
                }
            }
            
            foreach (var installer in installers)
            {
                if (installer.TryGetComponent(out IGameConstructor constructor))
                {
                    constructor.ConstructGame(gameContext);
                }
            }
            
            return Task.CompletedTask;
        }
    }

 [CreateAssetMenu(
        fileName = "Task «Start Game»",
        menuName = "GameTasks/Task «Start Game»"
    )]
    public sealed class GameTask_StartGame : GameTask
    {
        public override Task Do()
        {
            GameObject
                .FindGameObjectWithTag(TagManager.GAME_CONTEXT)
                .GetComponent<GameContext>()
                .StartGame();

            return Task.CompletedTask;
        }
    }

Финальная схема выглядит так:

Таким образом, у нас и получилась единая точка входа в игру. И если, например, необходимо еще подгрузить интерфейс игры и другие объекты на этапе загрузки, то эти процессы можно сделать до внедрения зависимостей:

А если нужно сделать несколько этапов пост инициализации: например, загрузить прогресс игрока — то это можно сделать после внедрения зависимостей.

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

Выводы

Таким образом, мы реализовали простую и оптимизированную архитектуру игры на Unity без Zenject на примере перемещения кубика.

Теперь в нашей архитектуре:

  1. Есть GameLauncher, который является точкой входа в игру и делает запуск по списку задач.

  2. Есть GameContext, который управляет состоянием игры и вызывает Update у элементов системы.

  3. Есть механизм разрешения зависимостей, который реализован в коде через инсталлеры и сервис-локатор.

  4. Вся игровая логика реализована на классах C#.

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

Ремарка: в будущем, если вы хотите описывать ваши инсталлеры более декларативно, то вам потребуется рефлексия и атрибуты для того, чтобы собирать слушателей и сервисы, а также же делать внедрение зависимостей. Например, у меня в проектах это выглядит примерно так:

public sealed class PlayerInstaller : GameInstaller
    {
        [GameService]
        [SerializeField]
        private Player player;

        [GameListener]
        private readonly MoveController moveController = new();

        [GameListener]
        private readonly CameraController cameraController = new()
    }

 public sealed class InputInstaller : GameInstaller
    {
        [GameService, GameListener]
        private readonly KeyboardInput keyboardInput = new();

        [GameService, GameListener]
        private readonly MouseInput mouseInput = new();
    }

Такую архитектуру я использую в своих проектах: 

  1. Tank Puzzlers

  2. Tribal Land

  3. Демо-проект для курса

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

Прилагаю ссылку на код к статье.

Также хочу порекомендовать вам еще один полезный урок, где изучим паттерн Model-View-Presenter на примере попапа (pop-up) магазина. Подробнее про урок можете узнать по этой ссылке.

На этом у меня все, всем спасибо за внимание :)

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


  1. DizzyJump
    19.04.2023 09:19

    Мне кажется стоило назвать цикл статей в духе "Отказываемся от zenject и пишем свой велосипед", оно бы лучше отражало суть написаного. Но я так и не увидел мотивационную часть, а зачем? Какие преимущества важные для данного проекта мы получили отказавшись от библиотеки, которая является в некотором смысле индустриальным стандартом di-фреймворка в юнити и написав свой велосипед? Во имя чего всё это?


    1. StarKRE
      19.04.2023 09:19

      Привет! Спасибо за фидбек)

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

      Одна из ключевых идей, которую я хотел донести в статье это то, что разработчики сами могут построить свою простую архитектуру и заточить ее под без использования фреймворков Zenject или VContainer

      Почему мне фреймворки не нравятся:

      1. Во первых, они построены на инверсии управления и забирают у разработчика возможность управлять потоком исполнения. И если тебе нужно перестроить ход инициализации приложения, то это может очень быть проблематично.

      2. Во-вторых, если говорить конкретно про Zenject, то для меня это выглядит сложно. Нужно потратить немало времени на изучение фреймворка прежде чем приступать к реализации. Зачем это все: 1000 способов биндингов, различные контексты, встроенные фабрики, пулы, интерфейсы IInitializable, ITickable, если это все мне может не понадобиться в общем случае?

      Поэтому для меня гораздо проще будет сделать свое решение по принципу KISS (Keep It Simple Stupid), которое будем понятным и простым для разработчиков. И заложить в архитектуру только те опции, которые будут нужны в проекте. И если нужно управлять ими или оптимизировать.

      Уже говорил неоднократно, что "серебряных пуль не бывает"!

      Надеюсь, ответил на вопрос. Если в команде нету опыта проектирования архитектуры, то берите Zenject или VContainer :)


      1. HexGrimm
        19.04.2023 09:19

        Я бы пожалуй возразил про:

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

        Так как управлять потоком выполнения на самом деле мешает Юнити и неопределённость вызова методов у MonoBehaviour. Как раз это и корень проблемы. А например конструкторы - не важно кем выполненные, не являются такой проблемой. И не важно, выполняет их контейнер активатором или сам рантайм.

        И действительно, в Zendject написано 90% бесполезного мусора, который только мешает понимать суть проблемы, тк этот сахар живёт по правилам убогого фреймворка. Но сама суть Di от этого хуже не становится, просто нужно использовать инъекцию только в конструктор и абстрактные фабрики. Для всех случаев в разработке игр этого будет достаточно. Единственный ООП кейс в котором будет неудобно, это параметризированный конструктор, где сигнатура зависит от типа. Такое скорее всего можно обойти абстрактным методом Init(x, y) у общего типа, и скрыть внутри своей фабрики, или наоборот, сделать пост-иньекцию в метод, а сами конструкторы сделать одинаковыми.


        1. StarKRE
          19.04.2023 09:19

          По поводу конструкторов и методов пост-инъекции вопросов нет. Тут речь идет про то, что должна быть единая точка входа в программу, и разработчик решает в какой момент и куда и как делать инъекцию, а не так, что через Script Execution Order фреймворк сам побежал "шурудить" все игровые объекты по сцене через рефлексию


          1. HexGrimm
            19.04.2023 09:19

            Всё так, но почему в примерах статьи так много наследников от MonoBehaviour когда это обычные классы которые нуждаются в конструкторе?

            Не нужно иметь под рукой сервис локатор или метод GetService, если в конструктор всё приходит, и выстроено в правильном порядке. А то что накликано в юнити мышкой, ну фиг знает, скорее всего DI нарушен многократно.


            1. StarKRE
              19.04.2023 09:19

              Хм, к концу статьи у нас получилось всего 4 монобеха в системе)

              Вот ссылка на код-базу

              ----------

              Монобехами являются GameContext, GameLauncher, PlayerInstaller & InputInstaller, а вся игровая логика сделана на обычных классах

              GameContext — монобех, чтобы можно было вручную делать старт/паузу игры и видеть стейт

              GameLauncher — монобех, чтобы запускать игру с помощью списка задач, которые можно выставлять в инспекторе

              А инсталлеры — PlayerInstaller & InputInstaller чтобы можно было через инспектор видеть состояния классов и дебажить их (Но можно их сделать и ScriptableObject'ами)

              ----------

              По поводу сервис-локатора согласен, что класс, который выполняет бизнес-логику не должен иметь зависимость на ServiceLocator, а должен получать зависимости через конструктор или в метод пост-инъекции. И если у тебя есть инсталлеры, которые хранят классы с бизнес-логикой, то ты можешь поручить им ответственность за создание экземпляров этих классов и внедрения зависимостей.

              Просто внедрение зависимости не ограничивается получением сервисов из ServiceLocator'а. Про накликано в юнити мышкой пока что не оч понял, скорее всего речь про инспектор)


      1. redHurt69
        19.04.2023 09:19

        Все еще довольно сомнительно.
        Не понимаю кто в здравом уме между вариантами "костылить свой фреймворк ради IoC" и "посмотреть 5-минутное видео про Zenject" выберет второе.
        Если опять же у нас архитектура делается под "сроки, команду, бюджет и технологии", то в каком варианте этих составляющих, предпочтение сделается в пользу создания своего сервис локатора, который пытается быть похожим на di вместо использования "индустриального стандарта di-фреймворка"?)
        Я бы понял еще если бы шел разговор о том, что "вот Zenject не подходит, у него нет такого-то списка фичей, которые нам тут нужны", но тут мы имеем всего лишь пример с кубиком и по сути вся речь о том "как сделать тоже самое, только хуже и свое".


        1. StarKRE
          19.04.2023 09:19

          Готов поспорить, что мое решение отработает быстрее чем Zenject


          1. redHurt69
            19.04.2023 09:19

            А никто не говорит про скорость работы. Да и козырять ей на примере с кубиком очень уж сомнительно) Уверен, что после 5 лет разработки можно понять, что нет смысла хвастаться из-за сэкономленной миллисекунды, особенно если потратил гораздо больше времени, костыля свой фреймворк) Хотя я бы с радостью посмотрел на эти тесты
            Ну и для кучи можно еще VContainer протестировать, раз уж мы вдруг заговорили про скорость)


        1. StarKRE
          19.04.2023 09:19

          Когда я писал первую часть статьи, я обозначил проблемы, которые нам нужно решить. Напротив каждой задачи буду ставить плюсик, есть ли эта фича в Zenject:

          1. Механизм внедрения зависимостей (+)

          2. Оптимизация архитектуры и уход от монобехов (+)

          3. Единая точка входа в приложение (-)

          4. Порядок инициализации игры (-)

          5. Работа с состояниями игры (старт/пауза/завершение) (-)

          6. Оповещение компонентов системы об изменение состояния игры (+-)

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


          1. HexGrimm
            19.04.2023 09:19

            Мне кажется что 3 и 4 пункты как раз в Zenject есть. Это момент когда у рутового объекта будет вызван единственный Resolve. Этот объект и может иметь единственную точку входа и единственный Update() метод во всём проекте, и дальше работать уже со своим деревом просто как c C# классами.

            А вот как раз 5 и 6 и не должны быть в функционале контейнера, и если в сахаре Zenject это есть, то использовать то не стоит.


            1. StarKRE
              19.04.2023 09:19

              И да и нет :)

              Метод Resolve не может быть единственным, поскольку у каждого контекста инициализация и разрешение зависимости в своём Di контейнере происходит в разные моменты выполнения программы. Например, у ProjectContext'а в момент его создания, у GameObjectContext'а — в методе Construct, у SceneContext'а — в Awake() (причем первый в ScriptExecutionOrder'е)

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

              ---

              По поводу Update(), он тоже не единственный, так как этот метод вызывается у каждого MonoKernel, который крепиться дополнительно к каждому гейм-обджекту, на котором висит компонент ProjectContext / SceneContext / GameObjectContext

              ---

              5 и 6 пункт, согласен :)


          1. DizzyJump
            19.04.2023 09:19
            +2

            Управления жизненным циклом приложения и создание точки инициализации можно через бутстрап сцену сделать. Зенжект тут ничем не мешает совершенно.


            1. StarKRE
              19.04.2023 09:19

              Да, так обычно и делается :)


      1. DizzyJump
        19.04.2023 09:19

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

        Ну и я вцелом не согласен что лучше писать свой велосипед на любой чих потому что он комуто-там (прежде всего автору) понятен. Прелесть использования широко распространенных библиотек и фреймворков как раз в том что новый разработчик если знаком с тем же зенжектом может сразу плюс минус идти в бой, даже на относительно крупном проекте. Но нет никаких шансов нанять инженера который раньше уже катался на всех ваших велосипедах, которые вы напишете для реализации давно решенных задач.


        1. StarKRE
          19.04.2023 09:19

          Да, тут я согласен, что популярность фреймворков упрощает интеграцию новых разработчиков в проект, и это, действительно, оправданный выбор использовать Zenject с точки зрения бизнеса.

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

          Таким образом, вы можете сделать все то же самое, и это будет работать быстрее, проще и компактнее, потому что вы взяли только то, что вам нужно для вашего проекта и отсекли все лишнее :)

          А вопрос велосипедов — это вопрос опыта разработки архитектуры

          И если в команде нет такого опыта, то лучше делегировать ответственность фреймворку и не париться ????