Всем привет! Меня зовут Григорий Дядиченко, и я технический продюсер. Обсуждая по работе архитектуру речь зашла про UI. Есть много разных подходов к тому как работать с графическим пользовательским интерфейсом в Unity. Хотелось предложить один вариант реализации переходов по интерфейсу плюс заодно показать пример использования атрибутов и рефлексии в C#.

Концепт системы пользовательского интерфейса

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

  1. Поддержка кнопки назад (на андроид)

  2. Связывание разных префабов

  3. Диплинки

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

Все экраны умеют открываться по url. Это нам даёт.

Логичный диплинкинг. То есть если из рекламного баннера в интернете пользователь должен попасть на определённую страницу игрового магазина — мы просто обрабатываем ссылку. Если в игре появится баннер, промо-кнопка и подобный функционал, то по нажатию мы опять же переводим по урлу с параметрами.

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

Не включать лишние зависимости во взаимоотношение между экранами. Открытие любого экрана происходит через строковую команду с параметрами посредством менеджера роутов. Поэтому у нас не бывает ситуации где "Экран А передаёт экрану Б объект В". Система мотивирует манипулировать идентификаторами, параметрами и прочими объектами, которые можно представить в виде строки. Данные строки не создают ненужных зависимостей между окнами. Это мотивирует разработчика такой системе писать по схеме, что у окна есть какой-то привязанный к нему сторадж данных или контроллер. И окно открываясь с параметрами передаёт их в сторадж или контроллер и открывается отрисовываясь в соответствии с нужными данными.

Недостатки у этой системы есть. Её нужно поддерживать, и она без защиты от дурака. Плюс может кто-то дополнительные напишет в комментариях. Одной из несущественных проблем является быстродействие. Конечно для любителей экономить на спичках рефлексия и SendMessage — страшный грех, но так как система относится только к редким операциям, которые не обязаны быть быстрыми, я тут не вижу особой проблемы.

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

Итак, начнём делать нашу систему. В первую очередь нам нужно научиться указывать роуты. И тут нам поможет одна замечательная штука в C#.

Атрибуты

Атрибуты в шарпе — это очень полезный механизм. Сам по себе атрибут, это по сути класс отнаследованный от System.Attribute. Но что это даёт? Это позволяет вам записывать метаданные в ваши классы в такой форме.

Что нам и пригодится. Эти данные в дальнейшем можно достать с помощью механизмов рефлексии в шарпе. Рефлексия же, это по своей сути механизм доступа к метаданным сборок (assembly), модулей и типов. Через неё вы можете узнать какие методы есть в типе, какие типы есть в сборке и так далее. Можно динамически создавать объекты типов. В общем для ряда задач весьма удобная штука упрощающая жизнь разработчика.

Помимо разбираемого в статье примера я часто использую её для "читов". У вас допустим в игре есть читы, вы хотите, чтобы существовали текстовые команды, которые что-то делают. Вы просто создаёте паттерн команда на абстрактном классе CheatCode, где в атрибут пишете название команды, а дальше по патерну команда у вас должен быть в нём метод Execute. Условно как-то так:

[CheatName("add_money")]
public abstract class AddMoney : CheatCode
{
    public override void Execute(Dictionary<string, string> param)
    {
        User.Current.AddMoney(param["amount"]);
    }
}

public abstract class CheatCode
{
    public abstract void Execute();
}

Дальше через рефлексию собираются все классы, которые являются наследниками CheatCode. Смотрится, какой атрибут у них. И через Activator создаётся инстанс команды, который кладётся в словарь забрав из атрибута название команды. Как при этом парсятся параметры будут понятно из примера с роутером. Читы — это не такая большая и интересная система, поэтому её я опишу как-нибудь в блоге. Для статьи на хабр на мой взгляд там недостаточно информации, если не повторяться ещё раз разъясняя тему рефлексии и атрибутов.

Пишем наш роутер

Но мы отвлеклись. Начнём писать нашу систему роутинга. И для этого сначала реализуем атрибут. В атрибут мы будем записывать наш роут до окна.

using System;

namespace Nox7atra.UIRouter
{
    [AttributeUsage(AttributeTargets.Class)]
    public class UIRouteAttribute : Attribute
    {
        public string Route;
        public UIRouteAttribute(string fullRoute)
        {
            Route = fullRoute;
        }
    }
}

