Python conf in Kharkiv, Nov 16 with Intel, Elastic engineering leaders. Prices go up 21.10

Статический анализ префабов в Unity

Тем, кто часто использует ReSharper, знакомо предупреждение «Possible NullReferenceException» — так код проверяется еще до выполнения. Особенно хорошо, что не нами. Анализировать таким образом можно любую сложную систему, организованную по определенным правилам. Сегодня я расскажу, как это работает для префабов в Unity.

Предыстория

Композиция требует начать со вступления. Так и поступим. Некоторое время назад мы командой около десяти человек переносили Flash-игру на WebGL. Игре было много лет, написана другим языком (ActionScript 3), а целевая платформа WebGL едва вышла из стадии эксперимента. Времени нам хватало ровно для того, чтобы постоянно чувствовать его нехватку.

Конечно, где-то повезло: ребята из мобильной команды как раз держали под рукой несколько игр с клиентом на Unity. Игр вполне похожих на нашу, с таким же сервером и набором фич. Тут был готовый MVVM с биндингами и командами и модель, покрывающая 80 % требуемой логики (эдакий ироничный принцип Парето).

Больше всего работы приходилось на UI — префабы и ViewModel. И еще на то, чтобы каждое изменение проводить через строжайшие пул-реквесты. Не самый лучший способ сэкономить время. Наверняка, существуют и более эффективные средства, но тут важно вот что: уже стоял вопрос о качестве. Как-то делать легко. И делать, чтобы работало, тоже несложно. Но как делать лучше всего?

В пылу разработки каждый день всплывали новые факты о том, что и как работает. На основании этих фактов появлялись правила, приемы и рекомендации: не использовать компонент Shadow, потому что он медленный (и есть аналоги эффективнее); делать объекты неактивными и дописывать им окончание «_h», если активность управляется извне; убирать флажок Raycast Target у картинок (компонент Image), если они не взаимодействуют с мышью и т. д. Тут были как безобидные детали общего стиля, так и штуки, которые могут уничтожить приложение.

Однажды я узнал, что в окне иерархии сцены в Unity можно рисовать прямо на объектах. И меня вдруг осенило. В сущности, так-то и не совсем вдруг, но однажды я подумал вот что: раз мы часть ресурсов отводим на поиск ошибок, то, может, пусть нечего будет искать? Например, добавит кто-то Shadow на объект, и тотчас появится иконка с предупреждением, мол, осторожнее, возможно, ты не хотел этого делать, и вот почему...

Идея и наброски

Думаю, вы уже поняли, в какую сторону движется сюжет. Это одна из таких задач, для которых достаточно только в общих чертах наметить результат, и решение как бы обозначивается само собой.

Итак, требуется проверять объекты и рисовать иконки, если что не так. Ограничимся пока простым интерфейсом:

public interface IAnalyzer
{
    bool Execute(GameObject gameObject);
}

Уже можно проверять использование компонента Shadow:

public sealed class ContainsShadowAnalyzer : IAnalyzer
{
    public bool Execute(GameObject gameObject) =>
        gameObject.GetComponent<Shadow>() != null;
}

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

Следующий вопрос: когда анализировать? Раз уж мы согласились, что рисовать на объектах в окне иерархии можно, оттуда и начнем. А именно — с события EditorApplication.hierarchyWindowItemOnGUI.

Получается так:

private static void OnItemGUI(int instanceID, Rect rect)
{
    if (!_isEnabled)
        return;

    var instance = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
    if (instance == null)
        return;

    // Analyzers собираем через рефлексию куда-нибудь в поле.
    if (!Analyzers.Any(x => x.Execute(instance)))
        return;

    DrawWarningTriangle(rect);
}

private static void DrawWarningTriangle(Rect rect)
{
    var texture = Resources.Load<Texture>("Editor/warning"); // Путь к иконке.
    var aspect = (float) texture.width / texture.height;

    rect.y += 1;
    rect.height = rect.height - 2;

    var width = rect.height * aspect;
    rect.x += rect.width - width - 4;
    rect.width = width;

    GUI.DrawTexture(rect, texture, ScaleMode.StretchToFill, true, aspect);
}

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

Архитектура

Это только первое приближение картины, ее смутные очертания и штрихи. Оно показывает, что идея, скорее всего, имеет смысл. Еще оно мотивирует продолжить работу. Представим теперь, что прошло несколько лет: какой архитектура должна быть, чтобы не рассыпаться в ветрах времени?

Оценки

