Всем привет!

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

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

Теория


Реализацию подбора монет, хранения их количества и прочего, опускаем, так как сам может это сделать так, как ему нравится, и сразу перейдем к теории решения этой задачи. Так как элемент интерфейса, который информирует нас о количестве монет один, то логично бы его сделать синглтоном, однако, это вынуждает нас либо везде проверять на null этот объект, либо инстанцировать новый объект если его нет. Постоянные проверки на null — может породить кучу повторяющегося кода, и этого следует избегать. Если мы забудем такую проверку, то получим самую распространенную по статистике ошибку. Инстанциация нового объекта тоже может быть крайне неудобным решением, например, в тестовой сцене, где мы проверяем какие-то механики и нам не нужны элементы интерфейса, или нужны но не все.

Как известно, данную проблему можно решить паттерном «прокси». Если есть такие, кто не знает, то это легко гуглится, но вкратце напомню о нем. «Прокси» — прослойка, которая замещает объект и обеспечивает доступ к нему. Обращение к прокси будет нам гарантировать, что обращение будет произведено к существующему экземпляру объекта. Далее сам прокси определит, существует ли целевой объект или нет. Если существует, то прокси вызовет требуемый метод у целевого объекта.

Раз уж мы хотим таким образом избавиться от дублирования кода и постоянных проверок на null, то логично было бы задуматься, что синглтонов и прокси может быть много, но все они объединены тем, что они синглтоны и прокси. Универсальные классы c# помогут нам решить проблему дублирования кода при их описании.

Решение


Начнем с универсального синглтона.

public class Singleton<T> where T : new()
    {
        private static T instance;
        public static T Instance
        {
            get
            {
                if (instance == null)
                    instance = new T();
                return instance;
            }
        }
    }

Наследование от этого класса позволит нам обеспечить создание синглтона. Но он не подойдет для тех классов, которые мы хотим добавить к GameObject, так как они должны наследоваться от MonoBehaviour. А MonoBehaviour, в свою очередь, создается через Instantiate, а не через конструктор. Для него мы можем сделать свой универсальный синглтон.

public class MonoBehaviourSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
        public static T Instance;

        protected virtual void Awake()
        {
            Instance = GetInstance();
        }

        protected T GetInstance() {
            return this as T;
        }
}

Теперь наследование от этого класса будет давать нам синглтон для MonoBehaviour объектов.

Чтобы мы могли пользоваться прокси вместо требуемого объекта, нам необходимо создать общий интерфейс и реализовать его в классе CoinUI и его прокси.


public interface ICoinUI
{
         void SetCount(int count);
}

Код элемента интерфейса для отображения кол-ва монет может выглядеть так

public class CoinUI : MonoBehaviourSingleton<CoinUI>, ICoinUI
{
        public Text coinCount;

        private void Start()
        {

        }

        public void SetCount(int count)
        {
                 coinCount.text = count.ToString();
        }

}

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


public class CoinUIProxy : ICoinUI
{
        public void SetCount(int count)
        {
                 CoinUI.Instance?.SetCount(count);
        }

}

В случае, если нет инстанса CoinUI, то просто ничего не произойдет.

Перейдем к созданию универсального конструктора прокси.

public class UIProxy<proxyType> : Singleton<proxyType> where proxyType : new()
{ }

И это весь код. Выше мы уже описали класс-родитель Singleton, так что просто наследуем от него, указывая универсальный тип, в качестве типа синглтона.

Финальным классом для нашей конструкции станет своего рода провайдер — класс, который будет предоставлять нам объект, реализующий интерфейс ICoinUI.


public class UIProvider 
{
        public static ICoinUI CoinUI => UIProxy<CoinUIProxy >.Instance;
}

Данный провайдер вернет нам экземпляр класса CoinUIProxy, который при вызове SetCount проверит существует ли CoinUI, если существует, то вызовет у инстанса CoinUI метод SetCount и передаст значение.

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


UIProvider.CoinUI.SetCount(100500);

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

Выводы


Минусы данного подхода:

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