То что мы указываем в конструкторе атрибута будет его параметрами при присвоении его классу. Слово Attribute сократится. То есть написав такое вы уже можете указывать атрибут как здесь.

[AttributeUsage(AttributeTargets.Class)] ограничивает что данный атрибут мы можем использовать только с классами. В общем случае атрибуты можно писать для методов, для интерфейсов, конструкторов, делегатов и так далее. Атрибут мы завели и даже записали. Теперь как нам его использовать?

Заведём класс UIRouteManager. Пока начнём с того, что соберём все классы, которые используют атрибут в словарь. Смотрим мы по всем классам приложения.

using System;
using System.Collections.Generic;

namespace Nox7atra.UIRouter
{
    public static class UIRouteManager
    {
        private static Dictionary<string, Type> _routesData;
        static UIRouteManager()
        {
            var assemblies = AppDomain.CurrentDomain.GetAssemblies();
            _routesData = new Dictionary<string, Type>();
            foreach (var assembly in assemblies)
            {
                var types = assembly.GetTypes();
                foreach(Type type in types)
                {
                    var attributes = type.GetCustomAttributes(typeof(UIRouteAttribute), true);
                    if (attributes.Length > 0)
                    {
                        var uiRoute = attributes[0] as UIRouteAttribute;
                        _routesData[uiRoute.Route] = type;
                    }
                }
            }
        }
    }
}

По сути мы тут:

  1. Получаем ассембли из домена приложения и заводим словарь для хранения результата.

  2. Перебираем все ассембли.

  3. В ассембли перебираем все типы.

  4. В типах проверяем есть ли наш атрибут.

  5. Если атрибут есть, то пишем в словарь тип (который нам потом пригодится) и значение роута в атрибуте.

Итак, мы собрали все наши типы с заданным атрибутом. Давайте попробуем открыть нужно окно. И тут уже проще воспользоваться механизмами Unity. Допишем в наш класс два метода. OpenUrl и ParseParams:

using System;
using System.Collections.Generic;
using UnityEngine;
using Object = UnityEngine.Object;

namespace Nox7atra.UIRouter
{
    public static class UIRouteManager
    {
        private static Dictionary<string, Type> _routesData;

        public static void OpenUrl(string route)
        {
            var payload = ParseParams(route, out route);
            if (_routesData.ContainsKey(route))
            {
                var type = _routesData[route];
                var obj = Object.FindObjectOfType(type, true) as MonoBehaviour;
                if (obj != null)
                {
                    obj.gameObject.SetActive(true);
                    obj.SendMessage("Show", payload);
                }
                else
                {
                    Debug.LogError($"There no object of type:{type.Name}");
                }
            }
            else
            {
                Debug.LogError($"There no route with name: {route}");
            }
        }

        private static Dictionary<string, string> ParseParams(string fullRoute, out string route)
        {
            var routeEnd = fullRoute.Split("/")[^1];
            var result  = new Dictionary<string, string>();
            var routeParams = routeEnd.Split("?");
            if (routeParams.Length > 1)
            {
                var parameters = routeParams[1].Split("&");
                if (parameters.Length > 0)
                {
                    foreach (var param in parameters)
                    {
                        var data = param.Split("=");
                        if (data.Length > 1)
                        {
                            result[data[0]] = data[1]; 
                        }
                    }
                }
                route = fullRoute.Split("?")[0];
            }
            else
            {
                route = fullRoute;
            }
            return result;
        }
        
        static UIRouteManager()
        {
            var assemblies = AppDomain.CurrentDomain.GetAssemblies();
            _routesData = new Dictionary<string, Type>();
            foreach (var assembly in assemblies)
            {
                foreach(Type type in assembly.GetTypes())
                {
                    var attributes = type.GetCustomAttributes(typeof(UIRouteAttribute), true);
                    if (attributes.Length > 0)
                    {
                        var uiRoute = attributes[0] as UIRouteAttribute;
                        _routesData[uiRoute.Route] = type;
                    }
                }
            }
        }
    }
}

