Очень часто натыкался на то, что разработчики (в основном молодые), делая интерфейс для своего приложения, сваливают его весь в сцене и открывают\закрывают нужные им экраны и окна, просто включая и выключая их. Для прототипа, который надо сварганить на коленке за один день - это нормально. Также, если весь интерфейс это кнопка "Начать" и Hud, то заморачиваться конечно же не стоит. Но если у нас экранов больше, и если они включают в себя разнообразный графический контент, то такой подход неправильный. Все эти экраны и вся графика для них висит в памяти и отжирает не малый её кусок. А также подгрузка сцены становится сложнее и дольше.
Цель:
В данной статье хочу продемонстрировать простой способ работы с экранами интерфейса, также его можно применять и для различных других ресурсов.
Заодно хочу показать работу с Unity UI Elements для CustomEditor, притом (чисто для эксперимента и разнообразия) сделать это из кода, без использования .uxml.
План:
Написать систему, которая будет подгружать префабы с экранами из ресурсов и инстанцировать их в сцене.
Также - написать эдитор для неё с использованием UI Elements.
Система подгрузки префабов:
Для начала нам нужен класс для информации о префабе. В качестве полей у него будет:
Уникальный идентификатор, по которому можно будет находить префаб в базе.
Путь к префабу. Тут надо пояснить, что нужен именно путь, а не ссылка на него. Если хранить ссылки, то и все префабы прилинкованные к базе будут храниться в памяти, а этого мы и хотим избежать. Префабы должны лежать в папке Resources, оттуда и будем их доставать по надобности.
И для эдитора сохраняем юнитевый guid. Это нужно для того, чтобы в случае переименования или перемещения префаба система сама нашла к нему новый путь.
На всякий случай сохраним также инстанцированный экземпляр для быстрого доступа к нему в случае, если будут необходимы несколько обращений к нему.
using System;
using UnityEngine;
/// <summary>
/// Описание экрана
/// </summary>
[Serializable]
public class ScreenAsset
{
/// <summary>
/// Уникальный идентификатор\название экрана
/// </summary>
public string UID;
/// <summary>
/// Путь к префабу в папке с ресурсами
/// </summary>
public string Path;
#if UNITY_EDITOR
/// <summary>
/// Guid в Unity проекте, для легкого поиска в редакторе
/// </summary>
public string Guid;
#endif
/// <summary>
/// Несериализуемый экземпляр для инстанцирования
/// в единичном экземпляре
/// </summary>
[NonSerialized] public GameObject Instance;
/// <summary>
/// Дефолтный конструктор для инспектора
/// </summary>
public ScreenAsset()
{
}
/// <summary>
/// Конструктор
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <param name="assetPath">Путь к префабу в папке с ресурсами</param>
/// <param name="guid">Guid в Unity проекте,
/// для легкого поиска в редакторе</param>
public ScreenAsset(string uid, string assetPath, string guid)
{
UID = uid;
Path = assetPath;
#if UNITY_EDITOR
Guid = guid;
#endif
}
}
Теперь создаем саму базу с префабами. Для меня это префабы экранов. База будет представлять из себя ScripableObject, который можно будет прилинковать в будущем к нужной системе.
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Object = UnityEngine.Object;
/// <summary>
/// База префабов с UI экранами в ресурсах
/// </summary>
#if UNITY_EDITOR
[CreateAssetMenu(fileName = "Data",
menuName = "Chrono/CrerateScreensDataBase", order = 2)]
#endif
public class ScreensDataBase : ScriptableObject
{
/// <summary>
/// Путь к папке с ресурсам
/// </summary>
private const string ResourcesPath = "Assets/Resources/";
/// <summary>
/// расширение объектов префабов
/// </summary>
private const string ResourcesExtensions = ".prefab";
/// <summary>
/// Список экранов с ссылками на префабы.
/// </summary>
[SerializeField] private List<ScreenAsset> _screens;
/// <summary>
/// Список описаний экранов с ссылками на префабы. Только для чтения
/// </summary>
public List<ScreenAsset> Screens => _screens;
/// <summary>
/// Получить описание экрана
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <returns>Описание найденного экрана</returns>
public ScreenAsset GetScreenAsset(string uid)
{
return _screens.FirstOrDefault(s => s.UID == uid);
}
/// <summary>
/// Получение префаба из папки с ресурсами
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <returns>Префаб для инстанцирования</returns>
public Object GetScreenObject(string uid)
{
//Находим описание нужного экрана
var screen = GetScreenAsset(uid);
if (screen == null) return null;
//проверяем что префаб лежит в папке с ресурсами
//и он нужного расширения
if (screen.Path.StartsWith(ResourcesPath)
&& screen.Path.EndsWith(ResourcesExtensions))
{
//Находим нужный префаб в ресурсах
//Перед этим удаляем из пути ту часть, что ведет к папке
//с ресурсами Assets/Resources/ и удаляем расширение,
//так как Resources.Load нужен путь без этой информации
var asset = Resources.Load(screen.Path
.Remove(screen.Path.Length - ResourcesExtensions.Length,
ResourcesExtensions.Length)
.Remove(0, ResourcesPath.Length));
return asset;
}
//В случае, если ресурс не там лежит, то сообщаем об этом
Debug.LogWarning($"{screen.UID} not in {ResourcesPath}");
return null;
}
/// <summary>
/// Инстанцирование экрана
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <param name="parent">Transform где необходимо инстацировать</param>
/// <param name="single">Единственный экземпляр в сцене?
/// Если да и такой экран уже в сцене,
/// то возвращаете старый, если нет, то создаете новый</param>
/// <returns>Инстанцированный экран</returns>
public GameObject InstanceScreen(string uid, RectTransform parent = null,
bool single = false)
{
//Находим описание нужного экрана
var asset = GetScreenAsset(uid);
//Если экземпляр должен быть единичный и он уже существует,
//то возвращаем его.
if (single)
{
if (asset.Instance != null)
{
return asset.Instance;
}
}
//Находим префаб
var screenObject = GetScreenObject(uid);
if (screenObject == null) return null;
//Инстанцируем префаб как ребенка для указанной Transform
var instance = Instantiate(screenObject, parent) as GameObject;
//Обозначаем инстанцированный объект как экземпляр
//для этого описания экрана,
//чтобы использовать при повторных вызовах с единичным экземпляром
asset.Instance = instance;
return instance;
}
/// <summary>
/// Инстанцирование экрана с необходимым компонентом
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <param name="parent">Transform где необходимо инстациировать</param>
/// <param name="single">Единственный экземпляр в сцене?
/// Если да и такой экран уже в сцене,
/// то возвращаете старый, если нет, то создаете новый</param>
/// <typeparam name="T">Тип компонента в корневом объекте</typeparam>
/// <returns>Компонент необходимого типа</returns>
public T InstanceScreen<T>(string uid, RectTransform parent = null,
bool single = false)
{
//Инстанцируем сам объект
var gameObject = InstanceScreen(uid, parent, single);
if (gameObject != null)
{
//находим у него компонент необходимого типа
var component = gameObject.GetComponent<T>();
return component;
}
return default;
}
}
Надеюсь по коду все понятно, что и для чего там нужно. Как это использовать? Система, который нужен будет соответствующий экран, может обратиться к этой базе и по идентификатору инстанцировать его в нужном месте. Можно сразу передавать тип компонента, который должен быть у корневого объекта.
С заполненной базой легко работать, но как её правильно заполнить? Тут вступает в игру Editor для базы.
UI Elements в CustomEditor
Тут главный момент, что мы в редакторе добавляем ссылку на объект, но так как нам её хранить нельзя, мы получаем с неё нужные нам параметры (путь и guid) и сохраняем их. Для отображения в редакторе нужных объектов мы также используем сохраненные параметры, по ним находим сам объект и отображаем в редакторе.
using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
/// <summary>
/// Эдитор для ScreensDataBase
/// </summary>
[CustomEditor(typeof(ScreensDataBase))]
public class ScreensDataBaseEditor : Editor
{
/// <summary>
/// Экземпляр объекта для отображения в Инспекторе
/// </summary>
private ScreensDataBase _target;
/// <summary>
/// Сериализованный объект редактируемого экземпляра
/// Необходим для работы с его сериализуемыми полями
/// </summary>
private SerializedObject _targetSerializedObject;
// UI Elements для работы с добавлением нового префаба
private ObjectField _newPrefab;
private TextField _newScreenUID;
private VisualElement _listScreens;
// Поисковый запрос для фильтрации префабов в базе
private string _searchText = String.Empty;
/// <summary>
/// Отрисовка Инспектора объекта
/// </summary>
/// <returns></returns>
public override VisualElement CreateInspectorGUI()
{
//Сохраняем экземпляр редактируемой базы
_target = target as ScreensDataBase;
if (_target == null) return null;
_targetSerializedObject = new SerializedObject(_target);
//Создаем корневой UI Element в котором будут отрисовываться остальные
var root = new VisualElement();
root.Clear();
root.AddToClassList("root");
//Создаем блок для добавления нового экрана в список
var addNewBlock = new VisualElement();
//Присваиваем стили бля блока
addNewBlock.AddToClassList("body");
addNewBlock.AddToClassList("horizontalLayout");
//Поле ввода UID нового экрана
_newScreenUID = new TextField();
_newScreenUID.AddToClassList("chrono-textfield");
//Поле для линка на префаб для добавления в список
_newPrefab = new ObjectField {allowSceneObjects = false, objectType = typeof(GameObject)};
_newPrefab.style.width = 140;
//Кнопка Добавить с колбэком на нажатие OnNewButtonClick
var newButton = new Button(OnNewButtonClick) {text = "Add"};
newButton.AddToClassList("chrono-button");
newButton.AddToClassList("hero-button");
//Добавляем элементы ввода UID, линк на префаб и кнопку в блок addNewBlock
addNewBlock.Add(_newScreenUID);
addNewBlock.Add(_newPrefab);
addNewBlock.Add(newButton);
//Добавляем блок addNewBlock к корневому объекту
root.Add(addNewBlock);
//Создаем элемент поиска для фильтрации в списке экранов
var search = new ToolbarSearchField();
search.style.flexShrink = 1;
//Регистрируем колбэк на изменение текста в поисковой строке
search.RegisterValueChangedCallback(OnSearch);
//Добавляем строку поиска к корневому элементу
root.Add(search);
//Создаем блок с списком экранов. Храним этот блок в переменной для управления содержимым
//при фильтрации с помощью поисковой строки
_listScreens = new VisualElement();
_listScreens.AddToClassList("body");
//Отрисовываем список в отдельном переиспользуемом методе
DrawScreens();
//Добавляем список к корневому элементу
root.Add(_listScreens);
//Находим стили и добавляем их корневому элементу
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Editor/UI/ChronoStyle.uss");
root.styleSheets.Add(uss);
//Возвращаем корневой элемент как используемый для отрисовки в инспекторе
return root;
}
/// <summary>
/// Колбэк при изменении поискового запроса
/// </summary>
/// <param name="evt">эвент изменения</param>
private void OnSearch(ChangeEvent<string> evt)
{
//Сохраняем текст поиска
_searchText = evt.newValue;
//Отрисовываем блок со списком экранов, на основании введенного текста
DrawScreens();
}
/// <summary>
/// Отрисовываем список добавленных экранов
/// </summary>
private void DrawScreens()
{
//Очищаем предыдущий список
_listScreens.Clear();
//Находим список экранов в базе как SerializedProperty
//Необходимо работать именно с SerializedProperty для сохранения изменения в редакторе
var listProperty = _targetSerializedObject.FindProperty("_screens");
//Проходимся по списку экранов в базе
for (var ii = 0; ii < _target.Screens.Count; ii++)
{
//Находим отдельный элемент в списке как SerializedProperty
var prop = listProperty.GetArrayElementAtIndex(ii);
//Если текст в поисковом запросе содержится в UID или в пути и названии префаба,
//то отмечаем что его нужно отобразить в инспекторе
var needShow = _searchText == String.Empty ||
prop.FindPropertyRelative("UID").stringValue.ToLower().Contains(_searchText.ToLower()) ||
prop.FindPropertyRelative("Path").stringValue.ToLower().Contains(_searchText.ToLower());
if (needShow)
{
//Отрисовываем разделительную линию
var sep = new VisualElement();
sep.AddToClassList("distinction");
_listScreens.Add(sep);
//Добавляем отрисовку элемента через метод DrawScreenAsset
_listScreens.Add(DrawScreenAsset(prop, ii));
}
}
}
/// <summary>
/// Отрисовываем элемент списка экранов
/// </summary>
/// <param name="property">SerializedProperty этого элемента</param>
/// <param name="number">порядковый номер элемента</param>
/// <returns>верстка элемента</returns>
private VisualElement DrawScreenAsset(SerializedProperty property, int number)
{
//Блок, в котором будет все отрисовываться, с горизонтальным расположением элементов
var block = new VisualElement();
block.AddToClassList("horizontalLayout");
//Текстовое поле с отображением и изменением UID
var screenUID = new TextField();
//Связываем значение текстового поля с SerializedProperty UID
screenUID.BindProperty(property.FindPropertyRelative("UID"));
screenUID.AddToClassList("chrono-textfield");
//В случае изменения названия, вызываем автосохранение ассетов проекта
screenUID.RegisterValueChangedCallback(evt => { AssetDatabase.SaveAssets(); });
block.Add(screenUID);
//Поле с префабом
var prefab = new ObjectField
{
//Не использовать объекты на сцене (только в Ассетах)
allowSceneObjects = false,
//Использовать только тип GameObject
objectType = typeof(GameObject),
//Находим прилинкованный префаб по ранее сохраненному guid
value = GetByGUID(property.FindPropertyRelative("Guid").stringValue)
};
prefab.style.width = 140;
//В случае перелинковки префаба обновляем параметры
prefab.RegisterValueChangedCallback(evt =>
{
_targetSerializedObject.Update();
//Сохраняем путь к прилинкованному префабу
property.FindPropertyRelative("Path").stringValue = AssetDatabase.GetAssetPath(evt.newValue);
//Сохраняем его guid
property.FindPropertyRelative("Guid").stringValue = GetGUID(evt.newValue as GameObject);
//Сохраняем\сериализуем новые значения
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
});
block.Add(prefab);
//На случай, если префаб был переименован или перемещен, проверяем его текущий путь
var path = AssetDatabase.GUIDToAssetPath(property.FindPropertyRelative("Guid").stringValue);
//Если текущий путь и сохраненный не совпадают, то обновляем эту информацию
if (property.FindPropertyRelative("Path").stringValue != path)
{
_targetSerializedObject.Update();
property.FindPropertyRelative("Path").stringValue = path;
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
}
//Кнопка удаления элемента из списка
var delButton = new Button {text = "Del"};
//Добавляем делегат на нажатие кнопки
delButton.clicked += () => ClickDelete(number);
delButton.AddToClassList("chrono-button");
delButton.AddToClassList("community-button");
block.Add(delButton);
//Возвращаем верстку
return block;
}
/// <summary>
/// Удаление элемента из списка экранов
/// </summary>
/// <param name="screenAssetNumber"></param>
private void ClickDelete(int screenAssetNumber)
{
_targetSerializedObject.Update();
//Находим список экранов
var listProperty =_targetSerializedObject.FindProperty("_screens");
//Удаляем элемент
listProperty.DeleteArrayElementAtIndex(screenAssetNumber);
//Сохраняем изменения
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
//Периресовываем список в инспекторе
DrawScreens();
}
/// <summary>
/// Добавление нового экрана в список
/// </summary>
private void OnNewButtonClick()
{
//Проверяем, выбран ли префаб и введен ли UID
if (_newPrefab.value == null || _newScreenUID.value == String.Empty) return;
_targetSerializedObject.Update();
//Находим список с экранами
var listProperty =_targetSerializedObject.FindProperty("_screens");
//Создаем новый элемент списка в самом верху
listProperty.InsertArrayElementAtIndex(0);
//Берем вновь созданный элемент для заполнения
var newElementProperty = listProperty.GetArrayElementAtIndex(0);
//Записываем UID
newElementProperty.FindPropertyRelative("UID").stringValue = _newScreenUID.value;
//Находим путь и записываем его
newElementProperty.FindPropertyRelative("Path").stringValue = AssetDatabase.GetAssetPath(_newPrefab.value);
//Находим guid и записываем его
newElementProperty.FindPropertyRelative("Guid").stringValue = GetGUID(_newPrefab.value as GameObject);
//Сохраняем изменения в списке
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
//Перерисовываем список в испекторе
DrawScreens();
}
/// <summary>
/// Находим guid префаба
/// </summary>
/// <param name="prefab">префаб</param>
/// <returns>guid</returns>
private string GetGUID(GameObject prefab)
{
var fullPath = AssetDatabase.GetAssetPath(prefab);
return AssetDatabase.AssetPathToGUID(fullPath);
}
/// <summary>
/// Находим префаб по guid
/// </summary>
/// <param name="guid">guid</param>
/// <returns>префаб</returns>
private GameObject GetByGUID(string guid)
{
return AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid), typeof(GameObject)) as GameObject;
}
}
Если объект был перемещен или переименован, то по отрисовке этого элемента эдитор находит этот объект по guid и автоматом переписывает путь на новый.
В результате у нас получается вот такое редактор
В верхнее поле перемещаем нужный для сохранения префаб, прописываем ему название и нажимаем "Add". Префаб сохраняется в список.
Далее идет поле поиска, оно для фильтрации списка. Поиск осуществляется как по названию, так и по пути. Когда списки становятся большими, это очень необходимо.
Ну и сам список. Тут можно переименовать объект, можно перелинковать на другой, а также можно удалить ненужный из списка.
Работать с этой системой просто. В нужном месте вызываем инициализацию нужного объекта и используем по назначению.
private void AddBookmark(ChronoObjectLogged objectLogged)
{
if (_db == null) return;
var screenName = "";
switch (objectLogged)
{
case ChronoObjectCommunity _:
screenName = "BookmarkCommunity";
break;
case ChronoObjectEnvironment _:
screenName = "BookmarkEnvironment";
break;
case ChronoObjectHero _:
screenName = "BookmarkHero";
break;
}
var bookmark = _db.InstanceScreen<EntryBookmark>(screenName, _bookmarksBlock);
bookmark.Setup(objectLogged, _bookmarkGroup);
_bookmarks.Add(objectLogged.UID, bookmark.Bookmark);
}
Итого:
Думаю, что все это не сложно и прозрачно. Такую систему я использую очень часто и она очень помогает при работе с интерфейсами и не забивает ими память.