Плюсы:

  1. Нет проверок на null во всех местах, где вызывается изменение значения количества монет.
  2. Вызвать метод можно из любого класса и любого места в коде, не переживая за то, что мы забыли добавить объект отображающий кол-во монет на сцену.
  3. Можно спокойно менять внутреннюю структуру CoinUI, чтобы добиться желаемого отображения изменения количества монет — это никак не повлияет на работу основного кода.

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

Надеюсь, статья была полезной. Спасибо за внимание!

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


  1. evnuh
    17.10.2018 18:51
    +1

    ScriptableObject вам в помощь на замену синглтонов, эвентов и всего такого. Это как глобальный объект, который является ассетом, вы просто ссылаетесь на него везде, где он вам нужен.
    Посмотрите www.youtube.com/watch?v=raQ3iHhE_Kk, там как раз рассказывается как использовать SO для построения слабосвязанной архитектуры.


    1. FrostFT Автор
      17.10.2018 18:59

      ScriptableObject хорошая вещь, но есть некоторые отличия. В данном видео в примере с HP есть некоторый недостаток — НР устанавливается через Update. Можно сделать через события, но тогда все равно остается проблема с проверками на null, которые будут плодиться при каждом использовании ScriptableObject. В итоге после того, как будут импрувнуты все недочеты все сведется к схожей структуре, представленной в посте.
      ЗЫ ScriptableObject — очень крутой и мощный инструмент (с удовольствием его использую).


      1. evnuh
        17.10.2018 19:29

        Не очень понял в чём проблема Update и о каких проверках на Null вы говорите.
        Вот вы создали SO, назовём его Coins
        Дальше во всех местах, где вам нужен доступ к монетам (UI, Shop, PlayerLootManager) вы через ссылку на этот SO читаете/изменяете значения когда и как вам угодно. А в случае с монетками, side-эффект от того, что ScriptableObject является ассетом, он автоматом сериализуется и десериализуется, вам не нужна БД для хранения информации о кол-ве монет игрока, вы просто сейвите ассеты и значение сохранится на диск и будет прочтено и десериализовано в нужную переменную при следующей загрузке.


        1. FrostFT Автор
          17.10.2018 19:38

          Возможно у нас какое-то недопонимание, но пост о том, как сделать взаимодействие между элементами модели и вьюшками (или в других подобных ситуациях), а не как лучше хранить и передавать значения переменных. Если использовать ScriptableObject в качестве прослойки, то Вам придется делать ссылки (через public переменные) в скриптах на этот ScriptableObject. Желательно постоянно проверять, что по ссылке есть объект (что он не null). А это ведет к появлению побочного кода, если Вы про него забудете, то будут NullReferenceException. Чтобы не каждый кадр читать из ScriptableObject и конвертировать из int в string, а затем не присваивать каждый кадр значение в UI.Text, Вам все равно придется делать либо делегаты, либо ивенты. В итоге кода выйдет примерно столько же для обвязки, но будет дополнительная паблик переменная, которую надо заполнить ScriptableObject`ом, проверки на null при использовании. А если Вы захотите улучшить код так, чтобы избежать этого, то в итоге получите примерно то, что описано выше.


          1. evnuh
            17.10.2018 20:06

            Ну уменьшение связности между двумя зависимыми объектами подразумевает либо внешнюю ссылку на зависимость из одного в другой, либо dependency injection.
            Проверок на Null не надо, т.к. вы навряд ли забудете присвоить ссылке значение на SO, при первом запуске оно у вас и упадёт. А инстанциируется ScriptableObject всегда.

            По поводу обновления зависимых от SO частей при изменении значения — да, это нужно делать. И даже в вашем случае. Вы же схитрили, у вас, по-сути, нет модели Coins, у вас значение выставляется через UI и хранится в MonoBehaviour, привязанном к какому-то ГО в игре. А теперь представьте, что вы добавили магазин в игру и там тоже нужно показать кол-во монеток и обновлять их, например, если юзер купил монетки в магазине. Как вы это сделаете при вашей реализации?


            1. FrostFT Автор
              17.10.2018 20:31

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


              Вы тут тоже хитрите.

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


              Хранение Вы можете сделать как угодно, в том числе и в PlayerPrefs как значение, можете туда поместить строку, которую потом будете сериализовать в JSON или любым другим способом (хоть в SriptableObject хоть базу данных подключить).

              Монеты в данном случае — это пример для обозначения шаблона.

              Проверок на Null не надо, т.к. вы навряд ли забудете присвоить ссылке значение на SO, при первом запуске оно у вас и упадёт. А инстанциируется ScriptableObject всегда.


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

              Ну уменьшение связности между двумя зависимыми объектами подразумевает либо внешнюю ссылку на зависимость из одного в другой, либо dependency injection.


              Как раз в посте нет прямой ссылки между местом хранения «монет» и местом их отображения. Место и способ хранения может быть любым и универсальным. Реализации отображения их количества на UI не требуется знать, как они хранятся. UI — это вообще зависимая вещь, мы должны ей управлять, а не она сама должна знать архитектуру и пользоваться доступом к данным. А если мы ссылаемся на ScriptableObject, то получается наоборот. Поправьте меня, если я не прав.


              1. evnuh
                17.10.2018 21:02

                Вот у меня есть два текстбокса в двух разных местах (HUD и Shop), которые управляются каждый своим скриптом, плюс Shop может менять значение монет, HUD только показывает.
                В случае с SO, я просто обоим классам создам паблик ссылки на SO (Coins), подпишусь на эвент его обновления и буду обновлять текущее значение монет в текстбоксе по эвенту Coins.Updated, а обновлять через сеттер Coins, который и будет кидать эвент.

                То есть у меня есть глобально доступная модель, в которой хранится значение количества монет, чтение и записать этого значения только через эту модель (=SO), ни Shop ни HUD не имеют малейшего понятия как она хранится, откуда взялась и сколько ещё скриптов на неё подписаны и используют.

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


                1. FrostFT Автор
                  17.10.2018 21:13

                  Давайте попробуем прикинуть гибрид наших методов. Из Вашего возьмем Shop и HUD. Кто у нас меняет кол-во монет? Shop? Ну вот пусть он при присваивании кол-ва в переменную внутри ScriptableObject в этом же методе (сеттере) будет вызывать UIProvider.CoinUI.SetCount(value), который передаст новое значение в HUD.

                  Таким образом Вам не потребуется подписываться на события (и, возможно, отписываться от них, когда по какой-то причине придется удалить этот элемент интерфейса). Как-то читал одну книгу, так в ней было написано, что надо разделать классы и структуры (структуры не в плане struct. Есть объекты, предназначенные для хранения данных, а есть для работы с данными). Так вот ScriptableObject очень похож на объект для хранения, соответственно, было бы неплохо из него вообще любую логику постараться убрать. Хотя смешать хранение и обработку выглядит привлекательной затеей.


                  1. evnuh
                    17.10.2018 21:18

                    сеттер у Coins, а не у Shop.
                    То есть вы предлагаете, чтобы Shop что-то знал про CoinUI (HUD в моём случае)? Это ли не связность. А теперь представьте, что я решил убрать из HUD отображение монет и перенёс их в инвентарь. Мне менять Shop тоже придётся, т.к. они СВЯЗАНЫ.
                    Или теперь добавил LootMananger, который тоже меняет кол-во монет вместе с Shop. Мне и в него добавлять не только ссылку на SO, но и на CoinUI?


                    1. FrostFT Автор
                      17.10.2018 21:32

                      Во-первых, если Вы собираетесь менять кол-во монет из разных мест, то это породит несколько ссылок на ScriptableObject(Coins) — это 100% жеские связи, причем слабая связанность кода подразумевает абстрагивание от конкретных реализаций классов и переходить к абстракциям(например, interface).

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

                      И еще раз повторюсь, объект, отвечающий за хранение данных, желательно избавлять от логики преобразования.


  1. sith
    17.10.2018 19:44
    +1

    Спасибо за статью. Но я бы изменил заголовок. На «Как получить статическую ссылку на MonoBehaviour объект на сцене».

    При этом в Вашем примере проблема связности не решается — Вы просто подменяете прямую ссылку на label на непрямую ссылку через

    UIProvider.CoinUI.SetCount
    — т.е. Вы всё равно остаётесь привязаны и к UIProvider и к CoinUI

    Нет проверок на null во всех местах, где вызывается изменение значения количества монет.

    На самом деле проверка остаётся, но она скрыта за фасадом. Проверки можно избежать, если указать напрямую ссылку на эту label в MonoBehaviour который отвечает за её изменение.

    Вызвать метод можно из любого класса и любого места в коде

    Это и есть связывание. На самом деле про отображение монет должен знать только код, который отображает эти монеты (т.е. очень короткий и элементарный label.text = count). Этот код может быть и на самом MonoBehaviour монеты. «Другие классы» должны только изменять данные (число монет) и, в случае необходимости (если это не ecs) рассылать нужное событие.

    Можно спокойно менять внутреннюю структуру CoinUI

    Обычно, в Unity для работы с GameObject (в данном случае отображение числа монет) используют компоненты на самом объекте — например, MonoBehaviour на со слушателем нужного события (если это не ecs).

    И, самое главное. MonoBehaviour singleton это, чаще, антипаттерн и есть множество способов не добавлять его в свой код.


    1. FrostFT Автор
      17.10.2018 20:12

      Здравствуйте! А Вам спасибо за отзыв!

      «Как получить статическую ссылку на MonoBehaviour объект на сцене»


      В данном случае получается не ссылка на MonoBehaviour, а interface. Хотя название, возможно, слишком абстрактно.

      То, что проверки скрыты фассадом — это минус повторяющийся код, минус ошибки, в случае, если мы забываем писать повторяющийся код.

      CoinUI — действительно MonoBehaviour, но вместо того, чтобы подписываться на события он просто предоставляет свой интерфейс всем, кому он потребуется.

      Этот код может быть и на самом MonoBehaviour монеты.

      Тут непонятно. Вы предлагаете сделать у каждого объекта (условной монеты) ссылку на элемент интерфейса?

      Ну и естественно, когда кто-то пишет про паттерн сингтон, то никуда без «анипаттерна».

      Еще раз спасибо за развернутый ответ!


      1. sith
        17.10.2018 21:11

        Тут непонятно. Вы предлагаете сделать у каждого объекта (условной монеты) ссылку на элемент интерфейса?

        Не совсем я. Скорее, архитектура Unity. Каждая монета, которая отображается так или иначе через GameObject и Вам нужен MonoBehaviur чтобы работать с отображением монеты на этом GameObject.

        Добавляем на Coin GameObject скрипт Coin MonoBahaviour с сылкой на нужную label и слушателем, который узнает о том, что число монет изменилось. Это будет самым простым, эффективный и несвязным решением. Этот же скрипт можно будет добавить и на другой GameObject где нужно поменять label.

        Конечно, всё это не относится к ECS.


        1. FrostFT Автор
          17.10.2018 21:20

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


          1. sith
            17.10.2018 21:47

            Нет, не придётся вручную. Код изменения количества монет в моём случае вообще ничего не знает про UI — он только рассылает события. MonoBehaviour на монете (который там есть в любом случае) слушает это событие и изменяет label.


  1. Griboks
    17.10.2018 20:02
    +2

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

    Правильно — это заменить несколько строчек кода на 6 классов?
    Так писать неправильно?
    static CoinUI Instance;
    void Awake()=>Instance=this;
    public static void SetCount(int x)=>Instance?.coinCount.text=x.ToString();


    1. FrostFT Автор
      17.10.2018 20:19
      -1

      Спасибо за ответ.

      Когда Вам потребуется еще 1 сингтон, то Вы сможете его просто отнаследовать, а не дублировать одни и те же строки. Если Ваш проект будет состоять более чем из одного подобного элемента (случая), то скорее всего в итоге кода будет написано меньше.

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

      ЗЫ Ваш пример более чем достоин права на существование.


  1. Leopotam
    17.10.2018 20:46
    +3

    Простой кейс — я вешаю 2 компонента в сцену, наследующихся от Singleton — что произойдет в итоге?
    Второй кейс — что произойдет при смене сцены?
    Третий кейс — CoinUI.Instance?.xxx будет возвращать не null всегда в редакторе (даже если не проинициализирован), потому что там перегружен оператор сравнения для всех наследников Component: blogs.unity3d.com/ru/2014/05/16/custom-operator-should-we-keep-it

    В чем был смысл статьи? Синглтонов уже понаписано очень много (в том числе и на хабре) и разной степени паршивости. Вариант из статьи — один из худших.


    1. FrostFT Автор
      17.10.2018 21:00

      1. Странно пытаться 2 раза использовать сингтон. По ссылке будет возвращать тот, который инициализировался позже. Непонятно в чем вопрос, если Вы разбираетесь в самом простом паттерне.
      2. Ничего не будет, если не будет требуемого элемента на сцене, то просто не пройдет проверка на null и ничего не произойдет.
      3. Тут Вы ошибаетесь от слова «совсем». Попробуйте таким образом обратиться к объекту, которого нет на сцене и сами все поймете.

      Спасибо за оценку варианта синглтона, дайте ссылку на лучший, чтобы я переделал код у себя.


      1. Leopotam
        17.10.2018 21:36

        1. Странно пытаться 2 раза использовать сингтон.

        Нет, не странно. Можно по ошибке повесить его 2 раза и ничего для разруливания этой ситуации в коде не предусмотрено — это нужно сделать. Т.е нужно думать не только про создание его из кода, но и что будет, когда его как обычный MonoBehaviour кто-то попытается повесить на GameObject в сцене.

        2. Ничего не будет, если не будет требуемого элемента на сцене

        Я говорю про смену сцены с той, где синглтон был на ту, где его нет. Подскажу — статик поле нужно занулять при смерти GameObject, или будет утечка памяти. Этого тоже нет в коде. Отдельный кейс, когда меняем сцену с той, в которой был синглтон на ту, где он тоже есть — один из них должен умереть и ссылка на инстанс должна остаться валидной.

        3. Тут Вы ошибаетесь от слова «совсем».

        Да, перепутал с "??". Вот тест:
        gist.github.com/Leopotam/d23521ea1f75cb2ad2d72ce3cf6e701c
        Нужно прогнать один раз, потом заменить Test на Test1 и прогнать снова. Код вроде как одинаковый по логике, но вот работает по-разному.

        дайте ссылку

        Я пользуюсь (все меньше и меньше) вот таким велосипедом.
        Позволяет работать не только с монобехами и SO, но и с любыми классами единообразно. Пример можно посмотреть тут.


        1. FrostFT Автор
          17.10.2018 21:45

          Благодарочка за ссылки.

          По второму пункту надо проверить, но думаю, что логика может быть такой:

          1. инициализация объекта — присваивание статик значению ссылки на оъект.
          2. переход на сцену — дестрой объекта. статик переменная ссылается на задестроенный объект, которого нет, он (null).
          3. при обращении к объекту от прокси выполнение будет отклонено, так как там null в instance.

          Если будет не так, я сообщу.


          1. Leopotam
            17.10.2018 21:48

            2. переход на сцену — дестрой объекта. статик переменная ссылается на задестроенный объект, которого нет, он (null).

            Он задестроен в нейтиве, но не в managed части. Т.е ссылка висит, обращение по ней вызывает эксепшн, но она не null. Чтобы занулять ее нужно ловить OnDestroy или что-то типа того.


            1. FrostFT Автор
              18.10.2018 00:14

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

              Все как и писал выше в комменте — логика именно такая.


  1. FrostFT Автор
    17.10.2018 20:54
    -3

    Всем спасибо за ответы! Некоторые весьма полезны.


  1. rumyancevpavel
    17.10.2018 22:19
    +1

    Не вижу ничего общего между «слабосвязанной» архитектурой и той кучей связанных между собой как вермишель объектов которые вы описали. Для подобный решений отлично подходят ScriptableObjects, а loose coupled architecture не предназначена для избежания проверок на null.


  1. wlbm_onizuka
    18.10.2018 06:39

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

    Начнем с универсального синглтона

    Синглтон это не паттерн, а анти-паттерн, который превращает код в ужасную лапшу, т.к создает неявные зависимости в коде класса. Все зависимости должны быть явными и передаваться в конструктор. Почитайте про Dependency Injection.


  1. Tutanhomon
    18.10.2018 11:40

    Вы еще своим подходом провоцируете гонки в Awake методах. Будете потом ScriptExecutionOrder тягать? Такое себе решение.


  1. KHH
    18.10.2018 12:35

    Слабо связная архитектура, это как минимум архитектура на событиях и сигналах.