В первую очередь отметим, что результат работы анализатора (IAnalyzer выше) выражается только в «да» или «нет» (bool). Слишком поверхностно. Нужно нарастить семантику. В ООП (и не только) мы делаем это с помощью новых понятий, и, раз уж заговорили про оценки, можно так и написать — Diagnostic:

public interface IDiagnostic
{
    DiagnosticSeverity { get; } // Error, Warning, Hint.
    DiagnosticId { get; } // Уникальный идентификатор проверки.
    
    void Draw(Rect rect); // Rect будет попадать сюда из метода `OnItemGUI` (см. выше).
}

Предвижу замечание: что метод Draw делает в модели? Хороший вопрос. Хотя тут вообще прямая зависимость от Rect (View), что еще хуже. Но это как раз тот случай, когда ты знаешь, что никто не захочет всё это дело потом переиспользовать в каких-то консольных или WPF-проектах. А если и захочет, то изменить существующее проще (изменения вполне определенные и независящие от масштабов системы), чем закладывать избыточную сложность. Это если одним предложением. Еще проще: система анализа — проста, и усложнять ее заранее избыточно.

Интерфейс анализатора также подстраивается:

public interface IAnalyzer
{
    DiagnosticId DiagnosticId { get; }
    
    // Обобщили GameObject до общего Object, чтобы подставлять еще Component.
    IDiagnostic Execute(UnityEngine.Object context);
}

Теперь нужно убедиться, что анализ можно запускать тысячи раз в секунду на исполинских префабах. Намерения у нас, разумеется, полярны: или быстро без тормозов редактора, или никак.

Скорость

Итак, если анализаторов будет много (а это ожидается), то как их запускать сразу на всех объектах и чтобы быстро? Выше я писал о том, что EditorApplication.hierarchyWindowItemOnGUI отрабатывает слишком часто. Незачем многократно перепроверять объекты, когда кликаешь по сцене или ходишь по дереву, — ничего ведь не изменилось. Нужно еще какое-нибудь событие.

Например, EditorApplication.hierarchyWindowChanged оповещает об изменении элементов окна иерархии (добавляется/удаляется объект/компонент). Теперь можно провернуть такое: будем проверять объекты всё так же в момент отрисовки, но делать это будем не напрямую, а через некую AnalysisSession. Там инкапсулируем все существующие анализаторы и кэш.

Сценарий получится следующим:

  • я анализирую объект 1 (ключ — InstanceID);
  • в сессии прогоняются по нему анализаторы;
  • оказывается, в нём есть какие-то ошибки;
  • эти ошибки сохраняются в кэш по ключу 1;
  • теперь, сколько бы сцена ни перерисовывалась (hierarchyWindowItemOnGUI), сессия будет давать заранее подготовленный ответ;
  • в момент изменения сцены по hierarchyWindowChanged кэш сессии очистим, и информация обновится.

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

Осталось создать большущий префаб с теоретически бессмысленным уровнем вложенности и количеством дочерних объектов и гонять по нему анализ. Я в свое время предпочел собрать глыбу из почти 20 тысяч объектов, когда над каждым движением мыши редактор думал по 2 секунды. На таких масштабах уже неважно, сколько времени занимает анализ, — и без него невозможно работать. Потом каждый элемент системы (это и все отдельные анализаторы, и общий механизм их прогона) ускорял так, чтобы искры сыпались и молнии сверкали. Итого: на 2 секунды работы редактора приходилось 50 миллисекунд анализа.

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

Улучшения

Улучшали на основе сценария, когда на объекте был скрипт, а потом его удалили (или перенесли):

public sealed class MissingScriptAnalyzer : ComponentAnalyzer
{
    public override DiagnosticId DiagnosticId => DiagnosticId.MissingScript;

    public override IDiagnostic Execute(Component component)
    {
        if (ReferenceEquals(component, null))
            return Diagnostic.Error;

        return Diagnostic.None;            
    }
}

Подсказки

Первое соображение: одной иконки, очевидно, мало. Как и с булевскими да/нет — недостаточно исчерпывающе. Для начала неплохо бы использовать нативные подсказки Unity:

Код для такого анализатора немного меняется:

if (ReferenceEquals(component, null))
    return Diagnostic.Error
        .WithTooltip(“There is probably a missing script in game object.”);

WithTooltip в свою очередь оборачивает диагностику декоратором TooltipedDiagnostic (проще было — WithTooltipDiagnostic), где метод Draw делает сразу несколько вещей:

