По сей день Unity остаётся популярен у тысяч разработчиков. На этом движке созданы многие популярные игры, такие как V Rising, Beat Saber, Hearthstone, Genshin Impact и прочие. Насколько полезен был бы анализатор PVS-Studio разработчикам таких проектов? Какие ошибки и возможности для оптимизации кода он мог бы помочь найти? Давайте узнаем!
Для начала хочется отметить, возможно, очевидную вещь: написание кода для игры — занятие непростое. Поддержание качества этого кода — задача не менее трудная. Многие баги проскальзывают в релиз, после чего их находят игроки, что значительно ухудшает их первое впечатление и может стать ключевым фактором для решения написать негативный отзыв, обзор или вовсе удалить игру. Это в свою очередь может негативно отразиться на потенциальных продажах.
Помимо багов, у игровых проектов есть ещё один не менее, а может, и более критичный "камень преткновения": плохая оптимизация. Во многом это связано с тем, что обычно в игровом коде не используются преимущества параллелизма. Ещё разработчики часто пренебрегают оптимизацией таких вещей, как:
При этом они забывают, что их код может выполняться десятки раз в секунду. Если таких пренебрежений будет много, они могут значительно ухудшить производительность игры.
Как и баги, плохая оптимизация может негативно сказаться на впечатлении игроков. Кроме того, она является ограничивающим фактором, который может помешать дальнейшему развитию проекта.
Вывод из всего сказанного один: чтобы увеличить шансы на успех вашей игры, количество багов нужно минимизировать, а оптимизацию поддерживать на высоком уровне с самого начала разработки. В особо амбициозных проектах для этого не обойтись без специальных инструментов, таких как PVS-Studio. Особенно теперь, когда анализатор научился находить как специфичные ошибки, так и возможности для оптимизации в коде Unity-проектов! Именно с этими нововведениями мы хотим познакомить вас в этой статье.
На случай, если вы слышите это название впервые: PVS-Studio — статический анализатор кода, инструмент, который автоматически проверяет код вашего проекта без его фактического выполнения на наличие потенциальных ошибок, уязвимостей безопасности и возможностей для оптимизации.
Если вы плохо знакомы со статическими анализаторами кода, у вас может создаться впечатление, будто это что-то сложное в использовании, требующее основательного изучения, но это не так. Установка анализатора включает несколько простых стандартных шагов, а использование зачастую сводится к нажатию кнопки анализа и изучению предупреждений в удобном специальном интерфейсе в вашей IDE или редакторе кода.
Первые наши Unity-диагностики по большей части основаны на рекомендациях и специфичных моментах, о которых мы узнали из документации движка. Сейчас таких диагностик всего 20. В рамках этого пункта предлагаем рассмотреть лишь некоторые из них. Полный перечень вы сможете найти в конце статьи.
Ссылка на документацию к диагностике.
Итак, первая диагностика в нашем обзоре предупреждает об ошибках, связанных с далеко неочевидным поведением: неявной инициализацией полей ваших компонентов самим движком. Это происходит, если тип поля UnityEngine.Object
или наследуется от него (исключения — MonoBehaviour
и SerializableObject
), а сами поля могут быть отображены в инспекторе Unity.
Представим, что в нашем коде есть такое поле, значение которому не присваивается ни при его объявлении, ни в инспекторе Unity. В таком случае мы планируем инициализировать его некоторым значением по умолчанию во время выполнения. Это может выглядеть так:
[SerializeField] GameObject DefaultTarget;
public GameObject Target;
....
void Update()
{
var target = Target ?? DefaultTarget;
....
}
В Update
, если Target
не было явно задано значение, мы ожидаем, что поле будет равно null
. Но в действительности это не так, ведь Target
будет неявно инициализирован объектом, с точки зрения Unity эквивалентным null
:
Однако для оператора ??
этот объект является корректным, а потому переменной target
значение DefaultTarget
не присвоится.
Чтобы избежать подобных неожиданностей, для проверки на null
следует использовать операторы ==
, !=
или сокращённые проверки field
, !field
, которые имеют специальные перегрузки, учитывающие специфичные для Unity нюансы.
Ссылка на документацию к диагностике.
Класс Awaitable
— относительно новый механизм для асинхронного и параллельного программирования, который появился в 2023.1 версии Unity. По сути, он является аналогом Task
. Основное отличие в том, что в некоторых сценариях использования Awaitable
даёт значительный выигрыш в производительности. Получить больше информации об этом классе можно в документации Unity.
У Awaitable
есть свои ограничения. Одно из них состоит в том, что к каждому экземпляру Awaitable
оператор await
может быть применён лишь раз. Это связано с тем, что они объединяются в пул, и при применении к ним await
возвращаются в него. Повторное применение await
к той же ссылке может привести к непредсказуемым негативным последствиям, таким как выброс исключения или "состояние гонки".
Ссылка на документацию к диагностике.
Кроме асинхронного выполнения, класс Awaitable
предоставляет возможность вынести выполнение в фоновый поток с помощью Awaitable.BackgroundThreeadAsync
. Для этого нужно вызвать этот метод с помощью await
, после чего весь последующий код в текущем методе будет выполнен в фоновом потоке, но уже при обработке следующего кадра. Для возврата выполнения в главный поток (также с задержкой в кадр) используется метод Awaitable.MainThreadAsync
.
И здесь также есть своё ограничение: из фонового потока нельзя вызывать Unity API, меняющее какое-либо состояние в игре (загрузка новой сцены, изменение позиций объектов, запуск анимации и пр.). Это приведёт к непредсказуемым последствиям вроде зависания игры или выброса исключения. Однако можно безопасно использовать функции из Mathf
, а также все основные структуры данных вроде Vector3
и Quaternion
.
Ссылка на документацию к диагностике.
Эта диагностика предупреждает о попытке запуска корутины как обычного метода, без вызова StartCoroutine
. На первый взгляд, это ошибка новичка, однако она вполне может быть допущена из-за усталости или невнимательности, после чего потребуется потратить ещё некоторое время на выяснение, почему часть кода не работает без каких-либо сообщений об ошибках. Куда лучше после написания скрипта запустить анализатор, через несколько секунд получить предупреждение и сразу внести правку, не так ли?
Ссылка на документацию к диагностике.
Продолжаем отлавливать возможные проблемы с корутинами. Эта диагностика предупреждает о запуске корутины через передачу в StartCoroutine
строкового литерала. Само по себе это ошибкой не является, по крайне мере пока... Дело в том, что в какой-то момент название корутины может поменяться и, если это изменение также не будет учтено в подобных вызовах, они просто перестанут работать.
Лучше всего запускать корутину через передачу в StartCoroutine
её IEnumerator
, но если запуск по названию по каким-то причинам принципиален, стоит рассмотреть возможность использования оператора nameof
.
Как упоминалось ранее, некоторый код Unity-скриптов имеет очень большую частоту выполнения, в частности, в таких методах, как Update
, FixedUpdate
и т.д. Это означает, что в нём следует избегать:
Vector3
и Quaternion
.Однако в пылу разработки не всегда удаётся помнить про такие моменты, а потому хорошо иметь под рукой инструмент, который будет обращать ваше внимание на них, не правда ли?
Ссылка на документацию к диагностике.
Диагностика предупреждает о вызове распространённых и относительно тяжёлых функций, таких как FindObjectOfType
, FindGameObjectWithTag
, GetComponent
и пр., внутри часто выполняемого кода.
В подавляющем большинстве случаев вызывать эти функции, например, при обработке каждого кадра, очень избыточно, т.к. при каждом вызове они повторно вычисляют результат, который зачастую можно закэшировать и дальше переиспользовать уже закэшированные значения.
Ссылка на документацию к диагностике.
Все мы привыкли думать, что порядок множителей в математическом выражении не имеет значения. Однако когда в этом выражении участвуют не только привычные числа, но и комплексные структуры, такие как вектора, это уже не совсем так. В этом случае от порядка выполнения операций умножения может зависеть фактическое количество выполняемых вычислений. Последнее же, в целях оптимизации, лучше стараться минимизировать.
Диагностика обращает ваше внимание на такие возможности оптимизации. Для наглядности давайте посчитаем количество выполняемых вычислений в следующих двух вариантах одного и того же выражения:
_requestedMoveDirection * _moveSpeed * Time.deltaTime
;_requestedMoveDirection * (_moveSpeed * Time.deltaTime)
.Здесь _requestedMoveDirection
имеет тип Vector3
, тогда как _moveSpeed
и Time.deltaTime
— вещественные числа с типом float
.
В первом случае:
_requestedMoveDirection
на _moveSpeed
фактически будут выполнены 3 операции умножения, так как при умножении Vector3
на число каждая из трёх компонент вектора умножается на это число. В результате будет получен новый вектор.Time.deltaTime
, для чего также потребуются 3 операции умножения.Итого: 6 операций.
А теперь аналогичным образом разберём второй вариант:
_moveSpeed
умножается на Time.deltaTime
, т.к. это умножение числа на число, будет выполнена только 1 операция умножения, и результат также будет являться числом;_requestedMoveDirection
умножается на результат предыдущей операции. Для этого, как мы уже знаем, потребуются 3 операции умножения.Итого: 4 операции.
Разница кажется не очень существенной, но стоит помнить, что эта операция может выполняться несколько десятков раз в секунду, и таких операций в коде может быть немало.
Кроме того, представленное выше выражение весьма простое, но чем больше множителей будет в нём, тем заметнее будет эффект от оптимизации. Если сравнить следующие выражения:
Vector3 * float * float * float * float
;Vector3 * (float * float * float * float)
.То получится уже 15 и 6 операций, соответственно.
Ссылка на документацию к диагностике.
Знали ли вы, что регулярное создание и уничтожение игровых объектов в методах вроде Update
является плохой практикой? Это приводит к частому выделению и освобождению значительных объёмов памяти и заставляет сборщик мусора работать усерднее, замедляя вашу игру.
В большинстве случаев операции создания и уничтожения объектов можно заменить на операции включения, отключения и сброса состояния.
Так как отключённый объект всё равно "существует", занимаемая им память освобождаться не будет (а значит, и повторно выделяться). Кроме того, включение объекта обходится движку намного "дешевле" в плане вычислительных ресурсов, чем создание нового экземпляра объекта.
Если такой объект один (допустим, некоторое модальное окно), то можно создать его экземпляр, например, в методе Start
, кэшировать в поле и далее в Update
использовать это поле, при необходимости включая/отключая его на сцене.
Если регулярно создаваемых/уничтожаемых объектов (например, снарядов) множество, то в этом случае вам помогут пулы объектов. К слову, в Unity уже есть универсальные реализации пулов, для использования которых вам лишь надо определить несколько обработчиков.
Ссылка на документацию к диагностике.
В Unity 5.3 и более поздних версиях были представлены неаллоцирующие версии cast-методов в Physics.
Так, к примеру, для Physics.RaycastAll
появился неаллоцирующий аналог Physics.RaycastNonAlloc
. Вся разница между ними в том, что Physics.RaycastAll
создаёт новую коллекцию для результата при каждом вызове. Physics.RaycastNonAlloc
же принимает в качестве параметра уже созданный буфер, в который будут добавляться полученные RaycastHit
. В первом случае при каждом вызове происходит новое выделение памяти, что, как нам уже известно, нехорошо. Во втором случае буфер можно вынести в поле и с помощью метода Clear
сбрасывать его перед каждым новым использованием.
Стоит отметить, что, если буфер является обычным списком, вызов Clear
не приведёт к освобождению памяти, выделенной под этот буфер. Если вы об этом не знали, возможно, вам будет интересно ознакомиться с нашей статьёй, посвящённой особенностям реализации списка в C#.
Мы рассмотрели несколько примеров новых Unity-ориентированных диагностик, а полный список, как и обещал, вы можете найти ниже.
V3188. Unity Engine. The value of an expression is a potentially destroyed Unity object or null. Member invocation on this value may lead to an exception.
V3205. Unity Engine. Improper creation of 'MonoBehaviour' or 'ScriptableObject' object using the 'new' operator. Use the special object creation method instead.
V3206. Unity Engine. A direct call to the coroutine-like method will not start it. Use the 'StartCoroutine' method instead.
V3208. Unity Engine. Using 'WeakReference' with 'UnityEngine.Object' is not supported. GC will not reclaim the object's memory because it is linked to a native object.
V3209. Unity Engine. Using await on 'Awaitable' object more than once can lead to exception or deadlock, as such objects are returned to the pool after being awaited.
V3210. Unity Engine. Unity does not allow removing the 'Transform' component using 'Destroy' or 'DestroyImmediate' methods. The method call will be ignored.
V3211. Unity Engine. The operators '?.', '??' and '??=' do not correctly handle destroyed objects derived from 'UnityEngine.Object'.
V3212. Unity Engine. Pattern matching does not correctly handle destroyed objects derived from 'UnityEngine.Object'.
V3213. Unity Engine. The 'GetComponent' method must be instantiated with a type that inherits from 'UnityEngine.Component'.
V3214. Unity Engine. Using Unity API in the background thread may result in an error.
V3215. Unity Engine. Passing a method name as a string literal into the 'StartCoroutine' is unreliable.
V3216. Unity Engine. Checking a field with a specific Unity Engine type for null may not work correctly due to implicit field initialization by the engine.
V4001. Unity Engine. Boxing inside a frequently called method may decrease performance.
V4002. Unity Engine. Avoid storing consecutive concatenations inside a single string in performance-sensitive context. Consider using StringBuilder to improve performance.
V4003. Unity Engine. Avoid capturing variable in performance-sensitive context. This can lead to decreased performance.
V4004. Unity Engine. New array object is returned from method or property. Using such member in performance-sensitive context can lead to decreased performance.
V4005. Unity Engine. The expensive operation is performed inside method or property. Using such member in performance-sensitive context can lead to decreased performance.
V4006. Unity Engine. Multiple operations between complex and numeric values. Prioritizing operations between numeric values can optimize execution time.
V4007. Unity Engine. Avoid creating and destroying UnityEngine objects in performance-sensitive context. Consider activating and deactivating them instead.
V4008. Unity Engine. Avoid using memory allocation Physics APIs in performance-sensitive context.
Хочется отметить, что Unity-специфичные диагностики — это лишь малая часть возможностей PVS-Studio по анализу C# кода. Анализатор может находить множество ошибок, актуальных для всех C# проектов. Среди них диагностики, обнаруживающие:
Ознакомиться с полным перечнем C# диагностик (и не только) вы можете на нашем сайте.
Кроме того, в анализаторе имеются механизмы, корректирующие и улучшающие работу некоторых общих диагностик c учётом специфики Unity-скриптов. Таким образом, анализатор:
target
/!target
эквивалентны проверкам target != null
`target == null;
Если вы разработчик на Unity, и анализатор кода PVS-Studio привлёк ваше внимание, хотелось бы порекомендовать к прочтению пару наших статей:
Возможно, в них вы найдёте ответы на вопросы, которые могли у вас возникнуть при прочтении этой статьи.
Кроме того, мне также было бы интересно увидеть ваше мнение по паре вопросов:
Буду рад увидеть ваши ответы в комментариях :)
Также хотелось бы упомянуть, что вы всегда можете бесплатно опробовать PVS-Studio на собственных проектах, просто запросив триал на официальном сайте. До встречи в следующих статьях!
0