Здравствуйте уважаемые читатели! Сегодня я хотел бы затронуть тему хранения UI-элементов и доступа к ним, а также узнать, как вы это делаете в своих проектах (и не обязательно в Unity).
Дело в том, что когда я решил сделать один проект, он должен был полностью быть связан с UI-элементами. Тогда я задумался о том, как мне вызывать элементы интерфейса, а самое главное - где их хранить?
Вступление
Поискав в интернете, я видел лишь то, что даже в уроках по Unity разработчики проводят классическую инициализацию элементов (т.е. через Inspector, а поля у них открытые, хоть кто-угодно может изменить их). Очевидно, такой подход плох тем, что загромождает класс множеством экземпляров разных объектов (Text, Image, etc.), но самое ужасно то, что логика оперирования с игровыми данными идёт в одном объекте вместе с оперированием над UI-элементами. Проще говоря, это рушит некоторые принципы ОО-проектирования, что является не особо хорошей практикой.
С другой стороны это облегчает работу над небольшими проектами, которые можно сделать за пару дней (классический пример "крестики-нолики"), однако я хочу поговорить о том, как же всё-таки нам оперировать с UI для более серьезных проектов (не AAA конечно, хотя кто знает какой код пишется в больших компаниях).
Итак, у меня было несколько интересных идей на тему того, как же можно сделать не просто хранение элементов, но и оперирование с ними. Кто-то может сказать: "а как же такие архитектурные решения как MVC или MVVM? Это же отличное решение для такой примитивной задачи!1!!" - Конечно, существуют разные подходы к решению подобных задач, но здесь всё не так просто, как вам может показаться (имею в виду среду Unity)
Ох этот дивный API
Когда я столкнулся с данной проблемой, первая идея, которая пришла мне в голову - сделать общую контейнер-структуру и делать в тех классах, где нужно оперировать с UI - закрытый экземпляр этой структуры, а в самой структуре сделать список всех элементов и доступ к ним.
Вот так примерно и выглядела структура данных:
[Serializable]
public struct StorageUIElements
{
[SerializeField]
private List<RectTransform> elements;
private Component lastElement;
internal T GetElement<T>(byte id) where T : Component
=> elements[id].GetComponent<T>();
internal T GetElement<T>(string name) where T : Component
=> elements.Find(element => element.name.Contains(name)).GetComponent<T>();
internal T GetLastElement<T>() where T : Component
=> lastElement.GetComponent<T>();
internal T GetElementIterator<T>(byte id) where T : Component
{
if (lastElement == null)
{
lastElement = GetElement<T>(id);
return lastElement as T;
}
else return lastElement as T;
}
internal T GetElementIterator<T>(string id) where T : Component
{
if (lastElement == null)
{
lastElement = GetElement<T>(id);
return lastElement as T;
}
else return lastElement as T;
}
internal void ClearLastElement() => lastElement = null;
}
В данном случае - метод "GetElementIterator" был сделан для повторных вызовов к одному и тому же элементу и если нужно будет переключиться на другой элемент - предварительно вызываем метод "ClearLastElement", подробнее о таком подходе смотрите далее в разделе "Об оптимизации".
Но, возможно кто-то уже успел заметить, что обобщения ограниченны... ограниченны... Правильно - классом Component. "Но как же так? Мы ведь может получать из этих элементов абсолютно любой компонент, это ведь нехорошо..." - да, так и есть, а теперь об ограничениях.
Дело в том, что в мы не можем сделать так, чтобы наш метод принимал более одного ограничителя по классу (такой запрет имеется в самом языке C#), но мы можем добавлять разное кол-во интерфейсов, прямо как при наследовании класса. Так вот, дело в том, что при изучении архитектуры компонентов в Unity (таких как Text, Image etc.) я увидел, что большая часть компонентов никак не взаимосвязана, то есть не имеют общего класса. Точнее имеют, но всё что их связывает - класс "RectTransform" (который как раз используется при инициализации списка). Например класс Text и Image базируются на основе класса Graphic, в то время как, например, Button - нет, но наследуется от Selectable, а CanvasGroup и вовсе от Component! (от Component Карл! ).
Вот так грубо представлена иерархия компонентов UI (показаны лишь основные компоненты):

Теперь решите задачку: Как хранить в N'ом количестве объектов, которые должны выводить информацию на экран N'ое кол-во элементов UI, с условием, что кол-во может увеличиваться или уменьшаться, а также мы бы имели доступ к любому свойству нужного элемента И не хранили бы все элементы в кучке в каждом N'ом объекте?
Я буду рад услышать ответ, ведь, возможно, вы поможете найти самый удобный подход к UI-элементам!
Кстати, в одной из моих версий я также думал сделать класс-обработчик такого контейнера, который выглядит примерно так:
public sealed class OutputUIElements
{
private StorageUIElements storage;
public OutputUIElements(StorageUIElements storage)
{
this.storage = storage;
}
public void SetText(byte id, object someValue)
=> storage.GetElement<Text>(id).text = someValue.ToString();
public void SetText(string id, object someValue)
=> storage.GetElement<Text>(id).text = someValue.ToString();
public void SetTextIterator(byte id, object someValue)
=> storage.GetElementIterator<Text>(id).text = someValue.ToString();
public void SetTextIterator(string id, object someValue)
=> storage.GetElementIterator<Text>(id).text = someValue.ToString();
}
А чтобы далее оперировать с элементами, я создавал класс-контейнер в нужном нам классе, а также класс-обработчик:
public class TestOutputResult : MonoBehaviour
{
[SerializeField]
private StorageUIElements storage;
private OutputUIElements output;
private void Start()
{
output = new OutputUIElements(storage);
//Example
output.SetText("Score", 2500);
}
}
Зачем? Дело в том, что я хотел избавиться от дублирования кода и попытаться сделать все методы по изменению данных на более абстрактном уровне. Как оказалось, всё самое неприятное впереди...
Об оптимизации
Когда я реализовал такой подход, казалось, всё хорошо, до момента тестирования. Для начала я хочу выделить проблемы такого подхода, который применил я:
- Доступ к любому свойству любого элемента списка. Это и плюс и минус. Плюс заключается в том, что нам не обязательно хранить в классе-обработчике все операции. С другой стороны, не стоит забывать, что возвращающий метод даст нам Component, а значит мы можем изменить свойства совершенно любого компонента, что очень плохо.
- Класс-обработчик очень низкоуровневый. Я имею в виду, что в моём подходе, класс-обработчик не просто хранит примитивные методы по типу "SetText", но ещё и перегружает метод (чтобы к нему был доступ не только по индексу, но и по имени). А это значит, что если нам нужно добавить новую операцию, то мы должны написать метод два раза. Мы работаем на уровне реализации, а это плохо.
- Жёсткая привязка элементов в контейнере. Дело в том, что в окне "Inspector" мы можем добавлять новые элементы в список, но что если нам нужно будет убрать элемент, стоящий в середине списка? Заново переставлять все элементы вручную? Или написать обработчик на null-ую ссылку? Лучший способ для решения данной проблемы - ReordableList, однако он нуждается в реализации пользовательского инспектора.
- Оптимизация. На ней мы также остановимся.
Вот график, который я сделал после ряда тестов:

Конечно, это лишь один из тестов по выявлению эффективного доступа к UI элементам и их изменениям и кому-то покажется чересчур странным, например почему я выбрал 200.000 итераций, а не больше или меньше? Думаю, что разницы никакой нет, какое кол-во брать, главное, чтобы видеть потенциальные различия. Как вы можете видеть - самый производительный способ - хранить экземпляр UI-элемента внутри того класса, в котором он меняется или в структуре, однако время на чтение элемента из Get-метода уже даёт менее производительные данные. Самыми затрачиваемыми оказались вызовы из списка (с условием, что я использовал тот самый метод "GetElementIterator" т.к. мы обращались к элементу повторно и если бы я использовал классические методы, то по результатам тестов, они были бы хуже более чем в два раза!).
Чуть не забыл. Метод "GetElementIterator" реализован через проверку на значение null. В моём подходе так было сделано потому, что иначе, если бы мы проверяли вызываемый элемент с последним, эффективность метода стала бы хуже не менее чем в 3 раза! По результатам тестов число обработки доходило до 80 ms, что существенно ударило по производительности.
Теперь, зная, как данный подход влияет на производительность, мне уже не хочется использовать такой подход. Но как тогда быть? А самое главное, как сделать передачу данных. Может быть, стоит ещё рассмотреть static-класс с методами для вывода, но опять же всплывает проблема объема класса и инициализации элементов.
Кстати, пару слов о MVC. Я хочу показать небольшой и грубый пример использования такой архитектуры (тут ещё и двунаправленная связь).
View-объект я сделал так:
using UnityEngine;
using UnityEngine.UI;
public class PlayerView : MonoBehaviour
{
[Serializable]
private struct Storage
{
[SerializeField]
private Text score;
public Text GetScore() => score;
}
[SerializeField]
private Storage storage;
private PlayerController playerController;
private Stopwatch stopWatch = new Stopwatch();
public void OutputScore() => storage.GetScore().text = playerController.GetScore().ToString();
private void Start()
{
playerController = gameObject.GetComponent<PlayerController>();
OutputScore();
}
}
А Controller-объект так:
using UnityEngine;
using System;
public class PlayerController : MonoBehaviour
{
[Serializable]
private struct Data
{
private short currentScore;
public short GetCurrentScore() => currentScore;
}
private Data data;
private PlayerView playerView;
public short GetScore() => data.GetCurrentScore();
private void Start()
{
playerView = gameObject.GetComponent<PlayerView>();
}
}
Безусловно, здесь есть недостатки, но что самое важное, это организация. Здесь можно видеть, как все нужные поля инкапсулированы и никто ничего менять не может. Но что самое интересное, это тест. А тест показывает, что при таком же кол-ве итераций (200.000), затраченное время на исполнение вывода очков составило... ~50ms. Как-то так. Даже больше, чем в моём подходе со списками. Хотя может быть просто я реализовал в таком примере что-то не так, однако всё работало, даже несмотря на то, что я вывожу кол-во очков в тот же момент, когда происходит инициализация PlayerController. Не совсем хороший пример.
Однако что меня удивило, так это то, что при замене структуры, в котором хранились UI-элементы, на прямое обращение к тексту, время не изменилось. Хм, может объект для подсчёта времени "Stopwatch" что-то делает не так? Ведь ранее при прямом обращении время обработки уменьшалось.
Что ж, переходя к заключению, я лишь хочу повторить свой вопрос из начала этой публикации - каким образом вы построили свою архитектуру? И если используете такие шаблоны как MVC или MVVM, как именно вы их реализовали? Будет интересно послушать.
UPD: Прикрепляю одно из решений, предложенного пользователем mopsicus, а точнее, ссылки, оставленные им:
Обязательно гляну на практике, спасибо, товарищ!
Смотрите также:
Комментарии
- 1 (Текущая страница)
- 2
Подход конечно интересный, но с точки зрения отладки, разработки и производительности так себе. Лично я подошел немного с другой стороны. Это очень похоже на MVC подход, но не совсем чистый.
Перейдем к делу
Во-первых, я разработал набор компонентов наблюдателей, общий список был примерно такой:
public interface IObservable {
event Action OnChanged;
}
public interface IValueObservable<TValue> : IObservable {
TValue Value { get; set; }
event Action<TValue, TValue> OnValueChanged;
}
public interface IComputedObservable<TValue> : IObservable {
TValue Value { get; }
event Action<TValue, TValue> OnValueChanged;
void SetFunction(Func<TValue> computeFunction, params IObservable[] dependency);
}
public interface ICollectionObservable<TValue> : IObservable, ICollection<TValue> {
event Action<TValue> OnValueAdded;
event Action<TValue> OnValueRemoved;
}
public interface IDictionaryObservable<TKey, TValue> : IObservable, IDictionary<TKey, TValue> {
event Action<TKey, TValue> OnValueAdded;
event Action<TKey, TValue> OnValueChanged;
}
Конечно-же были добавлены всякие вспомогательные функции для быстрого доступа к значениям, к основным операциям, быстрая регистрация и инициализация и т.д.
Во-вторых, я завел игровое состояние (aka Data) на основе всего этого. Вылядит это примерно следующим образом:
public class PlayerState {
public Guid PlayerId { get; private set; }
public readonly ObservableList<PlayerResearchState> Researches = new ObservableList<PlayerResearchState>();
public readonly Observable<float> Money = new Observable<float>(0.0);
public readonly Observable<float> Metal = new Observable<float>(0.0);
}
public class Unit : MonoBehaviour {
public Observable<float> Health { get; set; }
}
В-третьих, создается интерфейс (aka View), размещаются все нужные элементы как угодно. Например, для отображения ресурсов накидываем на форму два текстовых поля, добавляем по вкусу иконки и радуемся жизни и красивому дизайну (нет)
И заключительный этап. Создаем контроллер для нашего интерфейса:
public class PlayerResourcesUIController : MonoBehaviour {
public Text MoneyText;
public Text MetalText;
private PlayerState _currentPlayer;
public void Start() {
// Получаем текущего игрока
_currentPlayer = GameState.LocalPlayer;
// Подписываемся на события
// RegisterAndInit - подписывается на обновление значения и автоматически вызывает функцию для первичной инициализации интерфейса
_currentPlayer.Money.RegisterAndInit(HandleMoneyUpdate);
_currentPlayer.Metal.RegisterAndInit(HandleMetalUpdate);
}
private void HandleMoneyUpdate(float value, float previousValue) {
MoneyText.text = string.Format("{0:00}", value);
}
private void HandleMetalUpdate(float value, float previousValue) {
MetalText.text = string.Format("{0:00}", value);
}
public void OnDestroy() {
_currentPlayer.Money.Unregister(HandleMoneyUpdate);
_currentPlayer.Metal.Unregister(HandleMetalUpdate);
}
}
Итог
- Удобство - на данный момент система показывает себя с хорошей стороны в RTS проекте, который сейчас параллельно разрабатываю. Конечно есть некоторые не самые удобные нюансы связанные с использованием этого на объектах, которые постоянно создаются и уничтожаются, но есть идеи по улучшению системы. Нравится, что если нужно собрать данные из нескольких объектов типа Observable, то можно воспользоваться ComputedObservable, на уровне контроллера ничего не меняется, а гибкость настройки увеличивается в разы. Так одним изменением удалось изменить систему притока ресурсов от статичной формулы одного здания, до динамического расчета с учетом особенной расположения постройки, ее уровня, персональных характеристик и изученных исследований. При этом на уровне контроллера интерфейса ничего менять не пришлось. Если оценивать, я бы ее оценил сейчас на 3.5 / 5 когда приходится поднимать интерфейс с 0 и на 4.5 / 5 когда данные уже объявлены.
- Красота кода - лично мне нравится, за исключением того, что приходится подписываться и отписываться вручную (ситуацию описал уже выше). В целом схема выглядит почти как работающая MVC архитектура. Оценить могу на 4 / 5
- Производительность - вот это самый интересный момент, если сильно не заигрываться с лямбдами и ComputedObservable, то можно получить минимальную просадку, а если еще и допилить систему с небольшой оптимизацией для ComputedObservable (например отложенные вычисления), то можно снизить просадку до минимального значения. На данный момент оценю ее на 4.2 / 5
Послесловие
Хочется услышать также от вас отзывов о таком подходе. Эту тему я уже как-то поднимал в Unity QA, но народу подключилось не очень много. Если заинтересовала система, но пишите плиз, постараюсь доработать, задокументировать и выложить в гитхаб :)
alexprey, Это достаточно интересный подход! Я тоже думал на счёт использования паттерна "наблюдатель", но не пытался использовать на практике. Самое интересное то, как вы реализовали хранение значений, сделав объектом "Observable<T>". Интересно, как у вас происходят изменения значений в таких объектах и сохранение данных при таком подходе... И как вы реализовали вызовы для вывода информации из разных классов (например для системы игровых достижений или что-то подобного)? Static-методами? P.S. хочется посмотреть эту систему в действии, так что был бы рад ознакомится на гитхабе :)
И как вы реализовали вызовы для вывода информации из разных классов (например для системы игровых достижений или что-то подобного)?
Все также с помощью игрового состояния, static методы по прежнему тут не нужны. Просто создается контроллер для отображения данных. Например, каждый элемент достижения можно представить в виде отдельного состояния и контроллера, где будет поле ComputedObservable<bool> IsComplete и задать в этой формуле условие достижения, например
ComputedObservable<bool> IsComplete = new ComputedObservable(false);
// ....
// Задаем условие показа того, что ачивка достигнута
IsComplete.SetFunction(() => GameState.LocalPlayer.Achievements.Contains("Harvest-Money-5000") || GameState.LocalPlayer.Money > 5000.0f, GameState.LocalPlayer.Money, GameState.LocalPlayer.Achievements);сохранение данных при таком подходе..
Ну тут все просто, все данные хранятся в едином месте, просто сериализуешь GameState и готово :)
хотел написать развернутый ответ, но блин, лень )
если коротко, мне не нравится решение alexprey, ибо эвенты будут аллоцировать (это все скрытые массивы, так то) + не френдли для юнити инспектора как-то.
дальше должен был быть список принципов которых стоит придерживаться.
ну и роспись о том что MVC для юнити - не годится, и почти все топ конторы юзают MVVM.
Devion, ну у меня не было цели делать это юзабельным для инспектора) а вот на счет эвентов тут да, есть несколько проблем, я бы переделал эту штуку на немного другой способ менеджмента подписок на изменения, при которых стандартная система эвентов вообще не годится.
Давай не ленись и поделись сакральными знаниями о годных принципах :3
Статья мне показалась странной :) . Сначала ты вроде предлагаешь решение, но в финальном примере это решение не используется. Плюс в классе PlayerController есть зачем-то ссылка на PlayerView. Ну да ладно, разобраться все же можно.
Самое интересное, это постановка проблемы:
Очевидно, такой подход плох тем, что загромождает класс множеством экземпляров разных объектов (Text, Image, etc.),
Можешь пожалуйста пояснить, что ты подразумеваешь под словом "загромождать"? Одна-две строчки на виджет? Как должен выглядеть в таком случае "незагроможденный" код?
но самое ужасно то, что логика оперирования с игровыми данными идёт в одном объекте вместе с оперированием над UI-элементами.
Ты имеешь в виду GameObject? Или объект в терминах ООП? Если последнее, то что мешает вынести данные и работу с ними в отдельный класс?
Проще говоря, это рушит некоторые принципы ОО-проектирования, что является не особо хорошей практикой.
А какие именно принципы ООП? Ты имеешь в виду принципы SOLID, или какие-нибудь другие принципы?
Если о моем мнении, я предлагаю не выпендриваться и использовать MVP (Passive View). Данные и их обработку - в Model. Обработка событий изменения данных и обновление интерфейса помещается в Presenter. Если интерфейс становится сложным (Больше одного текстового поля), выделяй работу с ним в отдельный класс View. https://www.martinfowler.com/eaaDev/uiArchs.html
Юнити дает хорошие инструменты из коробки, используй их и не стоит усложнять без критической необходимости.
(хотя может и стоит ради саморазвития иногда усложнять, но не в рабочих проектах)
Юнити дает хорошие инструменты из коробки, используй их и не стоит усложнять без критической необходимости.
Инструменты то дает, вопрос лишь в грамотной реализации архитектуры с аккуратным доступом к данным. Например, я стараюсь сделать так, чтобы игровые сущности не знали, что происходит на стороне UI
alexprey, Что ты подразумеваешь под словом "знали" ? Имели ссылки? Да, не должны иметь ссылок. Да, полностью согласен. Мне честно понравилось твое решение с закосом под MVVM. Но я бы так не упарывался, а просто слал события при изменении данных из класса, содержащего эти самые данные.
- 1 (Текущая страница)
- 2


<a href= http://mosros.flybb.ru/viewtopic.php?f=2&t=635>Процесс получения диплома стоматолога: реально ли это сделать быстро?</a>