public void Draw(Rect rect)
{
    _diagnostic.Draw(rect); // Рисует декорированная диагностика.

    // Дорисовываем тултип.
    rect.y += 1;
    rect.height = rect.height - 2;

    var width = rect.height;
    rect.x += rect.width - width - 4;
    rect.width = width;

    var tooltipContent = new GUIContent(“”, _tooltipText); // Поле с текстом тултипа.

    GUI.Box(rect, tooltipContent, GUIStyle.none);
}

Метки

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

Иконки коротко подписываются (максимум 7 символов), и общий смысл ошибки виден издалека. Соглашусь, в некоторых случаях надпись положительно нечитаема (особенно, когда это, скажем, BND_PTH). Однако я заметил, такие недоаббревиатуры быстро запоминаются, особенно если проблемы всегда одни и те же.

Реализуется тоже с помощью декоратора, а в MissingScriptAnalyzer к вызову WithTooltip добавляется WithLabel:

if (ReferenceEquals(component, null))
    return Diagnostic.Error
        .WithTooltip(“There is probably a missing script in game object.”)
        .WithLabel(“MISSING”); // Вот это добавилось.

Глубокий анализ

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

Сворачиваем:

Отсюда новый вопрос: если на сцене лежит глубокая иерархия объектов, а я вижу только корневой, то что, если проблемный — один из самых закопанных?

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

Разворачиваем.

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

Исправления

Как же теперь не автоматизировать не только проверку ошибок, но и их исправление? У тех, кто работал с ReSharper, я уверен, в мозге есть отдельный нейрон, ответственный за Alt + Enter. С этой комбинацией клавиш связано всё теплое и душевное.

С ними, исправлениями, еще проще, чем с анализом, или по меньшей мере так же. Вот интерфейс:

public interface IFix
{
    DIagnosticId DiagnosticId { get; }

    void Execute(UnityEngine.Object context);
}

Выше мы описывали ContainsShadowAnalyzer. Теперь напишем для него фикс:

public sealed class ContainsShadowFix : ComponentFix<Shadow>
{
    public override DiagnosticId DiagnosticId => DiagnosticId.ContainsShadow;

    public override void Execute(Shadow shadow)
    {
        // Тут лучше заменять один компонент на другой, более эффективный.
        Undo.DestroyObjectImmediate( shadow );
    }
}

Вопрос тот же: когда выполнять? И если выделенный на сцене объект легко достается из Selection.activeGameObject, то с хоткеями не так уж просто. Во всяком случае, если в комбинацию включается Enter (обязательное условие).

Один из простейших способов — создать MenuItem с нужной последовательностью клавиш. Но тут мы ограничены строгим набором возможных комбинаций и не можем себе позволить Enter. Как не можем позволить его, используя объект Event.

Остается одно: импортировать user32.dll и использовать WinApi. Нам понадобятся такие методы:

  1. GetActiveWindow — возвращает указатель на очередь сообщений активного потока приложения (или IntPtr.Zero, если в фокусе другое приложение), позволяет узнать, что сейчас активен именно редактор Unity.
  2. GetKeyboardState — записывает в массив байтов текущее состояние клавиатуры (какие клавиши нажаты, какие нет).

Единственное, чего так и не получится сделать, — использовать Alt + Enter в качестве ключевой комбинации. Но Ctrl + Enter, как выяснилось, — отличная альтернатива.

Итого

Получилась бесхитростная и легкая система, направленная на две вещи:

  1. Выискивать ошибки до того, как они попадут куда-либо.
  2. Автоматизировать мелочи.

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

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

На этом всё. Спасибо за внимание! Пишите вопросы в комментариях.

Пример рабочей системы можно посмотреть на GitHub.

LinkedIn

8 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

А как быть с поддержкой такой системы? Ведь чем больше тест кейсов тем больше времени на отладку/внесение правок.

Всё устроено так, что каждый анализатор обособлен от других и как бы замкнут в собственном пространстве. Если возникает проблема, скажем, с определением того, нужно ли включать Raycast Target у Image, исправляется/отлаживается данный конкретный `RaycastTargetAnalyzer`.

отдельный человек выделен или хвататет раз в месяц/неделю лиду пройтись?

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

Уточните, плз: Ваша команда работала до этого с Юнити?

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

Согласен, я только за — эдитор скрипты для этого и нужны). Просто по описанию выглядело так, вроде бы люди Юнити раньше не юзали, но явно это не указано, поэтому решил уточнить.

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

Подписаться на комментарии