В ParseParams мы обрабатываем наш роут, которым мы открываем окно. Уметь открывать роуты без параметра не имеет смысла. Возьмём для примера наш MyAwesomePopup со скрина выше. Это попап открываемый с какими-то данными. И чтобы передавать данные по ссылке нам нужно их парсить. Так как я предпочитаю всё делать по стандартам (хотя тут для демонстрации не полная поддержка стандарта), то мы просто парсим стандартный get запрос. В гет запросе у нас есть урл выглядит как-то так "url?param1=value&param2=value". Так что мы просто вычленяем это в словарь.

Чтобы не мучаться пока с многопоточностью мы каждый раз создаём новый, а не имеем очищаемый буффер, чтобы не сорить в память. Хотя наверное можно сразу завести буффер, так как используя Unity методы всё равно нужен будет контекст главного потока.

А роут мы вычленяем уже без параметров, чтобы получить из словаря нужный нам тип. Дальше через механизм Unity FindObjectOfType по типу находим нужное нам окно. Бул параметр отвечает за то, чтобы найти даже выключенное. Приводим его к MonoBehaviour, так как в таком случае оно им будет (да и по логике системы должно быть). Включаем объект так как без этого не сработает SendMessage и делаем SendMessage. SendMessage в Unity по сути так же работает через рефлексию и отправляет классу "вызови этот метод с этими данными". И вуаля наша система работает. Окна открываются и даже с параметрами.

Но тут стоит сделать оговорку. Это место можно сделать оптимальнее. Скажем завести контейнер с регистрацией окон, завести общий класс окна и сделать, чтобы все такие окна наследовались от него. И после без всяких FindObjectOfType и SendMessage просто вызывать абстрактный метод Show с параметром в виде словаря, находя окно в словаре <Type, Наш базовый класс>.

Я сознательно не захотел так делать, чтобы не вводить наследование. Концепт от этого не сильно страдает. Но мне не понравились две вещи. Подключая такую штуку библиотекой нужно наследоваться от какого-то базового класса все окна. Что не хорошо. Хотя с другой стороны вся эта система требует определённого подхода к проектировке GUI, поэтому может это не так важно. И метод Show. Текущая реализация позволяет делать так.

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

А где же удобная кнопка назад?

И теперь я приведу полную реализацию класса менеджера.

using System;
using System.Collections.Generic;
using UnityEngine;
using Object = UnityEngine.Object;

namespace Nox7atra.UIRouter
{
    public static class UIRouteManager
    {
        private static Dictionary<string, Type> _routesData;
        private static Stack<string> _screensStack;
        private static string _mainScreenRoute;
        public static void OpenUrl(string route)
        {
            _screensStack.Push(route);
            var payload = ParseParams(route, out route);
            if (_routesData.ContainsKey(route))
            {
                var type = _routesData[route];
                var obj = Object.FindObjectOfType(type, true) as MonoBehaviour;
                if (obj != null)
                {
                    obj.gameObject.SetActive(true);
                    obj.SendMessage("Show", payload);
                }
                else
                {
                    Debug.LogError($"There no object of type:{type.Name}");
                }
            }
            else
            {
                Debug.LogError($"There no route with name: {route}");
            }
        }

        private static void HideUrl(string route)
        {
            var payload = ParseParams(route, out route);
            if (_routesData.ContainsKey(route))
            {
                var type = _routesData[route];
                var obj = Object.FindObjectOfType(type, true) as MonoBehaviour;
                if (obj != null)
                {
                    obj.SendMessage("Hide", payload);
                }
                else
                {
                    Debug.LogError($"There no object of type:{type.Name}");
                }
            }
            else
            {
                Debug.LogError($"There no route with name: {route}");
            }
        }
        public static void ReleaseLastScreen()
        {
            if (_screensStack.Count > 0)
            {
                _screensStack.Pop();
            }
        }

        public static void SetMainScreenRoute(string route)
        {
            _mainScreenRoute = route;
        }
        
        public static void ProceedBack()
        {
            if (_screensStack.Count > 0)
            {
                var lastScreen = _screensStack.Pop();
                HideUrl(lastScreen);
                if (_screensStack.Count > 0)
                {
                    var prevScreen = _screensStack.Pop();
                    OpenUrl(prevScreen); 
                }
                else
                {
                    OpenUrl(_mainScreenRoute);
                }
            }
            else
            {
                OpenUrl(_mainScreenRoute);
            }
        }
        
