В своей предыдущей статье я описал OneLine — PropertyDrawer, позволяющий рисовать объект любой вложенности в одну строку.
В этот раз я расскажу, каким образом мне пришлось оптимизировать код, чтобы в инспекторе можно было свободно редактировать базы данных, состоящих из сотен строк.
Внимание, под катом много гифок и картинок!
Суть проблемы
В стандартном инспекторе все поля, имеющие сложную структуру, рисуются свернутыми, что расходует довольно мало ресурсов и позволяет без проблем нарисовать массив, состоящий из сотен объектов.
Если посмотрим в профайлер, увидим 4.3 мс на отрисовку 100 элементов массива.
OneLine же придумывалась, чтобы избавить разработчика от постоянных кликов мышкой в инспекторе, и все вложенные поля рисуются сразу же. При этом, во время отрисовки производятся достаточно затратные вычисления позиций элементов.
Товарищ Шипилев в своем докладе "Перформанс: что в имени тебе моем?" предлагает график зависимости производительности кода от его сложности (время движется по кривой от A к E):
Это параметрический график: время тут течет от точки «A» до точки «B», «C», «D», «E». По оси ординат у нас производительность, по оси абсцисс — некоторая абстрактная сложность кода.
Обычно всё начинается с того, что люди велосипедят прототип, который медленно, но работает. Он достаточно сложен, потому что мы навелосипедили просто так, чтобы он не разваливался под собственным весом.
После того как начинается оптимизация — потихонечку начинается переписывание разных частей. Находясь в этой зелёной зоне, разработчики обычно берут профайлеры и переписывают куски кода, которые написаны очевидно ужасно. Это одновременно снижает сложность кода (потому что вы выпиливаете плохие куски) и улучшает производительность.
В точке «B» проект достигает некоторого субъективного пика «красоты», когда у нас вроде и перформанс хороший, и в продукте всё неплохо.
Далее, если разработчикам хочется ещё производительности, они переходят в жёлтую зону, когда берут более точный профайлер, пишут хорошие рабочие нагрузки и аккуратненько закручивают гайки. В этом процессе они там делают вещи, которые они бы не делали, если бы не производительность.
Если хочется ещё дальше, то проект приходит в некоторую красную зону, когда разработчики начинают корежить свой продукт, чтобы получить последние проценты производительности. Что делать в этой зоне — не очень понятно. Есть рецепт, по крайней мере, для этой конференции — идёте на JPoint/JokerConf/JBreak и пытаете разработчиков продуктов, как писать код, повторяющий кривизну нижних слоёв. Потому что, как правило, в красной зоне возникают штуки, которые повторяют проблемы, возникающие на нижних слоях.
График редкостно хорош, как и вся статья, очень рекомендую к прочтению.
Движение от A к B — штука довольно скучная и тривиальная. Наша статья охватывает сначала движение от B к С, затем — блуждания вокруг D в поисках правильного костыля/противовеса особенностям Unity.
Кое-кому в голову обязательно придет мысль: "Что за детский сад? И это вы называете блужданиями вокруг D? Где использование особенностей CLR и IL2CPP? Где бенчмарки по всем правилам? Где переусложнение кода ради выигрыша скорости в 0.05%?".
Скорее всего, статья не для этого читателя. Вещи, о которых я пишу, достаточно просты и направлены скорее на молодого читателя, начинающего свой путь разработчика в Unity. Поэтому я упрощаю реализацию и провожу скорее описание подходов к оптимизации PropertyDrawer.
Как-то раз я видел, как мой коллега писал PropertyDrawer, отображающий сетку с футпринтом игрового объекта. Он генерировал изображения на лету, попиксельно менял их цвета и делал еще много разных страшных штук. В конечном итоге, отображение одного такого поля могло значительно просадить FPS в редакторе.
Конечно, это не значит: "Если кто-то делает глупости, значит и мне можно". Просто некоторые глупости мы делать вынуждены, а после — оптимизировать.
Те же 100 элементов потребовали 104 мс, то есть в ~24 раза больше. Такую скорость можно охарактеризовать одним словом: отвратительно.
Кешируем что можем
OneLine сама вычисляет позиции полей в зависимости от их количества, типа и висящих на них атрибутов (например, [Width]
или [Weight]
). И каждый вызов OnGUI эти вычисления происходят заново.
Та же ситуация и с абстрактным PropertyDrawer, который работает очень медленно и поэтому не дает покоя программисту Васе. Очевидно, что если медленно — значит, скорее всего делает множество одинаковых вычислений.
Решено: будем кешировать!
Для продолжения нам нужно знать следующее: для одного окна инспектора для каждого типа создается лишь один объект PropertyDrawer, который рисует все поля этого типа, а после смены объекта отбрасывается.
А это значит, если у нас на экране два поля одного типа, их рисует один и тот же объект PropertyDrawer. Это несколько усложняет кеширование.
С другой стороны, если у нас один PropertyDrower на один инспектор, значит мы можем сохранять любые данные в Dictionary<string, YourData>
, где ключом будет property.propertyPath
.
В конце концов, получаем несложный кэш:
public delegate <T> T CalculateValue (SerializedProperty property);
public class PropertyDrawerCache<T> {
private Dictionary<string, T> cache;
private CalculateValue<T> calculate;
public PropertyDrawerCache(CalculateValue<T> calculate){
cache = new Dictionary<string, T>();
this.calculate = calculate;
}
public T this[SerializedProperty property] {
get {
T result = null;
if (cache.TryGetValue(string, out result)){
result = calculate(property);
cache.Add(property.propertyPath, result);
}
return result;
}
}
}
Первый вызов отрабатывается так же медленно, но все последующие вдвое быстрей. Неплохо, но ощущается все еще неприятно.
При первом вызове:
При последующих вызовах:
Проблемы с OneLine: Вложенные Массивы
Главная беда кеша: его нужно поддерживать в актуальном состоянии. Мы кешируем вычисленные позиции для полей классов и считаем их неизменными (не будет же структура классов меняться в рантайме). Однако мы не учли одного момента: OneLine умещает в строку также и все элементы дочерних массивов.
К счастью, проблема решается сбросом кешированных позиций элемента.
public void DrawPlusButton(Rect rect, SerializedProperty array) {
if (GUI.Button(rect, "+")) {
array.InsertArrayElementAtIndex(array.arraySize);
ResetCurrentElementCache();
}
}
Проблемы с OneLine: Корневой массив
Главная беда кеша: его нужно поддерживать в актуальном состоянии.
В ходе использования OneLine я наткнулся на интересный баг кеширования:
То же самое словами:
- берем массив массивов;
- добавляем элемент В (копируя элемент А — последний элемент массива);
- изменяем длину массива в элементе В;
- удаляем элемент В;
- добавляем элемент С (копируя элемент А);
- получаем ситуацию: элемент С имеет длину отличную от элемента В, в то же время в кеше уже лежат рассчитанные позиции для элемента В.
Решение: запоминаем размер для каждого массива и при следующей отрисовке проверяем, не изменился ли массив. Привожу решение только для случая, когда OneLine висит на массиве в корне ScriptableObject, чтобы упростить чтение.
Заодно обращаю внимание читателя на замечательную функцию IsReallyArray
в коде.
private Dictionary<string, int> arraysSizes = new Dictionary<string, int>();
public bool IsArraySizeChanged(SerializedProperty arrayElement){
var arrayName = arrayElement.propertyPath.Split('.')[0];
var array = arrayElement.serializedObject.FindProperty(arrayName);
return IsReallyArray(array)
&& IsRealArraySizeChanged(arrayName, array.arraySize);
}
private bool IsReallyArray(this SerializedProperty property){
return property.isArray && property.propertyType != SerializedPropertyType.String;
}
private bool IsRealArraySizeChanged(string arrayName, int currentArraySize){
if (! arraysSizes.ContainsKey(arrayName)){
arraysSizes[arrayName] = currentArraySize;
}
else if (arraysSizes[arrayName] != currentArraySize){
arraysSizes[arrayName] = currentArraySize;
return true;
}
return false;
}
Не рисуем что можем
В нашем массиве 100 элементов, а на экране видны лишь 20-25 (на гифках), что приводит нас к еще одной стандартной оптимизации: Culling: просто не будем рисовать то, что не влазит в экран!
Для этого нам необходимо знать размеры окна, в котором мы находимся, а также позицию ScrollView. Предлагаю вам решение на грани фола (привет, Unity-Decompiled).
internal class InspectorUtil {
private const string INSPECTOR_WINDOW_ASSEMBLY_QUALIFIED_NAME =
"UnityEditor.InspectorWindow, UnityEditor, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null";
private const string INITIALIZATION_ERROR_MESSAGE =
@"OneLine can not initialize Inspector Window Utility.
You may experience some performance issues.
Please create an issue on https://github.com/slavniyteo/one-line/ and we will repair it.
";
private bool enabled = true;
private MethodInfo getWindowPositionInfo;
private FieldInfo scrollPositionInfo;
private object window;
private float lastWindowWidth;
public InspectorUtil() {
try {
Initialize();
enabled = true;
}
catch (Exception ex){
// Находимся не в окне инспектора
// Или в новой версии Unity изменилась реализация,
// Оптимизация будет отключена.
enabled = false;
Debug.LogError(INITIALIZATION_ERROR_MESSAGE + ex.ToString());
}
}
private void Initialize(){
var inspectorWindowType =
Type.GetType(INSPECTOR_WINDOW_ASSEMBLY_QUALIFIED_NAME);
window = inspectorWindowType
.GetField("s_CurrentInspectorWindow",
BindingFlags.Public | BindingFlags.Static)
.GetValue(null);
scrollPositionInfo = inspectorWindowType
.GetField("m_ScrollPosition");
getWindowPositionInfo = inspectorWindowType
.GetProperty("position", typeof(Rect))
.GetGetMethod();
}
public bool IsOutOfScreen(Rect position){
if (! enabled) { return false; }
var scrollPosition = (Vector2) scrollPositionInfo.GetValue(window);
var windowPosition = (Rect) getWindowPositionInfo.Invoke(window, null);
bool above = (position.y + position.height) < scrollPosition.y;
bool below = position.y > (scrollPosition.y + windowPosition.height);
return above || below;
}
}
Затем используем:
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
if (inspectorUtil.IsOutOfScreen(position)) { return; }
<..>
}
Этот код будет работать только в случае, когда объекты отрисовываются в окне инспектора. Если вы используете OneLine (или другой PropertyDrawer) в своем кастомном окне, эта оптимизация работать не будет. Причина, конечно, в жесткой завязке на реализацию прокрутки экрана. Сделать универсальный инструмент в данном случае невозможно. Зато код достаточно прост, его всегда можно адаптировать под свои нужды.
Чувствуется совсем иначе, прокрутка идет значительно плавней, а количество FPS в большой мере зависит от высоты окна.
Проблема с куллингом
Если присмотреться к предыдущей гифке, можно заметить. что при прокрутке, фокус элемента "прилипает" к верхнему краю и не уезжает за экран.
Очевидно, Unity запоминает активный элемент на основании его порядкового номера с начала обработки ивента. А так как culling отбрасывает все невидимые элементы, мы этот порядок нарушаем.
Решить проблему можно довольно просто: сбрасывать фокус с контрола при каждом движении колесика.
if (Event.current.type == EventType.ScrollWheel){
EditorGUI.FocusTextInControl("");
}
Но я это решение так и не интегрировал в OneLine, потому что еще не убедил себя в том, что это решение достаточно хорошим. Когда-нибудь я закопаюсь поглубже и возможно сделаю это чуть лучше.
Соберем все вместе
При первом вызове:
При последующих вызовах все происходит значительно быстрее:
Для сравнения все варианты в одном окне:
В результате работы мы получили значительное ускорение работы библиотеки. Я не пишу "десятикратное ускорение", поскольку время отрисовки элементов в значительной степени зависит от высоты окна (если возьмем окно вдвое выше, время также увеличится практически в два раза). Однако и этот результат меня устраивает.
Не буду вставлять уже привычный на Хабре блок с рекламой: кому нужно, тот найдет.
Всем добра!