        private static Dictionary<string, string> ParseParams(string fullRoute, out string route)
        {
            var routeEnd = fullRoute.Split("/")[^1];
            var result  = new Dictionary<string, string>();
            var routeParams = routeEnd.Split("?");
            if (routeParams.Length > 1)
            {
                var parameters = routeParams[1].Split("&");
                if (parameters.Length > 0)
                {
                    foreach (var param in parameters)
                    {
                        var data = param.Split("=");
                        if (data.Length > 1)
                        {
                            result[data[0]] = data[1]; 
                        }
                    }
                }
                route = fullRoute.Split("?")[0];
            }
            else
            {
                route = fullRoute;
            }
            return result;
        }
        
        static UIRouteManager()
        {
            _screensStack = new Stack<string>();
            var assemblies = AppDomain.CurrentDomain.GetAssemblies();
            _routesData = new Dictionary<string, Type>();
            foreach (var assembly in assemblies)
            {
                var types = assembly.GetTypes();
                foreach(Type type in types)
                {
                    var attributes = type.GetCustomAttributes(typeof(UIRouteAttribute), true);
                    if (attributes.Length > 0)
                    {
                        var uiRoute = attributes[0] as UIRouteAttribute;
                        _routesData[uiRoute.Route] = type;
                    }
                }
            }
        }
    }
}

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

using System.Collections.Generic;
using TMPro;
using UnityEngine;

namespace Nox7atra.UIRouter.Samples
{
    [UIRoute("main_panel/card_popup")]
    public class MyAwesomePopup : MonoBehaviour
    {
        [SerializeField] private MyAwesomeStorage _awesomeStorage;
        [SerializeField] private TMP_Text _title;
        [SerializeField] private TMP_Text _text;
        
        public void Show(Dictionary<string, string> data)
        {
            gameObject.SetActive(true);
            var myAwesomeData = _awesomeStorage.GetDataById(data["id"]);
            _text.text = myAwesomeData.AwesomeText;
            _title.text = myAwesomeData.AwesomeTitle;
        }

        public void Hide()
        {
            UIRouteManager.ReleaseLastScreen();
            gameObject.SetActive(false);
        }
    }
}

То есть чтобы завести функциональность с поддержкой кнопки назад нужно. Сделать метод Show (со словарём или без параметров), сделать метод Hide, прописать в метод Hide UIRouteManager.ReleaseLastScreen(); и вот это всё уже звучит слишком сложно. Поэтому возможно обойтись без наследования было плохой идеей. Предлагаю обсудить это в комментариях.

Сама по себе реализация функции довольно простая. Так как для открытия экранов в такой системе мы используем только их роуты, то мы просто пишем их в стек, и по кнопке назад убираем из истории экранов текущий экран, и открываем прошлый. Вот и всё. Дальше реализуется такой менеджер.

using UnityEngine;

namespace Nox7atra.UIRouter.Samples
{
    public class MyAwesomeRouteHandler : MonoBehaviour
    {
        private void Update()
        {
            if (Input.GetKeyDown(KeyCode.Escape))
            {
                UIRouteManager.ProceedBack();
            }
        }
    }
}

И мы получаем работающую между экранами кнопку back.

В заключении

Спасибо за внимание! В общем получился такой концепт. Его есть куда докрутить и доработать. Но на мой взгляд он получился неплохо, так как он мотивирует строить удобную и логичную архитектуру, делать экраны эффективно с точки зрения памяти (в такой системе все окна существуют на старте сцены, поэтому не будут создаваться инстансы нужных объектов) и те преимущества, что я описывал выше. И это точно в разы лучше менеджера экранов с енамом, который ещё и хранит стейт. Так как если у чего-то такого есть стейт — это потенциальная точка проблем.

Если статья вам понравилась, то подписывайтесь на мой блог в телеграм. Я стараюсь придумывать там интересные задачки, разбирать какие-то аспекты Unity и писать про интересные новости из мира Unity разработки.

Ссылка на репозиторий с полным решением.

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


  1. SadOcean
    19.01.2023 20:48
    +1

    Отказ от интерфейса или базового класса делает необходимой рефлексию, что странно - откажемся от базового контракта, зато сделаем неявный.

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

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

    Но идея с атрибутами и диплинками хорошая